
浏览器插件这个领域过去很多年都被当成前端边角料——写个 content script 往页面里塞点 DOM再配个 popup 弹窗基本就能交差。但这两年情况完全变了。Chrome 全面推行 Manifest V3 之后后台页被 Service Worker 取代插件的生命周期、通信模型、资源加载方式全部重写与此同时端侧 AI 的推理能力开始往浏览器里下沉插件不再只是改改页面样式的小工具而是要承担模型加载、跨进程数据流转、离线推理调度这些实打实的工程任务。我最近完整地把一个带端侧 AI 能力的 MV3 插件从零搭到上线中间踩的坑比过去三年加起来都多。这篇就把 MV3 架构、跨进程通信、端侧 AI 集成这三块串起来讲清楚适合已经写过基础插件、想往工程化方向走的人也适合被 Service Worker 生命周期折磨过的同行对照排查。1. MV3 到底改了什么从常驻后台到事件驱动的范式切换很多人对 MV3 的抵触本质上不是讨厌新 API而是没接受后台不再常驻这个事实。MV2 时代 background page 是一个一直活着的页面你可以把全局变量、定时器、长连接都挂在上面随用随取。MV3 把这块换成了 Service Worker它会在空闲时被浏览器杀掉下次事件触发再重新拉起。这个变化不是优化是范式切换理解不到位后面全是坑。1.1 Service Worker 的生命周期为什么是核心矛盾Service Worker 的存活逻辑可以类比成随叫随到的临时工有事件比如收到消息、点击图标、网络请求拦截就唤醒干完活大约 30 秒没新事件就被回收。官方文档给的闲置回收时间是 30 秒但实测下来这个值并不稳定负载高的时候可能更短。这意味着任何依赖内存里存着某个状态的写法都会随机失效。我最初写的一个功能是用户点击插件图标开始一个持续几分钟的数据采集任务用setInterval每 5 秒抓一次数据。在 MV2 里跑得好好的迁到 MV3 后任务经常跑到一半就断了。原因很直接——Service Worker 被回收setInterval连同它的回调一起消失。正确的做法是把长任务拆成由chrome.alarms驱动的离散事件每次唤醒只做一小步状态落到chrome.storage里。// 错误示范依赖常驻内存的定时器 let counter 0; setInterval(() { counter; collectData(counter); }, 5000); // 正确做法用 alarms 驱动状态持久化 chrome.alarms.create(collect, { periodInMinutes: 0.1 }); chrome.alarms.onAlarm.addListener(async (alarm) { if (alarm.name ! collect) return; const { counter 0 } await chrome.storage.local.get(counter); await collectData(counter); await chrome.storage.local.set({ counter: counter 1 }); });这里有个细节值得说chrome.alarms的最小周期在打包发布的插件里被限制为 1 分钟periodInMinutes最小值 1只有在未打包加载的开发模式下才能用更短的值。所以如果你的任务需要秒级精度alarms 并不合适得换思路——要么用chrome.runtime.connect维持一个长连接端口port 存在时 SW 不会被回收要么把高频逻辑挪到 offscreen document 里。1.2 全局状态该往哪儿放storage、offscreen 与内存的取舍状态管理是 MV3 工程化的第一道分水岭。我把常见状态按存活需求分了三类对应三种存放位置这张表是我实际项目里总结出来的状态类型典型例子推荐存放位置原因跨会话持久状态用户配置、采集进度chrome.storage.localSW 重启后仍可读容量默认 10MB可申请 unlimitedStorage会话内临时状态当前任务 ID、临时 tokenchrome.storage.session浏览器关闭即清不落盘适合敏感临时数据高频计算/长任务模型推理、音视频处理offscreen document有独立 DOM 和完整生命周期不受 SW 回收影响chrome.storage.session是 MV3 后期才补上的 API很多人还不知道。它默认只在内存里浏览器重启就没了非常适合放那种不想写进磁盘但又需要跨 SW 唤醒周期保留的数据。我那个采集任务的任务 ID 就放在 session 里避免每次 SW 重启都重新生成导致任务对不上。至于 offscreen document它是 MV3 里被严重低估的能力。它本质上是一个隐藏的扩展页面能访问 DOM、能跑 Web Worker、能用URL.createObjectURL生命周期由你手动控制chrome.offscreen.createDocument/closeDocument。端侧 AI 的模型推理我最后就是放在 offscreen 里跑的原因后面第 3 节细说。1.3 资源加载与 CSP那些突然报错的远程脚本MV3 另一条硬性限制是内容安全策略CSP收紧扩展页面里不允许执行远程代码eval、new Function、远程script src全部被禁。这条规则直接干掉了一大批从 CDN 拉个库动态执行的写法。我遇到的具体问题是端侧 AI 用的推理库需要加载 WASM 文件最初我图省事从远程地址拉结果控制台报Refused to load the script because it violates the following Content Security Policy directive。解决办法是把 WASM 和模型文件全部打包进扩展用chrome.runtime.getURL()拿本地路径。// 打包进扩展的 WASM 路径获取 const wasmUrl chrome.runtime.getURL(wasm/inference_bg.wasm); const modelUrl chrome.runtime.getURL(models/model.onnx); // 注意manifest 里要声明 web_accessible_resources对应的 manifest 配置{ web_accessible_resources: [ { resources: [wasm/*, models/*], matches: [all_urls] } ] }提示web_accessible_resources在 MV3 里改成了对象数组格式必须显式声明matches否则资源无法被 content script 或页面访问。这个格式变化是迁移时的高频报错点。还有个容易忽略的点WASM 的加载在 MV3 里对wasm-unsafe-eval有依赖。如果你的推理库用到了动态编译 WASM需要在 manifest 的content_security_policy.extension_pages里加上wasm-unsafe-eval否则会静默失败。这个报错信息很不友好我第一次排查花了整整一个下午。2. 跨进程通信content script、SW 与 offscreen 之间的数据怎么走MV3 插件的进程模型比 MV2 复杂得多content script 跑在网页的渲染进程里Service Worker 跑在扩展自己的进程里offscreen document 又是另一个独立上下文。这三者之间传数据是工程化里最容易出 bug 的地方。我见过太多插件因为通信没设计好出现消息丢失、重复处理、状态不一致的问题。2.1 三种通信方式的适用边界先把可选的通信手段列清楚别一上来就无脑用chrome.runtime.sendMessagechrome.runtime.sendMessage/onMessage一次性请求-响应适合短消息。缺点是 SW 没醒的时候消息可能丢且不支持流式。chrome.runtime.connect/onConnect长连接 Port建立持久通道适合需要多次往返或流式传输的场景。关键优势是——只要 Port 还连着SW 就不会被回收。chrome.storage的onChanged事件间接通信适合广播状态变更而不是点对点传消息。我那个端侧 AI 插件的架构是这样的content script 负责从页面提取待处理文本通过长连接 Port 发给 SWSW 转发给 offscreen 做推理推理结果再原路返回。为什么用长连接而不是 sendMessage因为推理是异步且可能耗时的用 Port 能保证 SW 在整个推理期间不被回收同时支持进度回传。// content script 侧建立长连接 const port chrome.runtime.connect({ name: inference }); port.postMessage({ type: infer, text: extractedText }); port.onMessage.addListener((msg) { if (msg.type progress) updateProgressUI(msg.value); if (msg.type result) renderResult(msg.data); }); // SW 侧接收并转发给 offscreen chrome.runtime.onConnect.addListener((port) { if (port.name ! inference) return; port.onMessage.addListener(async (msg) { if (msg.type infer) { const result await forwardToOffscreen(msg.text, (p) { port.postMessage({ type: progress, value: p }); }); port.postMessage({ type: result, data: result }); } }); });2.2 消息丢失与重复一个真实的数据错乱案例讲个我踩过的坑。早期版本我用sendMessage做推理请求结果在批量处理 50 条文本时出现了结果和原文对不上的情况——第 3 条的结果显示成了第 7 条的。排查后发现两个问题叠加第一sendMessage是异步的我发出去 50 条消息没有等待响应就继续发SW 侧处理顺序和发送顺序不一致。第二SW 在处理过程中被回收了一次部分消息丢失而我的代码没有做超时重试导致回调错位。修复方案是引入请求 ID 做关联并且改用长连接保证顺序// 给每个请求打上唯一 ID let seq 0; function requestInference(text) { const id seq; return new Promise((resolve, reject) { const timer setTimeout(() reject(new Error(timeout)), 30000); pending.set(id, { resolve, timer }); port.postMessage({ type: infer, id, text }); }); } port.onMessage.addListener((msg) { const p pending.get(msg.id); if (!p) return; // 已超时或重复直接丢弃 clearTimeout(p.timer); pending.delete(msg.id); p.resolve(msg.data); });注意pending这个 Map 如果放在 SW 的全局作用域SW 被回收后就没了。所以要么保证 Port 连接期间 SW 不被回收长连接本身就有这个效果要么把 pending 状态也持久化。我选择前者因为长连接已经解决了存活问题。2.3 offscreen document 的创建时机与单例管理offscreen document 有个坑chrome.offscreen.createDocument在已经存在时会抛错。而 SW 被回收重启后你可能不知道 offscreen 是否还活着。所以创建前必须先检查async function ensureOffscreen() { const existing await chrome.runtime.getContexts({ contextTypes: [OFFSCREEN_DOCUMENT] }); if (existing.length 0) return; await chrome.offscreen.createDocument({ url: offscreen.html, reasons: [WORKERS], justification: Run on-device AI inference }); }chrome.runtime.getContexts是较新的 API比早期用clients.matchAll判断要可靠得多。reasons字段必须从官方枚举里选跑 AI 推理用WORKERS或BLOBS都行但 justification 要写清楚审核时会看。这里还有个实战经验offscreen document 不要频繁创建销毁因为每次创建都要重新加载 WASM 和模型开销很大。我的做法是首次创建后一直保留直到浏览器关闭。如果担心内存占用可以在闲置超过一定时间后主动closeDocument但要配合一个下次需要时重建的逻辑。3. 端侧 AI 落地模型怎么塞进插件、推理怎么跑得动把 AI 推理放到端侧动机很实际用户数据不出本地、没有网络延迟、不依赖后端成本。但浏览器环境对端侧 AI 并不友好——内存受限、没有 GPU 直通WebGPU 还在普及中、扩展还有 CSP 和包体积限制。这一节讲我实际跑通的方案。3.1 模型选型与量化为什么我最终选了 ONNX Runtime Web端侧 AI 在浏览器里的推理后端主要有几个选择TensorFlow.js、ONNX Runtime Web、WebLLM基于 WebGPU 跑大模型。我做的任务是文本分类和轻量语义匹配不是生成式大模型所以 WebLLM 那种动辄几百 MB 的方案直接排除。选型对比我整理成表方案适用场景包体积我的评估TensorFlow.js通用生态好中等API 友好但算子覆盖不如 ONNX 全ONNX Runtime Web跨框架模型算子全中等最终选择模型转换链路成熟WebLLM生成式大模型极大不适合轻量任务且强依赖 WebGPUTransformers.jsHuggingFace 模型直用中等底层也是 ONNX封装更厚最终选 ONNX Runtime Web 的核心理由是我的模型是从 PyTorch 导出的转 ONNX 最顺而且 ORT 的 WASM 后端在 CPU 上跑小模型完全够用。模型本身做了 INT8 量化原始 FP32 模型 45MB量化后压到 12MB精度损失在可接受范围内分类准确率从 94.2% 掉到 93.5%。量化这一步很关键直接决定插件包体积能不能接受。Chrome 应用商店对包体积没有硬性上限但超过几十 MB 用户下载体验会很差。我的做法是把模型文件单独放在web_accessible_resources里首次使用时才加载而不是打包进主 bundle。3.2 在 offscreen 里跑推理的完整链路为什么推理要放 offscreen 而不是 SW三个原因SW 没有 DOM很多推理库初始化依赖 DOM 或documentSW 会被回收推理跑到一半被中断是灾难offscreen 可以创建 Web Worker把推理放到 worker 里避免阻塞。完整链路是这样的// offscreen.js import * as ort from ./ort.min.js; let session null; async function initModel() { ort.env.wasm.wasmPaths chrome.runtime.getURL(wasm/); session await ort.InferenceSession.create( chrome.runtime.getURL(models/classifier_int8.onnx), { executionProviders: [wasm], graphOptimizationLevel: all } ); } async function runInference(inputIds, attentionMask) { if (!session) await initModel(); const feeds { input_ids: new ort.Tensor(int64, BigInt64Array.from(inputIds), [1, inputIds.length]), attention_mask: new ort.Tensor(int64, BigInt64Array.from(attentionMask), [1, attentionMask.length]) }; const output await session.run(feeds); return output.logits.data; } // 接收 SW 的消息 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) { if (msg.target ! offscreen) return; runInference(msg.inputIds, msg.attentionMask) .then((data) sendResponse({ ok: true, data })) .catch((err) sendResponse({ ok: false, error: err.message })); return true; // 保持消息通道开放以支持异步响应 });这里有个必须注意的点onMessage里如果要异步sendResponse必须return true否则消息通道会提前关闭响应发不回去。这个坑我在 SW 和 offscreen 之间来回调试了无数次才记住。分词tokenization这块也要在端侧做。我用的是一个精简版 BPE 分词器词表打包成 JSON加载后常驻内存。分词本身很快瓶颈主要在模型推理。实测下来单条 128 token 的文本INT8 量化模型在普通笔记本上推理耗时约 40-80ms完全能满足交互需求。3.3 性能与内存那些让插件卡死的细节端侧 AI 最怕的就是把浏览器拖卡。我踩过的几个典型问题内存泄漏ORT 的 Tensor 对象如果不释放多次推理后内存会持续上涨。虽然 JS 有 GC但 WASM 堆内存需要显式管理。我的做法是复用输入 Tensor 的缓冲区避免每次推理都新建大数组。首次加载卡顿模型首次加载要读 12MB 文件并初始化 WASM大概需要 1-2 秒。如果放在用户点击的同步流程里会明显卡顿。我的方案是插件安装后就在后台预热用户真正用时模型已经就绪。并发推理如果同时来多个推理请求WASM 后端是单线程的会排队。我加了一个简单的请求队列超过阈值的请求直接返回繁忙提示避免堆积。// 简单的推理队列 const queue []; let running false; async function enqueue(task) { return new Promise((resolve, reject) { queue.push({ task, resolve, reject }); drain(); }); } async function drain() { if (running || queue.length 0) return; running true; const { task, resolve, reject } queue.shift(); try { resolve(await task()); } catch (e) { reject(e); } finally { running false; drain(); } }提示WASM 后端的线程数可以通过ort.env.wasm.numThreads配置但多线程需要 SharedArrayBuffer而 SharedArrayBuffer 又要求页面处于跨源隔离状态。扩展页面默认不满足这个条件所以实际能用的还是单线程。别在这上面浪费时间。4. 工程化收尾调试、打包与上线后的那些事功能跑通只是开始真正让插件能稳定交付的是工程化环节。MV3 的调试体验比 MV2 差不少尤其是 SW 的调试很多人不知道怎么下手。4.1 Service Worker 的调试入口与日志技巧SW 的 console 不在普通 DevTools 里需要单独打开在chrome://extensions找到你的插件点击 Service Worker 那一行的链接会弹出一个独立的 DevTools 窗口。这个窗口关掉后 SW 的日志就看不到了所以调试期间别关。更麻烦的是 SW 被回收后之前打的日志全没了。我的做法是在关键路径上把日志同时写进chrome.storage.session这样即使 SW 重启也能回溯。另外chrome.runtime.onInstalled和onStartup事件可以用来标记 SW 的生命周期方便判断是不是被回收重启了。chrome.runtime.onStartup.addListener(() { console.log([SW] browser startup, SW fresh); }); // 在 SW 顶部打一个标记每次重启都会执行 console.log([SW] alive at, Date.now());如果这个 alive 日志频繁出现说明 SW 在被反复回收得检查是不是有长任务没拆好。4.2 打包体积控制与按需加载插件包体积直接影响审核和用户体验。我的控制策略模型文件、WASM 文件不打包进主 bundle放web_accessible_resources按需加载。用构建工具我用的 esbuild做 tree-shaking把没用到的 ORT 算子剔除。分词器词表做压缩JSON 换成二进制格式能省一半体积。实测下来主 bundle 控制在 200KB 以内模型和 WASM 加起来约 15MB整体在可接受范围。如果模型再大就得考虑分片加载或者放到后端了——但那就违背端侧 AI 的初衷了。4.3 上线后遇到的真实问题与修复上线后收到几类反馈都是测试阶段没覆盖到的低配设备上推理超时老机器上单次推理超过 200ms用户感知明显。修复是加了设备能力探测低配设备自动降级到更小的模型或者直接走规则匹配。多标签页并发用户同时开多个标签页每个页面的 content script 都发推理请求offscreen 队列被打满。修复是加了全局并发限制超出部分排队并给用户反馈。SW 与 offscreen 状态不同步SW 重启后不知道 offscreen 里的模型是否已加载重复初始化。修复是用getContexts检查 offscreen 存在性并通过消息确认模型状态。这些问题没有一个是文档里会写的全是实际跑起来才暴露的。我的体会是MV3 插件的复杂度已经从写脚本上升到了设计一个分布式小系统进程间的状态一致性、生命周期管理、资源调度这些后端工程里的老问题现在前端插件开发者也得面对了。如果你正准备从 MV2 迁移或者新做一个带 AI 能力的插件我的建议是先把 Service Worker 的生命周期模型吃透再动手写业务逻辑。通信层一定要用长连接加请求 ID别图省事用 sendMessage 裸奔。端侧 AI 这块模型量化和 offscreen 隔离是两条必须走的路绕不过去。至于调试早点习惯那个独立的 SW DevTools 窗口它会成为你最常待的地方。