1. 先搞清楚 MV3 换掉的到底是什么:不只是清单字段
标题里那个"差点废了"的感觉,我特别能理解。我第一次把一个跑了两年多的扩展往 MV3 上搬的时候,改完 manifest 一加载,扩展图标是灰的,后台逻辑全哑,控制台里飘着一行Service worker registration failed。当时第一反应是"是不是我字段名拼错了",折腾了大半天才反应过来:这不是字段拼写问题,是运行模型被换掉了。MV2 时代你写的是一个"长期驻留的后台页面",MV3 给你的是一个"随时会被回收的事件处理器"。这两者的差别,相当于把一个 24 小时开门的店铺,改成了一个只有客人按铃才出现的上门师傅——你的所有代码都得按"我随时可能不在场"来重写。
所以这篇不谈空话,我按自己实际迁移的顺序,把Chrome 扩展从 MV2 到 MV3 的迁移拆成几个真正会让你卡住的环节:后台服务的生命周期、网络拦截的重构、内容脚本与消息通道、权限模型收紧、以及打包上线阶段文档里不会明说的那些坑。每一段我都会说清楚"为什么要这么改",因为只抄代码不理解为啥,下一次遇到同类问题你还是会懵。这篇适合两类人看:一类是手上有个还能跑的 MV2 扩展,正在被强制升级逼着动刀;另一类是刚上手写扩展,想一开始就按新模型来,避免以后返工。前者能直接拿去对照改,后者能少走我走过的弯路。
1.1 三个硬约束,决定了你 80% 的改造工作量
MV3 的改动看起来零碎,但真正影响架构的其实就三条硬约束,我把它们列出来,你可以拿自己的扩展逐条对一遍,对完大概就知道工作量在哪个量级了。
| 约束 | MV2 的做法 | MV3 的限制 | 典型受影响功能 |
|---|---|---|---|
| 后台不再常驻 | background.page常驻页面,全局变量随便存 | 换成 Service Worker,空闲约 30 秒被终止,事件到来再唤醒 | 计时器、内存缓存、长轮询、状态机 |
| 禁止远程代码与动态执行 | 可以eval、可以远程拉脚本注入 | 扩展页面 CSP 禁止unsafe-eval,不允许加载远程可执行代码 | 模板引擎、旧版打包工具、在线配置下发的脚本 |
| 网络请求不能同步阻塞修改 | webRequest的blocking选项可读写请求 | 只能用declarativeNetRequest声明规则,规则匹配后才生效 | 请求头改写、重定向、按内容动态决策 |
第一条是绝大多数人翻车的地方,因为它不报错,只是"行为变了"——你的定时器不走了,你的缓存空了,你在后台存的登录态莫名其妙丢了。第二条会直接让你的扩展加载失败或者审核被拒,报错很明确,反而好处理。第三条最容易被低估:很多人以为declarativeNetRequest只是换个 API 名字,实际上它是把"过程式代码"翻译成"声明式规则",思维方式完全不同,后面我会单独用一章讲。
1.2 动手前先做一次能力盘点,别边改边发现
我现在带人做迁移,第一步永远是让对方列一张表:把扩展现有功能一条条写下来,标注它依赖的是哪种能力。这一步花半小时,能省掉后面好几天的返工。原因很简单——有些功能在 MV3 下不是"改写法",而是"做不到了",你必须提前想好降级方案或者直接砍掉,而不是改到一半才发现走不通。
盘点的维度我通常用这四类:后台状态类(登录态、缓存、计数器、任务队列)、网络干预类(改请求头、重定向、拦截资源)、页面注入类(内容脚本、DOM 操作、与页面 JS 交互)、权限与交互类(需要读取哪些站点的数据、是否需要用户手势触发)。每一类在 MV3 下的处理方式差别很大,混在一起改只会越改越乱。
提示:盘点时不要按代码文件划分,要按"用户能看到的功能"划分。同一个功能往往散落在后台、内容脚本、弹窗三个地方,按文件划分你会漏掉跨文件的依赖,最后上线才发现某个链路断了。
2. Service Worker 的空闲回收:状态到底该放哪
这一章是迁移里最费时间的部分,也是我踩坑最多的地方。MV3 的 Service Worker 在空闲大约 30 秒后会被浏览器终止,注意是"终止",不是"休眠"——你的全局变量、闭包里的引用、setInterval的句柄,全部清零。等下一个事件(比如用户点击图标、页面发来消息、闹钟触发)到来,浏览器会重新拉起一个全新的 Worker,你的脚本从头再执行一遍。如果你没意识到这一点,写出来的代码在手动测试时一切正常,交给用户用十分钟就崩。
2.1 一个计数器帮我把问题暴露干净
我当时的做法很土但很有效:在后台写一个全局计数变量,每次收到消息就自增,然后弹窗里显示这个数字。MV2 下它永远递增,MV3 下我盯着它数到 8、9、10,然后突然回到 1。那一刻我就明白了所有"偶发 bug"的来源——不是网络问题,不是用户操作问题,就是我自己把状态存在了内存里。
这个测试建议你也做一遍,成本五分钟,收益是你能亲眼看到回收发生的时机。具体做法:后台 Worker 顶层定义一个变量并打印时间戳,同时在每个事件回调里打印当前时间,你会发现两次事件之间的间隔如果超过半分钟,中间的执行环境其实已经换了一个。
// sw.js —— 仅用于验证生命周期,正式代码不要这么写 let bootTime = Date.now(); console.log('[sw] boot at', new Date(bootTime).toISOString()); chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { console.log('[sw] message at', new Date().toISOString(), 'bootTime still', bootTime); sendResponse({ bootTime }); return true; });跑一遍你就懂了:只要两次消息间隔超过回收阈值,bootTime就会变成一个更新的值——说明整个脚本重新执行了,你之前存的东西全没了。
2.2 三种存储介质的选择逻辑
知道会被回收之后,问题就变成"状态放哪"。MV3 提供了几个地方,选错的代价是性能问题或者数据丢失,我把判断标准整理成下面这张表,你可以直接套。
| 存储位置 | 生命周期 | 容量量级 | 适合放什么 | 不适合放什么 |
|---|---|---|---|---|
| 全局变量 / 模块级变量 | 单次 Worker 存活期,约几十秒 | 内存级 | 单次事件处理中的临时中间值 | 任何跨事件的业务状态 |
chrome.storage.session | 浏览器会话期,关闭浏览器清空 | 较小(MB 级) | 登录态、当前任务上下文、临时开关 | 需要长期保留的配置 |
chrome.storage.local | 持久化,除非用户清理 | 较大(数十 MB,可申请更大) | 用户配置、历史记录、缓存数据 | 高频写入的场景 |
chrome.storage.sync | 跨设备同步 | 很小(单项 8KB 量级) | 轻量偏好设置 | 大对象、频繁变更的数据 |
我个人的经验是:默认往session放,需要跨浏览器重启才用local,需要在事件之间传递的临时对象用完就删。很多人图省事全塞local,结果高频写入触发配额限制,或者数据残留导致下一次启动行为诡异。session是 MV3 才有的,权限声明里加storage就能用,它最大的好处是天然跟着浏览器会话走,用户关掉浏览器就自动清空,很适合放"当前这一次使用过程中的状态"。
2.3 定时任务:setInterval必须换成alarms
这一条几乎没有例外。setInterval在 Service Worker 里最多活几十秒,你设的 5 分钟轮询根本等不到第二次。正确做法是chrome.alarms,它由浏览器调度,Worker 被回收也不影响,时间到了会把 Worker 重新拉起来执行回调。
// 注册闹钟:注意周期的最小粒度是分钟级 chrome.alarms.create('poll-task', { periodInMinutes: 5 }); // 监听必须在顶层同步注册,不能放在异步回调里 chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name !== 'poll-task') return; await doPollingWork(); });这里有个我踩过的细节:alarms的周期下限受浏览器版本影响,早期最小是 1 分钟,新版本放宽了一些,但不要指望秒级精度。如果你的功能真的需要秒级定时,那说明设计方向就不对,应该改成事件驱动——用户操作触发、页面消息触发,而不是靠轮询。我见过有人在 MV3 里用setTimeout递归模拟秒级轮询,结果 Worker 频繁被拉起又回收,CPU 占用飙高,用户反馈"扩展很卡",最后还是要推翻重做。
2.4 监听器注册时机:顶层同步,永远是顶层同步
这一条是最隐蔽的坑。Service Worker 被唤醒时,浏览器只会把"注册在顶层"的监听器重新挂上,然后派发事件。如果你把chrome.runtime.onMessage.addListener写在某个async函数里面、或者写在chrome.storage.local.get().then()回调里,那么 Worker 被回收后重新拉起时,这个监听器根本没来得及注册,事件就直接丢了——表现就是"用着用着就不响应了,重启浏览器又好"。
我排查这个问题花的时间最长,因为它在开发阶段几乎不出现:你手动点开后台面板调试时,Worker 一直活着,监听器当然在。只有真实使用场景下 Worker 被回收了才会暴露。判断方法很简单:打开扩展的 Service Worker 调试面板,等它显示"已终止",然后再触发一次消息,看回调有没有执行。
// 正确:顶层同步注册 chrome.runtime.onMessage.addListener(handleMessage); chrome.runtime.onInstalled.addListener(handleInstalled); chrome.alarms.onAlarm.addListener(handleAlarm); // 错误:异步里面注册,Worker 重启后监听器缺失 chrome.storage.local.get('inited').then(() => { chrome.runtime.onMessage.addListener(handleMessage); // 别这么写 });如果初始化确实依赖异步读取的配置,正确姿势是"监听器顶层注册,回调里再去 await 配置"。监听器本身不做重量级工作,只是把事件接住,然后分发给具体的处理函数。
3. 网络层重写:把"写代码"换成"写规则"
如果说 Service Worker 是工作量最大的部分,那网络请求这块就是"思维方式最需要转变"的部分。MV2 里你可以用webRequest的blocking模式,同步拿到请求详情,爱怎么改怎么改,甚至可以根据响应内容决定下一步。MV3 把这个能力收走了,只留下declarativeNetRequest:你提前写好一组规则,浏览器在匹配到请求时按规则动作执行,你的代码不参与实时决策。
3.1 为什么必须这么改,以及它带来的能力边界
很多人第一反应是"这不是倒退吗"。从能力上看确实是收窄了,原因在于同步阻塞式拦截对性能影响太大,而且给了扩展过大的权限——一个扩展理论上能看到你所有的请求内容。声明式规则把决策提前到"规则加载时",运行期只做匹配,开销小得多。
代价是你要接受几个边界:规则只描述"什么请求做什么动作",不能读取请求体内容做条件判断;动作类型有限(主要是阻止、重定向、改写请求头或响应头);规则数量有上限,静态规则集有保证额度,动态规则一般在几千条量级。如果你的功能依赖"看内容再决定",那这条路走不通,得换方案。
3.2 静态、动态、会话规则的适用场景
三种规则我一开始没分清,用错了导致改一次配置要重新加载扩展,体验很差。它们的区别在于"规则什么时候确定、存在哪里"。
| 类型 | 声明位置 | 生效时机 | 数量额度 | 适用场景 |
|---|---|---|---|---|
| 静态规则 | manifest 的declarative_net_request.rule_resources | 扩展加载即生效 | 每个规则集有保证额度 | 固定不变的基础规则 |
| 动态规则 | 运行时调用updateDynamicRules | 持久化,重启后仍在 | 数千条量级 | 用户自定义、可增删的规则 |
| 会话规则 | 运行时调用updateSessionRules | 仅当前浏览器会话 | 数千条量级 | 临时性、一次性的拦截需求 |
我的用法是:内置的基础规则走静态,用户配置产生的规则走动态,那种"用户点了一下临时生效十分钟"的走会话。这样职责清晰,配置文件也不会太臃肿。
3.3 规则写法的几个实际坑
第一个坑是id必须唯一。动态规则增删时,如果你用数组下标当 id,删掉中间一条之后后面的 id 全乱,规则就错位了。我现在固定用一个自增计数器存在storage里,新增时取号,删除时按 id 精确删。
第二个坑是priority的默认值。多条规则命中同一个请求时,只有优先级最高的那条生效(同优先级下按动作类型排序,但别依赖这个隐式行为)。我一开始所有规则都没写priority,结果"允许"规则被"阻止"规则覆盖,调试了半天才发现是优先级问题。
// 动态规则的增删示例 async function addRule(rule) { const { nextId } = await chrome.storage.local.get({ nextId: 1 }); await chrome.declarativeNetRequest.updateDynamicRules({ addRules: [{ ...rule, id: nextId, priority: rule.priority ?? 1 }], }); await chrome.storage.local.set({ nextId: nextId + 1 }); } async function removeRule(id) { await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [id] }); }第三个坑是resourceTypes写得太粗。只写main_frame管不到子框架,只写xmlhttprequest管不到页面里用<img>发起的请求。这块建议对照官方枚举值逐个确认你的目标请求类型,别想当然。我在一个项目里就是因为漏了sub_frame,导致嵌套页面的请求完全没被规则命中,用户反馈"有的页面生效有的不生效",最后对着网络面板一条条比对才定位到。
3.4 声明式解决不了的部分,怎么优雅降级
有些需求确实无法用规则表达,比如"根据页面实际渲染出来的内容决定是否拦截某个资源"。我的做法是把决策前移到内容脚本:内容脚本在页面里先做判断,判断结果通过消息发给后台,后台再动态增删规则。虽然多了一次往返,但逻辑上是成立的,而且规则是持久化的,后续同类请求不用再判断。
这个模式的关键是规则的作用范围要收窄,用requestDomains、urlFilter把规则精确限定到目标,避免误伤。我见过有人为了图省事写了一条超宽的阻止规则,结果把页面自己的静态资源也拦了,页面直接白屏。规则一定要"最小命中",这是硬要求。
4. 内容脚本与消息通道:跨环境通信的那些细节
内容脚本这部分,MV2 时代大家写得比较随意,MV3 里因为后台形态变了,很多老写法直接失效。最典型的就是chrome.extension.getBackgroundPage(),它返回后台页的 window 对象,你可以直接调上面的函数、读上面的变量。Service Worker 没有 window,这个 API 也就没了,所有跨环境调用必须走消息。
4.1 隔离世界与主世界:该往哪边注入
内容脚本默认跑在"隔离世界",能操作 DOM,但碰不到页面自己的 JS 变量。如果你的扩展需要读取页面定义的全局对象、或者劫持页面自己的函数,就得注入到"主世界"。MV3 在content_scripts里支持world字段,直接声明"world": "MAIN"即可,比 MV2 时代用注入<script>标签的土办法干净得多。
但这里有个取舍:主世界里的代码和页面共享同一个执行环境,页面能改你的东西,你也能改页面的,风险更高。我的原则是"能在隔离世界解决就不要进主世界",非进不可的时候,注入的代码尽量短小,只做数据搬运,业务逻辑仍然留在隔离世界,两边用window.postMessage通信。
// 隔离世界侧 window.addEventListener('message', (event) => { if (event.source !== window) return; if (event.data?.type !== 'FROM_PAGE_WORLD') return; chrome.runtime.sendMessage({ type: 'PAGE_DATA', payload: event.data.payload }); }); // 主世界侧(注入的脚本) window.postMessage({ type: 'FROM_PAGE_WORLD', payload: collectSomething() }, '*');注意event.source !== window这个校验别省,不然页面里其他脚本发的消息也会被你接住,出问题时很难查。
4.2 消息通道的响应模型:return true这个细节
chrome.runtime.sendMessage的异步响应模型是另一个高频坑。监听器里如果要做异步操作再sendResponse,必须return true保持通道打开,否则通道提前关闭,发送方拿到的就是undefined。这个行为在 MV3 里没变,但因为后台变成了 Worker,异步操作更容易被打断,问题暴露得更频繁。
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type !== 'QUERY') return; (async () => { const data = await querySomething(); sendResponse({ ok: true, data }); })(); return true; // 关键:保持通道开放 });还有一种情况是内容脚本已经卸载(页面跳转了),后台还在往回发消息,这时会抛Receiving end does not exist。这个错误可以忽略但要捕获,不然控制台一片红,真出问题时反而看不到关键日志。
4.3 长连接保活:别指望它一直活着
有些场景需要后台和内容脚本保持长连接,用chrome.runtime.connect建立端口。MV3 下要注意,端口连接本身能让 Worker 多活一会儿,但不能无限延长,长时间没有消息往来,Worker 照样被回收,端口会断开。所以必须写重连逻辑。
我的做法是:内容脚本侧监听port.onDisconnect,断开后延迟一两秒重连,同时用一个心跳消息维持活跃(比如每 20 秒发一次)。心跳不要设得太密,否则 Worker 一直被唤醒,反而增加开销。这个平衡点需要实测,不同版本的回收策略略有差异。
4.4web_accessible_resources格式变化导致的 404
这个坑很典型:MV2 里这个字段就是一个字符串数组,列出一堆文件名就完事。MV3 改成了对象数组,每项包含resources和matches,必须声明这些资源对哪些页面可见。如果你直接把旧数组搬过去,扩展加载时就会报清单校验错误;如果你改了一半,最容易出现的是"资源请求 404"——因为matches写错了,页面根本没权限加载你的资源。
{ "web_accessible_resources": [ { "resources": ["assets/icon.png", "inject/main-world.js"], "matches": ["https://example.com/*"] } ] }我建议matches尽量精确,不要图省事写<all_urls>。一方面审核时宽泛的匹配范围容易被质疑,另一方面也确实是安全隐患——你的资源对所有站点可见,别人可以拿来探测你装了什么扩展。
5. 权限模型收紧:用户看到的第一屏就变了
权限这块改动看起来只是字段搬家,实际影响的是用户安装时的心理门槛。MV2 里permissions和permissions里的主机权限混在一起,MV3 把它们拆成了permissions(API 权限)和host_permissions(站点访问权限)。这个拆分不是为了折腾人,是为了让用户在安装页上能清楚看到"这个扩展要访问哪些网站",而不是一堆权限混在一起看懵。
5.1 拆分的具体写法与迁移对照
{ "manifest_version": 3, "permissions": ["storage", "alarms", "scripting", "declarativeNetRequest"], "host_permissions": ["https://example.com/*"], "optional_host_permissions": ["https://*/*"], "action": { "default_popup": "popup.html" }, "background": { "service_worker": "sw.js", "type": "module" } }顺便说几个容易漏的字段变更:browser_action和page_action合并成了action;content_security_policy从字符串变成了对象,分extension_pages和sandbox两个键;background的scripts数组换成了service_worker单文件(可以用type: "module"走 ES 模块,通过import拆文件)。
5.2activeTab与按需授权,能少要就少要
如果你的扩展只是"用户点了图标我才去读当前页面",那根本不需要host_permissions,用activeTab就够了——用户主动操作时临时获得当前标签页的访问权,安装页上也不会显示"读取你在所有网站上的数据"这种吓人的文案。这一条对转化率的影响比想象中大,我做过对比,把全站权限改成按需授权之后,安装后的留存明显好一些。
需要覆盖多个站点时,用optional_host_permissions配合运行时请求:
async function ensureHostPermission(origin) { const has = await chrome.permissions.contains({ origins: [origin] }); if (has) return true; return chrome.permissions.request({ origins: [origin] }); }注意permissions.request必须在用户手势(比如点击事件)的调用栈里触发,放到setTimeout或者异步回调深处会被拒绝,而且不报明确错误,只是返回false。这个限制我踩过一次,排查了很久才发现是"调用时机"的问题,不是权限本身的问题。
5.3 被拒权限时的降级路径要设计好
用户点了"拒绝"之后会发生什么,这是很多扩展没考虑的场景。我的做法是:核心功能不依赖敏感权限,敏感权限只用来增强体验。被拒的时候给一条明确的提示,告诉用户"当前功能受限,点击这里重新授权",而不是静默失败或者一直弹窗骚扰。
这里有个细节:chrome.permissions.contains是异步的,别在渲染关键路径上同步等它,会拖慢首屏。我的做法是先按"无权限"渲染一个骨架,拿到结果后再更新,用户感知不到延迟。
6. 加载失败、审核被拒:那些报错背后的真实原因
前面几章讲的是"改逻辑",这一章讲"改完之后跑不起来"。MV3 的加载失败报错往往很简短,但指向的问题五花八门,我按自己遇到过的顺序整理一下排查链路。
6.1Service worker registration failed的三种常见原因
第一种是脚本里有语法错误,这个直接看报错行号就行。第二种是顶层使用了浏览器不支持的 API,比如在 Worker 顶层直接document.querySelector,Service Worker 里没有document,一执行就抛异常,注册直接失败。第三种最常见也最隐蔽:顶层有import但 manifest 里没写type: "module",或者反过来,写了 module 但用了 CommonJS 的require。
排查方法:打开扩展管理页,找到你的扩展,点"Service Worker"链接进入调试面板,看 Console 的第一条错误。如果面板打不开(显示"已终止"且点不动),说明注册阶段就失败了,这时候要把 manifest 里的service_worker指向一个空文件测试,确认是清单问题还是代码问题。
6.2eval与远程代码:第三方库带来的隐性依赖
MV3 的扩展页面 CSP 不允许unsafe-eval,也不允许加载远程脚本。问题在于很多老库内部用了new Function或者eval做动态编译,你根本没直接调,但一加载就报EvalError。
我遇到过一次:项目里引了一个模板引擎,本地开发时用的版本没问题,打包上线后换成压缩版,压缩版内部用了Function构造器做优化,直接触发 CSP。解决办法有两个:换一个不依赖动态执行的库,或者把这个库放到沙箱页面里跑(sandbox页面允许更宽松的策略,但无法访问扩展 API,只能通过消息通信)。
注意:判断有没有踩这个坑,最简单的办法是在扩展的任意页面里执行一次
new Function('return 1')(),如果抛异常就是 CSP 拦了。开发阶段就该测一下,别等打包才发现。
6.3 审核阶段:权限说明与功能一致性
提交审核被退回,最常见的原因是"申请的权限和实际功能对不上"。比如你申请了全站主机权限,但实际只在一个站点用,审核方会要求你解释或者缩减。我的经验是:权限声明写到最小,功能描述写清楚每个权限的用途。这一步在迁移时顺手做掉,比上线后被退回再改要省事得多。
另一个容易被忽略的点是隐私说明。如果你的扩展会处理用户数据(哪怕是本地处理不上传),也要在描述里说清楚数据流向。这块内容按各平台的具体要求填就行,核心原则是"别让人猜"。
7. 我自己的迁移清单与实测顺序
写到这儿,把前面散落的东西收成一份可以照着走的清单。我现在的习惯是:先跑通最小闭环,再逐个模块搬,而不是一次性改完再测。一次性改完的问题是,出错了你不知道是哪个改动引起的,只能二分回退,效率极低。
7.1 分阶段推进的顺序
| 阶段 | 目标 | 验证方式 | 常见阻塞 |
|---|---|---|---|
| 第一步 | 清单改到能加载,Worker 能起来 | 扩展管理页无报错,调试面板有日志 | type: module缺失、顶层用了 DOM API |
| 第二步 | 消息通道打通 | 弹窗点按钮,后台能收到并回包 | 监听器没在顶层注册、忘了return true |
| 第三步 | 状态迁到 storage | 等待 Worker 被回收后再操作,状态仍在 | 把状态存在全局变量 |
| 第四步 | 定时任务改 alarms | 等待超过原定周期,任务仍触发 | 还在用setInterval |
| 第五步 | 网络规则改写 | 目标请求被正确拦截或改写,无误伤 | 优先级、resourceTypes不全 |
| 第六步 | 内容脚本与注入 | 页面内功能正常,主世界通信正常 | world声明错、资源 404 |
| 第七步 | 权限最小化 | 安装页文案合理,被拒后有降级 | 需要用户手势才能请求权限 |
每一步都单独验证,通过了再进下一步。我知道这看起来很慢,但实际比"改完一起调"快得多,因为每一步的失败原因都是收敛的,不会互相干扰。
7.2 三个我踩得最深的点,直接抄结论
第一个:Worker 里任何跨事件的状态都必须落盘。判断标准很简单——如果你在代码里看到let xxx = ...写在函数外面,就要问自己"下次事件来的时候它还在吗"。答案通常是"不在"。这个习惯改过来之后,我这类 bug 基本绝迹。
第二个:所有监听器注册必须在文件顶层同步执行。不管逻辑多复杂,监听器那几行永远放在最上面,回调里再去做异步的事。这一条没有任何例外,我试过各种"聪明"的写法,最后都会在 Worker 回收后翻车。
第三个:规则类的东西一定要有可视化的调试手段。declarativeNetRequest的规则错了不会报错,只会"不生效",你完全不知道是规则没命中还是动作没执行。我的做法是在扩展里做一个内部调试页,把当前所有动态规则的id、priority、urlFilter列出来,配合浏览器的网络面板对照。这个投入很值,后面每次调规则都能省大量时间。
还有一个实测下来很稳的小技巧:在 Worker 顶层加一行带时间戳的console.log,然后在调试面板里开着不关。你只要瞄一眼这个时间戳变没变,就知道 Worker 有没有被回收过。排查"偶发不响应"这类问题时,这一行日志比任何断点都管用,因为它不会干扰运行时,却能告诉你执行环境是不是换了一个。
最后说一句实在的感受:MV3 这次改动,难受的地方不在于 API 变了,而在于它逼着你把"依赖进程常驻"的偷懒写法全部清掉。我迁完之后回头看,代码其实比原来干净——状态管理明确了,事件边界清楚了,定时任务也不会因为一个异常就静默死掉。过程确实折腾,但折腾完之后的东西,比原来那套更能扛。