MV3浏览器插件迁移实战:跨进程通信与端侧AI摘要落地

📅 发布时间:2026/9/18 18:40:53
MV3浏览器插件迁移实战:跨进程通信与端侧AI摘要落地
如果说三年前有人告诉我浏览器插件会和“多进程通信”“端侧模型推理”这些词绑在一起我大概率会觉得是过度设计。直到我亲手把一个 MV2 时代只需要改页面 DOM 的小插件升级到 MV3 之后发现 background 会“死”、消息会丢、远程脚本不让跑了我才意识到插件早就不再是塞个 background.js 改改样式的小脚本而是一套跑在浏览器里的前后端工程。这篇文章记录的是一个真实的项目经验我把一个网页摘录工具重构成了 MV3并把端侧 AI 摘要能力直接塞进了扩展里。会涉及 MV3 架构迁移的踩坑过程、content script / Service Worker / offscreen document 之间的跨进程通信设计以及用本地小模型做推理的工程化细节。如果你是准备写 MV3 扩展的开发者或者正想给自己的插件加端侧 AI这篇内容应该能帮你少走不少弯路。1. 先搞清楚 MV3 改的到底是什么再谈迁不迁很多人在迁移的时候犯的第一个错误就是打开 MV3 的 manifest.json照着文档把background字段从scripts改成service_worker然后直接喊“迁完了”。实际上一旦触发到 Service Worker 生命周期、CSP 限制和权限模型这些底层变化原来的代码几乎要重写一轮。1.1 background“永远在线”已经是过去式MV2 时代的 background page 是一个常驻页面全局变量可以当作内存缓存用页面藏在幕后永远不关。MV3 把 background 换成了 Service Worker关键变化就是它会休眠也会被随时回收。V8 引擎为了保证内存和电量会在空闲一段时间后销毁整个 worker下次再有事件进来时重新拉起全局状态全部清零。这不是简单的“换了个运行环境”而是要求你把所有状态往chrome.storage里放尤其是跨事件需要保留的数据。chrome.storage.session这个 API 是 MV3 之后专门为这种场景准备的它只存在当前浏览器会话里但不会因为 worker 销毁而丢数据非常适合存“临时任务状态”。如果你在 MV3 的 Service Worker 里写了类似这样的代码let cache {}; chrome.runtime.onMessage.addListener((msg, sender, sendResponse) { cache[msg.tabId] msg.text; sendResponse({ ok: true }); });那你要小心了。Service Worker 一旦休眠cache就没了。后面再有消息进来你只能得到一个空对象。我当时排查了整整一个下午最后在 chrome://extensions 里盯着“Inspect service worker”反复点才意识到不是逻辑错了是 worker 被回收了。1.2 远程代码、eval 和 CSPMV3 的“防呆”设计MV3 明确禁止执行远程托管代码。也就是说你的 JavaScript 必须打包在扩展包里不能从 CDN 动态加载执行也不能用 eval、Function 构造器这类动态执行方式。这个限制对纯前端开发者来说可能没感觉但一旦涉及第三方库问题马上浮现。我最初做端侧 AI 功能时想图省事直接引 CDN 上的 Transformers.js结果扩展一加载就被 CSP 拦住控制台一整排报错。后来才明白MV3 扩展页面的 CSP 默认禁止 remote script必须把 JavaScript 文件下载到本地打进扩展包里。这条限制也意味着如果你用了某个库而那个库内部依赖 eval 或动态生成代码那在 MV3 里基本跑不通。选型的时候要多留个心眼尽量用纯 ESM 构建产物避免老式的全局 script 库。1.3 权限模型拆开permissions 与 host_permissions 到底有什么区别MV2 时代权限是可以混着写的很多老项目一个permissions数组把“所有能想到的权限”全塞进去。MV3 把权限分成了两类permissions浏览器能力比如storage、scripting、nativeMessaging、offscreen。host_permissions访问哪些站点的权限比如all_urls表示所有网站。区别在于host_permissions在安装时会有非常刺眼的提示用户看到“访问所有网站数据”这类描述时安装转化率会直线下降。如果你的功能只在用户在页面里主动点击的时候才需要注入脚本优先考虑activeTabscripting的组合而不是直接申请all_urls。我第一次做的版本就偷懒申请了all_urls结果一个面向内容创作者的社群测试中近三成人停在安装这一步理由是“权限太大”。后来改成用户点击插件图标时才通过scripting.executeScript注入内容提取脚本安装率明显回升。2. 跨进程通信Content Script、Service Worker、offscreen 怎么配合如果你把插件看成一个小操作系统那各个上下文就是不同的进程通信层就是它的系统总线。MV3 的跨进程通信本质上是在 Content Script、Service Worker、Popup、Options Page、Offscreen Document 这些“独立运行时”之间传递消息。2.1 一次“提取文章→后台处理→返回摘要”的消息全链路我做的插件核心功能是用户在当前网页上点击按钮插件提取页面正文摘要再用端侧模型生成要点列表。这个流程涉及三个上下文每一步都可能掉进消息丢失的坑。完整链路是这样的Popup 或页面按钮向 Service Worker 发起请求携带当前tabId。Service Worker 通过chrome.tabs.sendMessage(tabId, { type: EXTRACT_ARTICLE })通知 Content Script 提取正文。Content Script 把正文提取结果通过sendResponse回传给 Service Worker。Service Worker 把正文交给端侧模型推理模块生成摘要。结果通过chrome.tabs.sendMessage或 Popup 的 UI 显示出来。关键代码长这样先看 Service Worker 端chrome.runtime.onMessage.addListener((msg, sender, sendResponse) { if (msg.type RUN_SUMMARY) { handleSummary(msg.tabId, msg.text) .then(result sendResponse(result)); return true; // 重要表示你会异步调用 sendResponse } if (msg.type GET_ARTICLE) { chrome.tabs.sendMessage(msg.tabId, { type: EXTRACT_ARTICLE }) .then(article sendResponse(article)) .catch(err sendResponse({ error: err.message })); return true; } });Content Script 端chrome.runtime.onMessage.addListener((msg, sender, sendResponse) { if (msg.type EXTRACT_ARTICLE) { const text extractMainText(document.body); sendResponse({ title: document.title, text }); } return false; // 同步返回不需要 return true });一个很容易忽略的细节是chrome.tabs.sendMessage返回的是一个 Promise但它只有在 Content Script 已经注入该页面时才会成功。如果用户刷新了页面但插件没有重新注入消息就会抛错。稳妥做法是捕获错误后用scripting.executeScript做“应急注入”。async function ensureContentScript(tabId) { try { await chrome.tabs.sendMessage(tabId, { type: PING }); } catch { await chrome.scripting.executeScript({ target: { tabId }, files: [content.js] }); } }这条链路走到现在已经能感受到“跨进程”的复杂度了——两个独立上下文的生命周期不同步通信就必须自己做兜底。2.2 一次性消息还是长连接什么时候用什么chrome.runtime.sendMessage是一次性消息请求-响应模型用完即焚。chrome.runtime.connect建立的是长连接基于Port对象双向推送消息适合模型推理进度这类“持续推送”的场景。我实际测试下来端侧 AI 推理短则几百毫秒、长则十几秒用户端需要能看到进度提示。如果用一次性消息Service Worker 那边还没算完消息通道可能已经被回收sendResponse就变成无效操作。换成 Port 之后后台可以每秒推送一次进度// Service Worker 端 const port chrome.runtime.connect({ name: summary-progress }); port.postMessage({ status: loading_model, progress: 0.3 }); // 推理完成后 port.postMessage({ status: done, summary });这里也有个反面教训不要以为开着 Port 就能让 Service Worker 永活。长期空闲的 Port 同样会被浏览器回收只是有活跃消息时看起来“更稳定”而已。所以关键状态还是得落盘到chrome.storage.sessionPort 只当作“临时通知管道”使用。2.3 Native Messaging比很多人想象中更能打的“真跨进程”通信MV3 里的“跨进程通信”有时指的不只是扩展内部不同上下文而是扩展和浏览器外部本地程序之间的通信这就是 Native Messaging。为什么要用它因为端侧 AI 模型跑在浏览器里还是有限制如果我需要调用本地 Ollama、Python 推理进程或者一个 C 写的专用模型服务就必须通过 Chrome 的 Native Messaging 桥接到外部进程。Native Messaging 的机制是浏览器按需启动一个本地可执行程序然后通过标准输入输出stdin/stdout和它通信。消息格式不是普通 JSON 流而是“4 字节长度前缀 JSON 数据体”。关键配置有两块本地程序路径要写在一个 host manifest 里。host manifest 的路径要按操作系统注册到浏览器可发现的位置。host manifest 示例{ name: com.tabisum.local, description: TabiSum local inference host, path: /usr/local/bin/tabisum-host, type: stdio, allowed_origins: [chrome-extension://abcdefghijklmnop/] }Windows 上需要写注册表例如HKEY_CURRENT_USER\Software\Google\Chrome\NativeMessagingHosts\com.tabisum.local 默认值 C:\path\to\host_manifest.jsonmacOS 和 Linux 则是把 manifest 放到浏览器的 NativeMessagingHosts 目录里。Service Worker 里连接非常简单const port chrome.runtime.connectNative(com.tabisum.local); port.postMessage({ method: summarize, text: articleText }); port.onMessage.addListener((res) { updateProgress(res); }); port.onDisconnect.addListener(() { // 检查 chrome.runtime.lastError });我当时写 host 程序时踩了个很经典的坑Python 端用print()输出日志到 stdout结果 Native Messaging 拿到的不是合法 JSON连接直接断开。原因就是print()写的是 stdout而 Native Messaging 要求 stdout 必须只用来传协议数据。日志必须走 stderr。2.4 通信层最容易翻车的四个点消息体太大。把一个两三万字的网页正文直接塞进sendResponse短则卡顿长则超出消息体限制。我的做法是正文做一个轻量预处理比如去掉无意义的换行和脚注再按段落切块分批传给模型。JSON 序列化丢失类型。通过消息传递的数据会被序列化Date、Map、Error这些对象不会原样保留。我习惯在消息里只传普通对象错误对象用{ message: String(err) }传递。sender 上下文判断。后台收到多个 Content Script 的消息时需要用sender.tab?.id判断是哪个标签页发来的不能全往上一个 tab 回消息。并发消息竞争。用户连续点击两次“生成摘要”可能触发两个推理任务。后台需要按 tabId 维护任务队列新任务到来时取消或忽略旧任务。3. 端侧 AI 的工程化路线本地小模型不是把模型文件塞进去就完事浏览器插件跑端侧 AI听起来很有未来感但落到工程上有一堆现实问题模型文件放哪儿、怎么加载、怎么保证不同硬件都能跑、模型输出怎么解析。只有把这些都想清楚才有可能面向普通用户发布。3.1 为什么我坚持在插件里跑端侧推理云 API 方案在成熟度、模型效果上都比端侧好但有两个致命问题。一是隐私网页正文属于很敏感的内容让用户把整篇文章上传到云端大部分人心理上过不去。二是成本摘要功能如果频繁调用云端大模型作者和运营同学根本扛不住账单。端侧方案的好处是推理在用户本机完成隐私性天然更好用的时候没有网络延迟成本对开发者来说几乎为零。缺点也很明显模型效果不如云端大模型推理速度依赖于用户设备首次加载模型需要等待。3.2 三条可行路线的横向对比我实际对比过三条路线列个表格供参考路线优点限制适合场景Transformers.js浏览器内 WASM无需安装任何本地程序跨平台模型体积和内存占用受限于浏览器推理速度偏慢快速原型、小模型、想零依赖发布ONNX Runtime Web可以自己转模型、量化非常自由硬件加速更灵活需要适配 WebGPU/WebNN调试成本高定制模型、对体积有强约束Native Messaging 本地推理程序Ollama/Python/C可用大模型推理效率更高能复用现有 Python 生态用户必须额外安装客户端跨平台分发难度大面向技术用户、企业内网工具我用 Transformers.js 做了浏览器内版本同时用 Native Messaging 留了一个“本地大语言模型模式”的后门。默认走浏览器内小模型专业用户可以在本机装 Ollama插件检测到 host 存在后自动切换。3.3 Transformers.js 落地模型来源、量化、加载进度Transformers.js 是 Hugging Face 推出的浏览器端推理库核心思路是把 Transformers 模型的推理跑在 WASM 上。MV3 环境下不能直接在 HTML 里引 CDN必须把库文件打包进扩展。我的做法是这样。先安装 npm 包npm install huggingface/transformers然后在推理模块里初始化import { pipeline, env } from huggingface/transformers; env.allowLocalModels true; // 本地模型目录放在扩展包里 env.localModelPath chrome.runtime.getURL(models/); let summarizePipeline null; async function getPipeline() { if (!summarizePipeline) { summarizePipeline await pipeline(summarization, Xenova/distilbart-cnn-6-6); } return summarizePipeline; } export async function runSummary(text) { const pipe await getPipeline(); return pipe(text, { max_length: 120, min_length: 20 }); }这里的pipeline第一次调用会加载模型模型文件如果放在扩展包里扩展包体积会膨胀到上百兆商店审核和用户下载都会不愉快。我最终选择的是“首次使用按需下载”策略模型不打包第一次运行前通过后台下载到 IndexedDB 或浏览器的 Cache Storage加载进度实时推到 UI。模型下载和推理必须放到一个不会被 Service Worker 休眠打断的上下文里。我推荐在chrome.offscreen创建的 Document 里跑推理。原因是 Service Worker 生命周期太短不适合跑长任务而 offscreen document 是一个隐藏页面可以正常创建 Web Worker也不会因为页面切换被卸载。创建 offscreen documentchrome.runtime.onMessage.addListener((msg, sender, sendResponse) { if (msg.type START_AI) { chrome.offscreen.createDocument({ url: chrome.runtime.getURL(offscreen.html), reasons: [BLOBS], justification: Run local model inference }).then(() { chrome.runtime.sendMessage({ type: AI_TASK, payload: msg }); }); } });3.4 多线程 WASM 与跨源隔离Transformers.js 默认是单线程 WASM性能一般。想要用多线程需要SharedArrayBuffer而浏览器里要启用SharedArrayBuffer页面必须处于跨源隔离状态。扩展页面同样受这个限制。MV3 manifest 里可以这样配置{ cross_origin_embedder_policy: { value: require-corp }, cross_origin_opener_policy: { value: same-origin } }但开了跨源隔离之后扩展页面加载的跨域资源必须带正确的 CORP/CORS 头否则请求会失败。如果你的模型文件放在自己的服务器上记得给资源加Cross-Origin-Resource-Policy: cross-origin。如果模型全部放在扩展包本地这个问题会少很多。4. 一个可复用的工程样例网页内容本地摘要助手前面讲了不少原理这里直接给一套我在实践中跑通的工程结构你可以按这个框架去改造自己的插件。项目名字我叫它 TabiSum。4.1 项目目录与关键依赖tabisum/ ├── manifest.json ├── background.js ├── content.js ├── offscreen.html ├── offscreen.js ├── popup/ │ ├── popup.html │ └── popup.js ├── lib/ │ └── transformers.min.js └── models/ └── (按需下载首次运行后写入 Cache Storage)核心依赖只有一个huggingface/transformers其余全部用浏览器原生 API 实现。这样扩展包初始体积可以控制得很小。4.2 manifest.json 配置细节{ manifest_version: 3, name: TabiSum 本地摘要助手, version: 1.0.0, minimum_chrome_version: 109, background: { service_worker: background.js, type: module }, action: { default_popup: popup/popup.html }, permissions: [ storage, scripting, activeTab, offscreen, nativeMessaging ], host_permissions: [], content_scripts: [ { matches: [all_urls], js: [content.js], run_at: document_idle } ], web_accessible_resources: [ { resources: [models/**, lib/**], matches: [all_urls] } ] }注意这里我把host_permissions留空了Content Script 里的正文提取逻辑依赖content.js静态注入。如果你想在用户点击后才注入就需要移除content_scripts配置改用scripting.executeScript。两种模式我都试过静态注入更省事但权限显示更重。4.3 background 消息路由Service Worker 的职责是做一个“消息路由器”它自己不处理重活只负责协调各上下文chrome.runtime.onMessage.addListener((msg, sender, sendResponse) { switch (msg.type) { case START_AI: ensureOffscreenDocument(); chrome.runtime.sendMessage({ type: AI_TASK, ...msg }); sendResponse({ ok: true }); break; case AI_PROGRESS: // offscreen 推送给后台后台再转发给 popup chrome.runtime.sendMessage({ type: AI_PROGRESS, ...msg }); break; } return true; });在这个模型里offscreen.js是真正的“算力节点”它负责加载 Transformers.js、下载模型、跑推理、回传结果。Service Worker 永远保持“瘦”这样即使它被休眠重活也不会半途而废。4.4 推理任务封装与结果回传offscreen.js 里我会把推理任务包成一个简单类方便处理并发class LocalSummarizer { constructor() { this.pipeline null; this.running false; } async ensureModel() { if (this.pipeline) return; postProgress({ status: loading_model, progress: 0.1 }); this.pipeline await pipeline(summarization, Xenova/distilbart-cnn-6-6); } async summarize(text) { if (this.running) return { error: BUSY }; this.running true; try { await this.ensureModel(); postProgress({ status: running, progress: 0.6 }); const result await this.pipeline(text, { max_length: 120 }); return { ok: true, summary: result[0].summary_text }; } finally { this.running false; } } }推理结果回传 Popup 时我建议只回传摘要文本和任务 ID不要把完整模型日志传给 UI。否则消息体无谓变大UI 层的 console 也会被刷屏。5. 上线前必须处理的现实问题做完 Demo 和跑通核心链路距离面向用户发布还隔着几个大坑。这一节我按踩坑顺序总结每一项都是真实项目里会遇到的。5.1 Service Worker 被回收后的三种典型表现一是静态资源突然加载失败。比如你从chrome.runtime.getURL(lib/helper.js)动态 import 一个模块但 Service Worker 刚被唤醒时环境还不完整偶尔会报Extension context invalidated。二是事件监听器丢失。如果你在全局用addEventListener注册了一个非chrome.*的事件worker 回收后再唤醒这个监听器可能没来得及重新注册。三是全局变量被清空这个前面说过了。我的处理方式是把所有状态读写收敛到一个StateManager底层用chrome.storage.session内存变量只做当前事件的临时缓存。5.2 大文本、长任务与 UI 反馈一篇文章两三万字完整塞进摘要模型会超出模型的上下文长度限制。我做了两层处理先按段落和正文结构裁剪把无意义的导航、页脚、脚本标签全部去掉再按 512 个 token 的窗口切块逐块生成摘要最后把每块摘要拼接后再做一次全局摘要。这种“分层摘要”方式会让推理时间翻倍但用户体感上更稳定。UI 上我会展示当前处理到第几块比如“正在分析第 3/7 段”避免用户以为插件卡死了。另外端侧推理会长时间占用 CPU风扇狂转、电池掉电快是必然的。如果是面向普通用户的插件一定要给一个“只在主动点击时运行”的开关不要做成网页一打开就自动分析。5.3 端侧推理的性能与兼容性测试不同电脑上的差异大得离谱。同一段 800 字的文章在 M 系列芯片的 MacBook 上可能只要 3 秒在几年前的老 Windows 笔记本上可能跑 30 秒。我的测试矩阵会覆盖Chrome 最近三个大版本新旧两代硬件支持 WebGPU 和不支持 WebGPU4GB 内存和 16GB 内存的设备局域网弱网环境测试模型下载失败后的重试逻辑性能不达标的设备上我会自动降级到 Native Messaging 模式如果本地也没有安装 Ollama就明确提示“设备性能不足”而不是让用户干等。5.4 商店审核和权限描述发布到扩展商店时权限描述和隐私策略是审核重点。申请了activeTab和storage这类权限商店会要求填写详细的“数据用途说明”。我的原则是能不申请的权限就不申请申请了就必须在隐私策略里讲清楚这个权限用来做什么、数据在哪里处理、是否上传。端侧 AI 有个天然优势我们可以大大方方写“所有文本处理均在本机完成不上传任何内容”。这句话在审核和用户信任上价值非常高。但前提是你的代码里真的没有任何远程请求否则被用户抓包发现问题口碑就全毁了。最后再分享一个个人经验做端侧 AI 插件不要一上来就追求大模型。先用一个小到极致、能在普通笔记本上快速跑的模型把交互链路和跨进程通信跑通再把模型换成更大一号的。这个顺序能让你把精力花在真正难的问题上——通信可靠性、任务调度、回退策略这些才是决定插件能不能长期稳定服役的关键。