浏览器扩展脚本为什么有时候不生效:注入时机、iframe 和单页路由,多多开票助手

脚本没生效,先确认它到底有没有被注入

我自己写的这个扩展,起初就是给自己用的,名字叫多多开票助手,官网 duoduoke.net。它盯的是电商订单和发票这条线,要在三种页面上去批量申请发票:拼多多的发票页、1688 的买家订单页,还有 1688 的站内聊天页。三段脚本的写法差不多,跑起来的命运却完全不一样:有的页面一切正常,有的页面控制台干干净净,一行日志都没有。

一开始我在代码里到处找问题,后来才想明白:脚本在那些页面上压根就没被注入,自然什么都不会打印。content script 的注入是浏览器按 manifest 里的规则做的,规则匹配不上,或者 frame 不对,代码连执行的机会都没有。

想清楚这一点,排查顺序也就顺了:先看有没有注入,再看注入了几个 frame,再看路由变化之后有没有重新注入。这篇按这个顺序讲。

确认有没有注入,有个土办法很管用:打开控制台,看左上角的执行上下文下拉框,它会列出当前页面里的所有 frame。逐个切过去,在控制台里敲一句 typeof chrome.runtime,能返回对象就说明这个 frame 上有扩展脚本在跑。这比在代码里到处加日志快。

给一句能直接记住的:content_scripts 默认只注入顶层文档,iframe 里的页面要显式声明 all_frames 才会被注入。

六条注册分开放,别写一条通吃的

我最早的写法是一条 matches: ["<all_urls>"],所有页面都注入,脚本里再自己判断 location.host。图省事,代价来得很快。

一是权限描述变难看。用户装扩展的时候,<all_urls> 会显示成「读取您在所有网站上的数据」,装的人一看就犹豫。二是每个页面都要跑一遍判断,包括那些永远用不上的页面。

现在的写法是分开注册,一段脚本只匹配它真正要待的页面:

复制代码
"content_scripts": [
  {
    "matches": ["https://mobile.yangkeduo.com/transac_modify_invoice.html*"],
    "js": ["invoiceAutomate.bundle.js"],
    "run_at": "document_idle"
  },
  {
    "matches": ["https://air.1688.com/app/ctf-page/trade-order-list/buyer-order-list.html*"],
    "js": ["1688OrderInvoice.bundle.js"],
    "run_at": "document_idle"
  }
]

注册条数变多了,但每条的范围一目了然。哪个页面用哪段脚本,看 manifest 就知道,不用去翻代码。

有一个副作用要提前知道:匹配不上的时候,浏览器是静默的。控制台不会有任何提示,你可能只是发现功能没反应。所以我在每段脚本的入口都留了一条能看见的信号,用来区分「没注入」和「注入了但出错了」。

run_at 三档,我用的是哪两档

run_at 决定注入的时机,一共三档。

document_start 是文档刚开始解析就注入,这时候 DOM 还没建起来,适合要抢在页面自己的脚本之前改行为的场景。

document_end 是 DOM 建完,但图片、样式这些还没加载完。

document_idle 是浏览器觉得空闲的时候,也是默认值,最晚可能落在 load 之后。

我这边分两种用法:拼多多的商品页用 document_end,因为要在页面自己的脚本铺开之前先接管一段;发票页、订单页、聊天页都用 document_idle。

这里要提醒一句,document_idle 不等于「页面已经加载完」,它只是个相对空闲的时机。所以别指望 run_at 替你等到某个按钮出现,元素什么时候出现得自己等。等的方式也别用死循环去问「出现了吗」,盯着 DOM 的变化,比每秒问一次省事得多。

iframe 里的页面,不加 all_frames 就进不去

1688 的聊天窗口不是一个独立页面,它嵌在 iframe 里。我一开始用同样的写法给聊天页注册脚本,订单页跑得好好的,聊天页没反应。

原因很直接:content_scripts 默认只注入顶层文档,iframe 不进去。

补上 all_frames 之后才进去:

复制代码
{
  "matches": [
    "https://air.1688.com/app/ocms-fusion-components-1688/def_cbu_web_im/index.html*",
    "https://air.1688.com/app/ocms-fusion-components-1688/def_cbu_web_im_core/index.html*"
  ],
  "js": ["1688InvoiceChat.bundle.js"],
  "run_at": "document_idle",
  "all_frames": true
}

这个坑的麻烦在于它太安静。宿主页面本身是好的,只有嵌进去的那一块没反应,很容易被当成聊天组件自己的毛病,其实是我的脚本压根没进去。

加上 all_frames,脚本会在每个 frame 里各跑一遍

all_frames 管的不是某一个 iframe,是匹配到的 frame 全都注入。聊天页上可能同时挂着好几个 iframe,于是同一份脚本会跑好几遍。

多个副本同时跑,会互相打架:都在等元素、都想发消息、都去动同一份状态。所以每个副本必须自己判断,我是不是那个该干活的。

订单页那份的判断写在入口第一行:

复制代码
// 只允许最外层文档跑,且地址必须落在订单列表页
if (window === window.top && location.href.includes('buyer-order-list.html')) {
  init1688OrderPage();
}

window === window.top 用来排除掉所有嵌在别人里面的副本,只留最外层那一个。聊天页那边不好这么简单处理,因为它的业务本来就在 iframe 里,所以改成按当前 frame 的地址参数判断:地址里带的订单号和会话对象,要跟手上这条任务对得上,对不上就不动。

顺着 frame 再说一件相关的事。我想在扫订单的时候顺便钻进 iframe 里取节点,写了个递归遍历,遇到同源的能进去,遇到跨域的会抛异常:

复制代码
if (String(node.tagName || '').toLowerCase() === 'iframe') {
  try {
    if (node.contentDocument) walkOpenShadowRoots(node.contentDocument, visitor, visited);
  } catch (_) {
    // 跨域 frame 直接跳过,这是有意的
  }
}

跨域 iframe 的 contentDocument 是 null,这不是暂时拿不到,而是浏览器从根上就不让访问。有人为了穿进去,会把 all_frames 和 host_permissions 一路放宽,那是拿权限换一点便利,我没这么干。

单页路由切换,脚本不会重新注入

这一条最容易吃亏,因为它只影响页面内部跳转的场景。

拼多多那套页面是单页应用。从订单列表点进一个详情页,地址栏变了,可文档没有重新加载。而 content script 是跟着文档走的,文档不重新加载,脚本就不会重新跑。

结果就是,我在列表页加的那个悬浮入口,点进详情页之后还挂在那儿;反过来,某个只有详情页才该出现的东西,永远不出现,因为脚本还是列表页那次跑的那一份。

处理办法是自己监听路由变化,变了就重新同步一次界面:

复制代码
window.addEventListener('popstate', syncEntry);
window.addEventListener('hashchange', syncEntry);
syncEntry();

function syncEntry() {
  if (shouldShowPddFloatingEntry(location.href)) createEntry();
  else removeEntry();
}

syncEntry 里不重建界面,只判断这个地址该不该有它:该有就建,不该有就拆掉。这样无论路由怎么跳,界面都跟得上。

pushState 不触发 popstate,我加了一条轮询兜底

上面那段代码看着完整,其实漏了一类跳转。

popstate 只在前进、后退这类历史导航时派发。而很多前端路由用的是 pushState 和 replaceState 直接改地址,这两个 API 不会派发 popstate,也不会派发 hashchange。也就是说,只靠这两个事件监听路由,会漏掉一部分页面内部的跳转。

我是测试时发现的:从某些入口点进去,悬浮按钮的位置不对,手动刷新一下又正常了。刷新会重新注入,问题自己就消失了,这类 bug 最容易漏掉。

补的办法很土,但稳:

复制代码
let lastHref = location.href;
setInterval(() => {
  if (location.href === lastHref) return;
  lastHref = location.href;
  syncEntry();
}, 1000);

一秒查一次地址栏,变了就同步。它不是最优解,最优解是去 hook 页面的 history.pushState,可那要往别人的运行时里插代码,风险更大。用一秒的延迟换一段不侵入的代码,这笔账我算得过来。

小结

三件事按顺序过一遍,脚本不生效的问题基本能定位。

先看匹配范围,matches 写窄了,脚本压根不来。再看 frame,嵌在 iframe 里的页面要 all_frames,但要记得它会进不止一个 frame,每个副本都得自己认领身份。再看时机,单页应用切路由不会重新注入,得自己监听,而且不能只认 popstate 和 hashchange。

这三件事有个共同的麻烦,出错都不吭声。匹配不上没提示,frame 不对没提示,路由变了没重新注入也没提示。所以我现在的习惯是给每段脚本的入口留一条能看见的信号,先确认它在不在,再去找它为什么不对。一个不说话的脚本,比一个会报错的脚本难查十倍。


关键词:content_scripts, 浏览器扩展, all_frames, run_at, SPA路由, pushState, iframe注入, Chrome扩展开发

相关推荐
じòぴé南冸じょうげん1 小时前
油猴脚本突然发现变成灰色了,无法使用?页面不生效?刷新页面没反应?
前端
Csvn3 小时前
组合式 API(Composition API)
前端
火柴就是我4 小时前
Android 打包报错 25.0.3
android·前端
BD_Marathon4 小时前
消息对象中字段的说明
java·前端·python
八荒启·交互动画4 小时前
Web特效025—用 Canvas 2D 做Web特效的定义与边界:这支画笔能做到哪一步
前端·webgl·网页特效·八荒启-交互动画·八荒启
Ai-_Man4 小时前
您您这可以把Dola的多个会话比如说。左侧的多个会话一次性导出吗?不是单条会话里面的多次会对话。用AI导出鸭,答案是可以的
开发语言·前端·人工智能·小程序
excel5 小时前
Nuxt 中使用 useHead 优化 SEO 与 GEO
前端
IT_陈寒6 小时前
SpringBoot启动慢得像蜗牛?原来是这个配置在捣鬼
前端·人工智能·后端
计算机魔术师6 小时前
Muse Spark跑赢Gemini,但真正的底牌是这种设计
前端