侧边栏壁纸
博主头像
Setyg的博客

当秋风染红落叶的时候,就是新生~

  • 累计撰写 86 篇文章
  • 累计创建 66 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

Tampermonkey 监听 URL 变化:让脚本跟着 SPA 路由重跑

Setyg
2026-10-01 / 0 评论 / 0 点赞 / 1 阅读 / 0 字

写油猴脚本时经常遇到这种情况:脚本在首页好好的,点进详情页就再也不执行了。原因很简单——@match / @include 只在文档加载时判断一次,而单页应用(SPA)里点链接根本不重新加载文档,只是用 History API 把地址栏改掉。想让脚本「跟着路由跑」,就得自己监听 URL 变化。

一、先搞清楚 URL 有几种变法

不同的变法触发的事件完全不同,这是后面所有坑的根源:

导航方式典型场景会触发什么
整页加载 / 跳转普通 <a> 链接、直接输地址文档重建,脚本重新注入,@match 重新匹配
改 hashhash 模式路由 #/list 到 #/detail/1hashchange(同时也会触发 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 的脚本只在主框架跑,这一点要一起考虑。

五、速查表

导航方式hashchangepopstatewindow.onurlchange要自己打补丁
整页加载 / 跳转———不需要,脚本会重新注入
改 hash✅✅✅不需要
前进 / 后退hash 模式 ✅✅✅不需要
pushState / replaceState❌❌✅需要

最短可用写法:头注释加 @grant window.onurlchange,然后 window.addEventListener('urlchange', handler);要完全可控或者兼容别的管理器,就 @grant none + @run-at document-start 给 history.pushState / replaceState 打补丁,再补上 hashchange、popstate,用 URL 去重、用轮询兜底。

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区