浏览器扩展圈子里最近讨论最多的一件事,就是 Manifest V3 的强制推行。我这套扩展从最初几十个用户的小工具,慢慢长到有几万人在用,后台逻辑堆了三年多,一直靠着常驻的后台页跑着各种定时任务和请求处理。这次平台方不再保留旧版本的过渡空间,我花了将近两周时间做 Manifest V3 迁移,中间踩的坑比预想的多得多——后台脚本被回收、定时器失效、请求处理能力被砍掉一半、打包的远程脚本直接报错。这篇就把整个迁移过程拆开讲,包括改造思路、代码前后对照、参数怎么算、报错怎么定位。如果你手上也有 Manifest V2 扩展要迁移到 MV3,或者正准备从零写一个新版扩展,下面的内容基本可以照着做。
1. 这次不是"兼容性提醒",而是能力模型换了底座
很多人把 MV3 迁移理解成改个版本号、换几个字段名,这种判断会让你在第二周直接推倒重来。我最早也这么想,直到后台脚本在调试器里反复显示"已终止",才意识到这次变的是整个扩展的运行模型。这一章先把底层逻辑讲清楚,后面所有具体改造都是从这里推导出来的。
1.1 从常驻后台页到事件驱动的 Service Worker
Manifest V2 的后台是一个隐藏的 HTML 页面,扩展装上之后它就一直挂在那里,全局变量、定时器、长连接、事件监听器,你想放什么放什么,只要浏览器不关,它就一直在。我在旧版扩展里就依赖这一点:后台维护了一个内存里的状态对象,记录当前标签页的采集状态、待处理队列、用户配置的临时覆盖值,页面侧随时通过消息读写它,写起来非常顺手。
Manifest V3 把后台换成了 Service Worker。它不是页面,没有 window,没有 document,没有 localStorage,最关键的是它会被主动终止。空闲大约三十秒(官方说法是事件循环空闲后一段时间,实际观测在 30 秒上下浮动),Service Worker 就会被浏览器回收,全局变量全部清零,等到下一个事件到来再重新拉起一个全新的实例。写惯了 V2 的人第一次遇到这个问题会觉得莫名其妙:代码没改,逻辑没动,怎么就跑不通了?
理解方式其实很简单。旧模型是"一个一直开着的值班室",新模型是"一个随叫随到的客服"。客服接完电话就下班,你下次打过去,来的是另一个人,他不会记得上一位客服跟你聊过什么,除非你把聊天记录写进了系统。所以 MV3 迁移的第一原则是:任何需要在两次事件之间保留的状态,都必须落到持久化存储里,内存里的一切都是临时的。
这条原则一旦确立,改造方向就明确了。原来放在全局变量里的东西,全部搬进chrome.storage.local或者chrome.storage.session;原来用setInterval跑的心跳,换成chrome.alarms;原来依赖后台页做 DOM 解析的,改用 offscreen document;原来靠长连接维持的会话,改成事件触发时按需重建。后面几章我会逐个给出具体做法。
提示:Service Worker 被终止时不会触发任何"我要下班了"的事件,你没办法在终止前做收尾。所有写操作必须在事件处理函数内部当场完成,不要攒着批量提交。
1.2 能力被重新划线:声明式规则与远程代码禁令
除了后台模型,MV3 还动了另外两处根基,这两处对功能完整性的影响比后台回收更直接。
第一处是网络请求处理能力。V2 时代可以直接接管请求,读取请求头、请求体,边看边决定放行还是拦截,逻辑可以写得非常复杂。MV3 把这套能力收敛成了声明式规则:你提前把规则写成 JSON,浏览器自己按规则匹配执行。好处是性能好、隐私边界清晰,代价是你不能在请求发生的瞬间跑一段自定义逻辑。我那个扩展里有一段基于请求内容做条件判断的代码,在 MV3 下根本没法平移到声明式规则里,最后只能重新设计产品逻辑,把"动态判断"改成"规则预置 + 页面侧二次处理"。
第二处是远程代码禁令。V2 时期为了方便热更新,很多人会把部分逻辑放在服务端下发,扩展里用 eval 或者动态插入 script 执行。MV3 明确禁止执行任何非包体内的代码,eval、new Function、远程 script 全都不能用,CSP 默认就是script-src 'self'。这条规则没有商量余地,所有逻辑必须打包进扩展包体,更新只能走商店审核流程。
这两条约束叠加起来,意味着迁移不是翻译代码,而是重新审视你的功能哪些是平台允许的、哪些必须换实现路径。我在动手前先列了一张功能清单,逐条标注"可直接迁移""需要换实现""必须砍掉",这张表后来救了我很多时间,因为你最怕的不是难改,而是改到一半发现方向不通。
1.3 什么样的扩展最该马上动手
不是所有扩展的迁移难度都一样。根据我这段时间的经历和帮别人看代码的经验,按改造量从大到小排一下,你可以对照自查。
| 扩展类型 | 主要风险点 | 预估改造量 |
|---|---|---|
| 深度依赖请求拦截与改写的工具 | 阻塞式请求处理能力不可用,需重写为声明式规则 | 高,可能需要重新设计功能 |
| 后台维护大量内存状态的工具 | 状态全部需要持久化,逻辑要拆成事件驱动 | 高 |
| 依赖远程下发逻辑或配置的工具 | 远程代码被禁,逻辑必须内联打包 | 中高 |
| 以内容脚本注入为主的工具 | 注入 API 换了名字,改动相对机械 | 中 |
| 只在弹窗里做纯前端交互的工具 | 几乎没有后台逻辑,改动最小 | 低 |
我那个扩展属于第二类加第一类,后台有状态、还要处理请求,所以两周时间里大概有十天花在后台重构和请求能力替换上。如果你只是弹窗类工具,可能一个下午就搞定了,不用被网上的恐慌情绪带跑。
2. 迁移前的盘点:用一张表把家底翻清楚
动手改代码之前,我建议留出半天时间做盘点。这个环节很多人跳过,结果改到一半发现某个功能找不到替代 API,前面写的全白费。盘点的目标是搞清楚三件事:manifest 里哪些字段在新版本里语法变了、代码里调用了哪些被移除或降级的 API、哪些功能在新模型下根本没有对应能力。
2.1 manifest 字段逐项对照
字段层面的变化是机械的,但坑在于有些字段名看起来一样,语义却变了。我把自己踩过的整理成下表,你照着改基本不会漏。
| V2 写法 | V3 写法 | 需要注意的地方 |
|---|---|---|
"manifest_version": 2 | "manifest_version": 3 | 改完之后所有校验规则都按新版走 |
"background": {"scripts": [...]}或{"page": "..."} | "background": {"service_worker": "sw.js"} | 只支持单文件,模块化用"type": "module" |
"browser_action"/"page_action" | "action" | 两个旧字段合并成一个,图标配置挪进default_icon |
"permissions"里混着主机权限 | 主机匹配拆到"host_permissions" | 混写会导致安装时不弹对应授权 |
"web_accessible_resources": ["a.js"] | 数组套对象,需带matches | 不写matches网页侧访问资源会 404 |
"content_security_policy": "..." | {"extension_pages": "..."} | 值也被收紧了,不允许远程源 |
| 无 | "declarative_net_request" | 声明式规则集入口,静态规则走这里 |
web_accessible_resources这个改动我印象最深。旧版是一串路径数组,新版变成了对象数组,每一项要写清楚这批资源允许被哪些站点访问。我第一次改的时候只写了resources忘了matches,结果内容脚本往页面里插图片时一直在控制台报资源加载失败,排查了快一个小时才反应过来是这里的问题。正确写法是这样:
{ "web_accessible_resources": [ { "resources": ["assets/icon-sprite.svg", "assets/panel.css", "inject/*.js"], "matches": ["https://*/*", "http://*/*"] } ] }matches尽量写窄一点,不要图省事写<all_urls>。这不是洁癖,而是审核环节会看这个字段,范围写得越宽,被要求说明用途的概率越高。资源本身也应尽量精简,能合并的合并。
2.2 API 使用面扫描
manifest 改完只是让扩展能装上,真正的工作量在代码层。我的做法是在项目里全局搜索几个关键 API 名,搜到一个记一个,最后汇总成一张改造清单。
重点搜索这几个词:chrome.tabs.executeScript、chrome.extension.getBackgroundPage、chrome.extension.getViews、eval(、new Function、XMLHttpRequest、setInterval、localStorage、webRequest。前三个是直接被移除的 API,中间三个在新环境下要么被禁要么不好用,后三个是生命周期和存储相关的隐患。搜出来之后逐个确认调用点所在的文件、属于哪条功能链路,标上改造方式。
这里有个经验:别急着改,先归类。我一开始看到setInterval就想换成alarms,后来发现有些定时器其实只是弹窗打开时的界面动画节流,弹窗一关就没了,根本不需要迁移。归类之后真正需要改的定时任务只剩下三个,工作量一下少了很多。扫描的目的是减少无效改造,不是把所有匹配项都当成问题。
3. 五处核心改造的实操落地
这一章是全文的主体,五处改造基本覆盖了绝大多数扩展的迁移需求。每处我都会给出改造前后的对照、关键参数怎么定、以及我在实际调试中遇到的问题。
3.1 后台脚本重写:把内存状态搬进存储
旧代码里我有一个模块级的对象state,存着用户配置的临时覆盖项和一个待处理的 URL 队列。V2 下它活得很好,V3 下每次 Service Worker 重启它就变回初始值,表现为"用户刚设置完,过一会儿就失效"。
改造思路是把状态分层。长期配置放chrome.storage.local,容量大、持久、重启不丢;当前会话内的临时数据放chrome.storage.session,浏览器关闭就清空,适合放"本次浏览会话中已经处理过的页面标记"这类数据;纯计算过程中的局部变量就留在函数内部,不要往外抛。
// 改造前:模块级状态,SW 重启即丢失 let pendingQueue = []; let sessionFlags = {}; // 改造后:读写都走 storage,函数内部不持有跨事件状态 async function pushPending(item) { const { pendingQueue = [] } = await chrome.storage.session.get('pendingQueue'); pendingQueue.push(item); await chrome.storage.session.set({ pendingQueue }); } async function drainPending() { const { pendingQueue = [] } = await chrome.storage.session.get('pendingQueue'); if (!pendingQueue.length) return; for (const item of pendingQueue) { await handleItem(item); } await chrome.storage.session.set({ pendingQueue: [] }); }这段代码看着比原来啰嗦,但它是安全的。chrome.storage的读写都是异步的,所以整个后台代码风格要从同步转异步,这点要有心理准备。另外注意读写之间没有原子性保证,如果两个事件几乎同时进来,可能出现覆盖。我的做法是给写操作加一个内存中的 Promise 队列做串行化——注意这个队列只用于串行控制,不承载业务数据,丢了也不影响正确性:
let writeChain = Promise.resolve(); function serializeWrite(fn) { writeChain = writeChain.then(fn, fn); return writeChain; }chrome.storage.session默认只有扩展自身的页面能访问,内容脚本读不到。如果你需要让内容脚本读到会话数据,要显式放开访问级别:
await chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' });这个设置我建议只在确实需要时开,默认的隔离级别更安全。放开之后,任何注入到页面的内容脚本都能读到这些数据,别往里塞不该让页面知道的东西。
注意:Service Worker 里没有
window和document,也没有localStorage。访问localStorage会直接抛异常,而且异常信息不明显,容易误判成别的问题。全局搜索把这类调用全清掉。
3.2 网络请求能力迁移到 declarativeNetRequest
这是改造量最大的一块。V2 里可以用阻塞式的方式处理请求,读到请求信息后实时决定怎么办;V3 里能实时观察但不能改,能改的只有预先写好的声明式规则。
先明确能力边界。观察请求这件事仍然可以做,声明webRequest权限后,chrome.webRequest.onBeforeRequest之类的监听器还能收到事件,只是不能返回拦截或修改的结果,只能看。真正能改变请求走向的是declarativeNetRequest,规则以 JSON 形式提供,支持的动作有四类:block阻断、redirect重定向、allow白名单放行、modifyHeaders改请求头或响应头。
静态规则写在包体里,通过 manifest 注册:
{ "declarative_net_request": { "rule_resources": [ { "id": "core_rules", "enabled": true, "path": "rules/core.json" }, { "id": "optional_rules", "enabled": false, "path": "rules/optional.json" } ] } }规则文件的格式如下,一条规则由id、priority、action、condition四部分组成:
[ { "id": 1, "priority": 1, "action": { "type": "block" }, "condition": { "urlFilter": "||ad-delivery.example^", "resourceTypes": ["script", "image", "xmlhttprequest"] } }, { "id": 2, "priority": 2, "action": { "type": "redirect", "redirect": { "extensionPath": "/pages/blocked.html" } }, "condition": { "urlFilter": "||tracker-collect.example^", "resourceTypes": ["sub_frame"] } }, { "id": 3, "priority": 3, "action": { "type": "modifyHeaders", "requestHeaders": [ { "header": "referer", "operation": "remove" } ] }, "condition": { "urlFilter": "||cdn-assets.example^", "resourceTypes": ["image", "stylesheet"] } } ]几个参数必须说清楚,因为写错了规则不生效还不报错,非常难查。
priority是冲突时的裁决依据,数值大的优先。同一条请求同时命中allow和block时,高优先级的那条说了算。我一般把白名单规则的优先级设得最高,避免用户把自己要用的站点误伤。
condition里的resourceTypes建议显式写,不要依赖默认值。默认行为在不同资源类型上的表现不完全一致,显式声明能让规则意图清晰,也方便后期排查。常用的类型包括main_frame、sub_frame、script、image、stylesheet、xmlhttprequest、font、media、websocket、other。
urlFilter有一套自己的语法,||表示域名锚定,^表示分隔符,|表示首尾锚定。如果表达式复杂到不好写,可以改用regexFilter,但要注意正则匹配的性能开销比字符串匹配高,规则数量多的时候尽量用urlFilter。
除了静态规则,还有动态规则和会话规则。动态规则在运行时通过 API 增删,重启浏览器后仍然保留;会话规则只在当前浏览器会话有效。两者合计有数量上限,官方保证至少支持 5000 条,够绝大多数场景用了。
// 运行时更新动态规则:先删后加,避免 id 冲突 async function upsertRule(rule) { await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [rule.id], addRules: [rule] }); } // 只在本会话生效的临时规则 async function applyTempRule(rule) { await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [rule.id], addRules: [rule] }); }removeRuleIds这个参数一定要传,否则重复添加同一个 id 会直接报错。我在初始化逻辑里踩过这个坑:扩展重装之后动态规则还在,onInstalled里再添加同 id 规则就失败了,整段初始化中断,后面的逻辑全没执行。正确做法是把初始化写成幂等的,先删后加,或者先查一遍现有规则再决定要不要加。
chrome.runtime.onInstalled.addListener(async () => { const existing = await chrome.declarativeNetRequest.getDynamicRules(); const existingIds = existing.map(r => r.id); await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: existingIds, addRules: buildInitialRules() }); });调试规则有个技巧:getMatchedRules能查某次请求命中了哪些规则,但它依赖declarativeNetRequestFeedback权限,而这个权限只在开发版扩展里可用,上架版本用不了。所以规则调试必须在开发模式下做完,上线后只能靠日志和用户反馈定位。我的做法是开发期给每条规则写清楚注释,把规则 id 和业务含义对应起来,出问题时能快速反查。
3.3 注入逻辑迁移到 scripting API
chrome.tabs.executeScript在 V3 里彻底没了,替代品是chrome.scripting.executeScript。这个改动看着只是换个名字,实际上参数结构完全变了,而且多了一个很有用的能力:可以按 frame 精确注入、可以选择执行环境。
旧写法是这样:
chrome.tabs.executeScript(tabId, { file: 'content/highlight.js' }, (result) => { // 回调里拿结果 });新写法:
await chrome.scripting.executeScript({ target: { tabId, allFrames: false }, files: ['content/highlight.js'] });返回的是一个数组,每个元素对应一个注入的 frame,注入的脚本如果有返回值,会放在result字段里。这一点和旧版不一样,旧版直接返回结果值,新版要取results[0].result。
用函数注入比文件注入更灵活,尤其是在需要传参的时候:
await chrome.scripting.executeScript({ target: { tabId }, func: (color, width) => { document.documentElement.style.outline = `${width}px solid ${color}`; }, args: ['#2b7fff', 2] });func会被序列化后注入到页面里执行,所以函数内部不能引用外部作用域的变量,所有依赖必须通过args传进去。我第一次改的时候直接把外部变量写进函数体,注入后报未定义,找了半天原因。记住这条:func的函数体是独立世界,闭包不跟着走。
world参数值得单独说。默认注入到隔离环境,可以访问页面 DOM 但访问不到页面里的 JS 变量;设成world: 'MAIN'会注入到页面主环境,能拿到页面的全局对象,但也会受页面 CSP 影响,且和页面代码共享命名空间,容易冲突。只在确实需要读取页面 JS 状态时才用主环境,其余情况保持默认隔离环境更稳。
权限方面,scripting要写进permissions,目标站点的访问权限要走host_permissions或者通过chrome.permissions.request在运行时申请。V3 对权限申请的控制更严了,运行时申请必须由用户手势触发,不能在后台莫名其妙弹窗。
3.4 消息通道改造:return true 与异步响应
消息通信的 API 名字没变,但行为变严了。旧版允许你在onMessage监听器里写异步回调,只要最后调用sendResponse就行;新版里如果没有明确告诉浏览器"我要异步回复",监听器一返回,消息通道就关闭了,后续的sendResponse会静默失败,发送方永远等不到回复。
正确的写法是在监听器里同步返回true:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'collect') { (async () => { try { const data = await collectData(message.url); sendResponse({ ok: true, data }); } catch (err) { sendResponse({ ok: false, error: String(err) }); } })(); return true; // 关键:保持通道打开 } return false; });这个return true我漏过一次,现象是内容脚本发消息后一直挂着,既不成功也不报错,控制台一行日志都没有。后来在后台打断点才发现监听器返回了 undefined,通道当场关闭。这类问题没有报错信息,只能靠经验定位,所以看到"消息发出去没反应",第一反应就应该是检查return true。
还有一点更隐蔽:Service Worker 被回收之后,重新唤醒需要一个事件,而消息本身就能唤醒它。但如果消息发出时 Service Worker 正处于启动过程中,消息可能丢失。我的做法是在发送端加一次重试:
async function sendMessageWithRetry(message, retries = 2) { for (let i = 0; i <= retries; i++) { try { return await chrome.runtime.sendMessage(message); } catch (err) { if (i === retries) throw err; await new Promise(resolve => setTimeout(resolve, 150)); } } }长连接(chrome.runtime.connect)在 V3 下也要谨慎使用。Service Worker 被回收时端口会断开,另一端会收到onDisconnect。如果你的逻辑依赖长连接持续存在,等于依赖了一个随时会消失的通道,必须在断开回调里做重连,或者干脆改成按需发消息的短连接模式。我最后把长连接全换掉了,代码反而更简单。
3.5 资源与 CSP:远程脚本全部打包进包体
远程代码禁令带来的改造量取决于你原来的架构。我那个扩展为了做界面热更新,把一部分配置和渲染逻辑放在了服务端,V3 下这条路直接断了。
替代方案有三条,我最后混用了前两条:
第一条是全部内联打包。把逻辑写进包体,用import做模块拆分,构建时打包成单文件。缺点是更新要重新提审,好处是稳定、安全、无网络依赖。
第二条是数据与代码分离。服务端只下发 JSON 配置,扩展读取后用自己的渲染逻辑处理。这样仍然保留了配置热更新的能力,但逻辑本身在包体里。这里要注意,从远端拉下来的 JSON 如果被当成模板字符串插进 HTML,可能引入注入风险,必须做严格的字段白名单校验,不要直接把远端内容拼进 DOM。
第三条是用chrome.storage做配置缓存。远端配置拉取成功后落盘,下次启动先用缓存渲染,再后台更新。这套组合能显著改善启动时的界面空白问题。
CSP 字段也要改成新格式:
{ "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" } }extension_pages这个值不允许加入任何远程源,也不允许unsafe-eval。如果你的构建产物里有依赖eval的库(老版本的一些模板引擎、部分压缩工具产物),打包时会一切正常,运行时直接白屏。排查方法是打开扩展页面看控制台,会有明确的 CSP 拒绝日志。解决办法是换掉那个库,或者用不依赖eval的构建配置。我在一个模板引擎上卡了半天,最后换成了轻量的字符串拼接方案,代码量反而少了几十行。
4. Service Worker 生命周期:翻车率最高的部分
前面几章讲的是能力变化,这一章讲的是我踩坑最密集的地方——Service Worker 什么时候活、什么时候死、死了之后怎么恢复。这块的问题有个共同特征:本地调试一切正常,装上用一会儿就出问题,特别难复现。
4.1 用 alarms 替代 setInterval
setInterval在 Service Worker 里能跑,但不可靠。Service Worker 被回收时定时器一起没了,重新唤醒后也不会自动恢复。表现就是"前三十秒正常,之后不再触发"。
替代方案是chrome.alarms。它是浏览器层面的闹钟,不依赖 worker 存活,到点会唤醒 worker 执行回调。最小周期是 1 分钟(较新的版本把下限放宽到了 30 秒,但为了兼容性我建议按 1 分钟设计)。
chrome.runtime.onInstalled.addListener(() => { chrome.alarms.create('sync-job', { periodInMinutes: 1 }); }); chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === 'sync-job') { await runSyncJob(); } });如果确实需要更短间隔的检查,可以用"闹钟 + 页面侧驱动"的组合:闹钟负责兜底唤醒,页面可见时由内容脚本按较短间隔发消息触发。两条路径都调同一个幂等函数,不会重复执行。
还有一个细节:alarms的创建要在onInstalled和onStartup里都做一遍,或者用get先查一下存不存在。有些场景下闹钟会因为浏览器异常退出而丢失,只在安装时创建一次是不够的。
async function ensureAlarm() { const existing = await chrome.alarms.get('sync-job'); if (!existing) { chrome.alarms.create('sync-job', { periodInMinutes: 1 }); } } chrome.runtime.onStartup.addListener(ensureAlarm); chrome.runtime.onInstalled.addListener(ensureAlarm);回收这件事本身也有个正面用法:如果你有必须在后台持续跑的重活,应该把它拆成小段,每段在闹钟触发时处理一批,做完就退出。我原来有个一次性处理几百条数据的任务,在 V2 下跑得好好的,V3 下跑到一半 worker 被回收,数据只处理了一半。改成每批处理二十条、用闹钟驱动后,虽然总耗时变长了,但稳定性和可恢复性都上来了,中途被打断也能从存储里接着做。
4.2 offscreen document:需要 DOM 时的逃生舱
有些活必须在有 DOM 的环境里干,比如解析 HTML 片段、处理 Blob、做 canvas 相关计算、访问剪贴板。Service Worker 里没有这些能力,硬写只会得到一个又一个的undefined。
chrome.offscreenAPI 就是为这个场景设计的。它可以创建一个不可见的文档,提供一个完整的页面环境,用完再关掉。
async function parseHtmlFragment(html) { const has = await chrome.offscreen.hasDocument(); if (!has) { await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['DOM_PARSER'], justification: '需要 DOM 环境解析远端返回的 HTML 片段' }); } return await chrome.runtime.sendMessage({ type: 'parse', html }); }reasons是个枚举,必须从官方给定的列表里选,不能随便填。常用的有DOM_PARSER(解析 HTML)、BLOBS(处理 Blob 数据)、CLIPBOARD(剪贴板读写)、AUDIO_PLAYBACK(音频播放)。justification是一句人话说明用途,审核时会看,所以要写具体,别写"内部使用"这种糊弄的表述。
offscreen 文档的坑在于同时只能存在一个。多个任务并发调用createDocument时,第二个会报"已存在"错误。标准做法是封一层单例,用锁控制:
let offscreenLock = Promise.resolve(); function withOffscreen(fn) { const task = async () => { if (!(await chrome.offscreen.hasDocument())) { await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['DOM_PARSER'], justification: '解析页面片段用于内容提取' }); } return fn(); }; offscreenLock = offscreenLock.then(task, task); return offscreenLock; }还有一点容易被忽略:offscreen 文档也是要占资源的,用完应该主动关闭,尤其是只在安装时跑一次的初始化逻辑。长期挂着一个用不上的隐藏文档,属于给自己找麻烦。
5. 常见问题排查速查表
下面这些是我这次迁移过程中实际遇到并解决的问题。每一条都附上了现象、原因和定位方法,方便你直接对照。
5.1 典型现象与定位路径
| 现象 | 大概率原因 | 定位手法 |
|---|---|---|
| 后台逻辑跑一会儿就不执行了 | Service Worker 被回收,状态丢失 | 打开后台调试视图,观察是否出现"已终止" |
| 消息发出去没有响应,也不报错 | onMessage没返回true | 在监听器首行打日志,确认返回值 |
| 注入脚本报变量未定义 | func注入时引用了外部作用域变量 | 把依赖全部通过args传入 |
| 页面访问扩展资源 404 | web_accessible_resources缺matches | 检查资源路径与匹配规则 |
| 扩展页面白屏,无其他异常 | CSP 拦截了 eval 或远程脚本 | 打开页面控制台看 CSP 拒绝日志 |
| 规则明明写了但不生效 | 优先级被更高规则覆盖,或资源类型不匹配 | 逐条拆规则测试,检查resourceTypes |
| 初始化逻辑执行到一半中断 | 动态规则重复 id 导致抛错 | 捕获异常并检查removeRuleIds |
| 定时任务只在首次生效 | 用setInterval而非alarms | 全局搜索定时器调用 |
排查这类问题有个通用技巧:先看后台调试视图,再看页面控制台,最后看扩展管理页的报错。这三处的信息是分开的,很多人只盯着页面控制台,其实错误早就在后台视图里刷屏了。
打开后台视图的方式是在扩展管理页找到对应扩展,点击 Service Worker 那一行的检查入口。注意这个视图在 worker 被回收后会失去连接,需要点一下唤醒再重新打开。另外,后台视图开着的时候,worker 不会因为空闲被回收——这会干扰你复现问题,想测试回收行为必须把调试窗口关掉。
5.2 上线前的自测清单
改完之后不要急着提交,按这个清单过一遍,能挡掉大部分低级问题。
- 全新安装后走一遍完整功能链路,确认初始化逻辑没有依赖升级场景
- 从旧版本升级安装,确认数据迁移逻辑正确,旧数据能读出
- 关闭浏览器再打开,确认所有状态能恢复,不含内存残留依赖
- 后台放置超过两分钟不操作,再触发功能,确认没有因回收而失效
- 断网状态下启动,确认不会因为拉不到远端配置而完全不可用
- 内容脚本注入到不同类型页面(含 iframe 嵌套的场景),确认权限覆盖到位
- 在扩展管理页确认没有权限告警和 CSP 告警
- 检查包体大小,把没用到的大资源清掉
注意:升级路径的测试特别容易被跳过。很多问题只在"从旧版升级"时出现,因为
onInstalled的reason参数区分了全新安装和升级,而很多人只测了全新安装。
6. 过渡期的双版本共存策略
如果你的扩展有稳定的用户群,不可能一夜之间全量切换。我采用的方式是维护两个 manifest 文件,同一套业务代码,用构建脚本生成两个产物。这套方案能让新旧版本并行一段时间,降低切换风险。
6.1 一套代码兼容两个 manifest
目录结构大概是这样:
project/ src/ core/ # 业务逻辑,两份共用 background-sw.js background-page.html manifest.v2.json manifest.v3.json build.jsbuild.js根据环境变量选择 manifest 模板,把对应的后台入口拷进产物目录。业务逻辑里的差异点用适配层隔离,比如存储读写封装成一个统一模块,V2 下用localStorage或后台页变量,V3 下用chrome.storage,上层调用方式不变。
// storage-adapter.js export const store = { async get(key) { const result = await chrome.storage.local.get(key); return result[key]; }, async set(key, value) { await chrome.storage.local.set({ [key]: value }); } };适配层的好处是业务代码零改动就能同时跑在两个模型上。代价是适配层本身要设计得足够薄,否则会变成新的维护负担。我的做法是让适配层只处理"能力差异",不掺业务判断。
6.2 灰度发布与回滚预案
发布节奏上,我分了三步走。先发一个内测版,只给几十个愿意反馈的用户,收集一周问题;再放出一个比例很低的全量灰度,观察崩溃率和功能完成率;最后才完全切换。每一步都保留回滚到上一版的能力,回滚的关键是数据格式要双向兼容——新版本写入的存储结构,旧版本也要能读,否则回滚等于清空用户数据。
具体做法是给存储数据加一个版本号字段,读取时先看版本,高于自己认识的版本就走降级读取路径,只读认识的字段,忽略新增字段。写入时保留旧字段,不要删。这套做法看着保守,但真到了需要回滚的时候,能救你一次。
灰度期间还要盯几个指标:后台异常率、核心功能触发成功率、用户主动卸载率。前两个靠埋点,第三个只能看商店数据。我自己是在后台加了轻量的错误上报,把捕获到的异常类型和出现位置聚合起来,能比较快地发现哪个改造点有问题。
这次迁移真正让我改观的,是发现很多原来靠"后台一直活着"兜底的设计,本身就是脆弱的。以前没感觉,是因为平台帮你承担了容错。换成事件驱动之后,每一处状态、每一次唤醒、每一条通道都得显式设计,代码量上去了,但可靠性其实变好了——以前那种"偶尔失效、重启就好"的问题,现在能稳定复现,也就能稳定修复。如果你现在还在犹豫要不要动手,我的建议是先把第二章的盘点表填完,把功能和改造量摸清楚再决定节奏,别一上来就大面积重构,那样大概率会在半路进退两难。