写油猴脚本时经常遇到这种情况:脚本在首页好好的,点进详情页就再也不执行了。原因很简单——@match / @include 只在文档加载时判断一次,而单页应用(SPA)里点链接根本不重新加载文档,只是用 History API 把地址栏改掉。想让脚本「跟着路由跑」,就得自己监听 URL 变化。
一、先搞清楚 URL 有几种变法
不同的变法触发的事件完全不同,这是后面所有坑的根源:
| 导航方式 | 典型场景 | 会触发什么 |
|---|---|---|
| 整页加载 / 跳转 | 普通 <a> 链接、直接输地址 | 文档重建,脚本重新注入,@match 重新匹配 |
| 改 hash | hash 模式路由 #/list 到 #/detail/1 | hashchange(同时也会触发 popstate) |
| 前进 / 后退 | 点浏览器按钮 | popstate;hash 模式下另有 hashchange |
| History API 导航 | Vue Router history 模式、React Router,调用 pushState / replaceState | 什么都不触发 |
最后一行是关键:pushState 和 replaceState 按规范不会派发任何事件。所以「监听 hashchange 就够了」这个直觉,在现代 SPA 上一定会漏掉绝大多数导航。
二、五种捕获方式,从省事到兜底
方式 1:window.onurlchange(Tampermonkey 自带,优先考虑)
// @grant window.onurlchange
window.addEventListener('urlchange', (info) => {
console.log('URL 变化:', location.href, info);
});Tampermonkey 自己提供的事件,只要在头注释里 @grant window.onurlchange 就能用,它把 hash 变化、前进后退以及 pushState / replaceState 都覆盖了,是最省事的一种。几点注意:
- 不写
@grant window.onurlchange这个事件就不存在,addEventListener('urlchange', ...)会静默地什么都不做。 - 回调参数里带了变化信息,但字段名各版本之间有差别,稳妥起见处理逻辑里直接读
location.href。 - 它挂在
window上,iframe 里的地址变化不会冒泡到顶层文档;如果你的脚本跑在 iframe 里,就以那个 iframe 的 window 为准。
如果你的 Tampermonkey 版本上实测没触发(或者脚本要兼容别的管理器),用下面的方式 3。
方式 2:原生 hashchange + popstate
// @grant none
// @run-at document-start
function onUrlChange(type) {
console.log(type, location.href);
}
window.addEventListener('hashchange', () => onUrlChange('hashchange'));
window.addEventListener('popstate', () => onUrlChange('popstate'));纯原生、没有依赖,但只覆盖 hash 模式和前进后退。history 模式 SPA 的站内点击它一个都抓不到,所以只能作为补充。另外要用 addEventListener,别用 window.onhashchange = fn 这种赋值写法——页面自己很可能已经占了那个属性,赋值会把它顶掉。
方式 3:打补丁接管 pushState / replaceState(最通用)
把这两个函数包一层,调用完原函数后自己派发一个事件。要在页面脚本执行之前包好,所以 @run-at document-start:
// ==UserScript==
// @name 接管 History API
// @match https://example.com/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
function notify(type) {
window.dispatchEvent(new CustomEvent('userscript:urlchange', {
detail: { url: location.href, type: type }
}));
}
['pushState', 'replaceState'].forEach(function (method) {
const raw = history[method];
history[method] = function () {
const ret = raw.apply(this, arguments);
notify(method);
return ret;
};
});
window.addEventListener('popstate', function () { notify('popstate'); });
window.addEventListener('hashchange', function () { notify('hashchange'); });
// 自己的业务逻辑统一挂在这里
window.addEventListener('userscript:urlchange', function (e) {
console.log('URL 变化:', e.detail.type, e.detail.url);
});
})();两个前提必须满足:
@grant none:不申请任何特权时脚本直接跑在页面上下文里,改的是页面真正的history对象。- 脚本里出现了任何
@grant,脚本就进了沙箱上下文,此时要操作页面的真实对象得用unsafeWindow:
// @grant unsafeWindow
// @run-at document-start
const pageWindow = unsafeWindow;
const raw = pageWindow.history.pushState;
pageWindow.history.pushState = function () {
const ret = raw.apply(this, arguments);
// 通知逻辑
return ret;
};极少见的情况下补丁会失效:如果某个库在脚本注入之前就把 history.pushState 保存成了自己的引用,之后一直用那个引用调,你改 history.pushState 就拦不到它。这时候用方式 5 兜底。
方式 4:MutationObserver 触发检查
SPA 换页必然改 DOM,所以可以拿 DOM 变动当触发器,回调里比对 URL:
let lastUrl = location.href;
const observer = new MutationObserver(function () {
if (location.href !== lastUrl) {
lastUrl = location.href;
console.log('URL 变了', lastUrl);
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });比纯轮询省一点,而且天然带着「DOM 已经动过」的信息。代价是 DOM 变动频繁的页面上回调会被高频调用,需要自己做节流。
方式 5:setInterval 轮询(兜底)
let lastUrl = location.href;
setInterval(function () {
if (location.href !== lastUrl) {
lastUrl = location.href;
console.log('URL 变了', lastUrl);
}
}, 500);丑,但什么都不挑:任何一种改地址的方式都能发现,包括框架缓存了 pushState 引用的极端情况。适合当兜底,或者临时用来确认「到底哪种导航方式没被抓住」。
三、实战:按路由执行的完整骨架
把上面几种合起来,就是一个能长期用的骨架——首次执行一次,之后每次 URL 变化再执行一次,并且做了去重:
// ==UserScript==
// @name SPA 路由监听骨架
// @namespace https://example.com/
// @version 1.0.0
// @description SPA 里按路由执行,兼容 hash 与 history 两种模式
// @author You
// @match https://example.com/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
let lastUrl = null;
function onRouteChange(prevUrl, nextUrl) {
console.log('路由变化', prevUrl, '到', nextUrl);
if (nextUrl.indexOf('/orders/') !== -1) {
// 进入订单详情页要做的事
}
}
function check() {
const current = location.href;
if (current === lastUrl) return; // 去重:pushState 与 popstate 会重复触发
const prevUrl = lastUrl;
lastUrl = current;
// 路由变了不代表 DOM 渲染完了,等一帧再操作
requestAnimationFrame(function () {
onRouteChange(prevUrl, current);
});
}
// 1. 接管 History API(history 模式 SPA 的主要导航方式)
['pushState', 'replaceState'].forEach(function (method) {
const raw = history[method];
history[method] = function () {
const ret = raw.apply(this, arguments);
check();
return ret;
};
});
// 2. 哈希与前进后退
window.addEventListener('hashchange', check);
window.addEventListener('popstate', check);
// 3. 兜底:其他方式改了地址也能发现
setInterval(check, 500);
// 4. 首次执行
check();
})();几个设计上的取舍:
- 用
lastUrl去重:前进后退时popstate和补丁可能都触发,有的 SPA 每次渲染还会replaceState同步 query,不去重回调会狂跑。 - 用
requestAnimationFrame而不是直接操作 DOM:URL 变了只是路由层知道了,组件渲染是异步的,立刻querySelector往往拿到的是旧页面甚至 null。元素出现得晚就再加一层「等元素出现」的 MutationObserver。 - 首次调用
check():否则打开页面直接落在详情页时不会执行。 - 脚本要长期驻留的话,别忘了在合适的时候
clearInterval,或者用MutationObserver替换轮询。
四、常见坑
- 只监听
hashchange,history 模式全漏:这是最高频的一个。pushState/replaceState不派发事件,必须打补丁或用urlchange。 @run-at太晚:不少 SPA 初始化时就会replaceState一次来规范化地址,脚本注入晚了这次变化就丢了。打补丁的脚本一律document-start。- 沙箱里改不到真实对象:带了
@grant的脚本跑在沙箱上下文,补丁要打在unsafeWindow.history上,别改自己作用域里的history。 - 回调触发太多次:URL 去重是必须的,另外高频场景再加节流。
- URL 变了 DOM 还没好:见上面的
requestAnimationFrame/ 等元素出现。 - 跨出了
@match范围:路由跳到不再匹配的路径时,脚本是否被卸载、之后还能不能重新注入,跟 Tampermonkey 版本和导航方式有关,不要依赖这个行为安排时序——需要确定性就在回调里自己判断路径(location.pathname)决定做不做。 - 想直接借页面框架的路由钩子:Vue Router 的
afterEach、React Router 的订阅确实更准,但那是页面应用的 API,脚本里通常拿不到(除非它挂在全局且你用了unsafeWindow),通用做法还是监听 URL。 - iframes:
urlchange是 window 级的,iframe 里的变化不会传到顶层;头注释里加了@noframes的脚本只在主框架跑,这一点要一起考虑。
五、速查表
| 导航方式 | hashchange | popstate | window.onurlchange | 要自己打补丁 |
|---|---|---|---|---|
| 整页加载 / 跳转 | — | — | — | 不需要,脚本会重新注入 |
| 改 hash | ✅ | ✅ | ✅ | 不需要 |
| 前进 / 后退 | hash 模式 ✅ | ✅ | ✅ | 不需要 |
| pushState / replaceState | ❌ | ❌ | ✅ | 需要 |
最短可用写法:头注释加 @grant window.onurlchange,然后 window.addEventListener('urlchange', handler);要完全可控或者兼容别的管理器,就 @grant none + @run-at document-start 给 history.pushState / replaceState 打补丁,再补上 hashchange、popstate,用 URL 去重、用轮询兜底。
评论区