
做了这么多年浏览器插件有个特别直观的感受十年前写个插件本质就是往 background 页面里堆点 jQuery再加一个 content script 操作 DOMzip 一打包就能上架大家都管这叫脚本。但现在你再看一个正经的现代插件项目光目录结构就够写半篇 READMEService Worker、Content Scripts、Offscreen Document、Options Page、Side Panel再加上消息总线、状态管理、模型推理、CI 流水线这哪还是小脚本分明是一个完整的跨进程分布式应用——只是恰好跑在浏览器里而已。我今天想聊的就是这条从小脚本到工程化产品的演进路径重点拆解 MV3 架构带来的约束、跨进程通信的工程设计方案以及端侧 AI 在插件里怎么真正落地。这个话题适合谁正在把老 MV2 插件往 MV3 迁移的同学、打算从零写一个正经插件产品的前端工程师、以及对端侧 AI 集成好奇、想知道模型怎么塞进浏览器里的人。我会尽量用实际代码和踩坑记录来讲少谈空概念多给能直接用的方案。1. 从小脚本到正经工程MV3 到底改了什么1.1 MV3 的本质一场以安全为名的重构先纠正一个常见误解很多人把 MV3 当成一次普通 API 升级其实它是 Chromium 系浏览器对插件运行模型的一次彻底重构出发点非常明确——安全与性能。具体来说MV3 强制做了三件事。第一后台脚本从常驻的 background page 换成了 Service Worker也就是说后台代码不能一直占着内存浏览器随时可以把它杀掉再按需唤醒。第二修改网络请求的接口从阻塞式 webRequest 换成了声明式的 declarativeNetRequest开发者不能再任意拦截、改写每一个请求只能在规则表里声明什么请求怎么处理。第三全面禁止远程代码执行所有 JS 文件都必须打包在扩展目录里内容安全策略也变得更严格。这三件事对开发者的影响是连锁的。后台页面里不能随便挂全局状态了因为 worker 一休眠状态全没不能用 fetch 直接碰第三方接口了得先声明 host_permissions更不能偷偷从服务器拉一段 JS 来执行了代码审查变简单了但灵活性也没了。我见过不少团队在迁移时心态崩掉其实换个角度看MV3 是在强制你走向规范——把状态交给存储、把规则交给声明式 API、把代码全部纳入版本控制这本身就是一个工程成熟化的过程。1.2 Service Worker 替代后台页面后的连锁反应很多人第一次从 MV2 迁到 MV3 时最崩溃的就是 Service Worker 的休眠机制。在 MV2 时代background page 是一个真正持续的页面你在里面定义的全局变量、建立的 WebSocket、放的任务队列只要浏览器开着就一直在。MV3 的 service worker 则完全不同它在事件触发时被唤醒事件处理完就被挂起大约 30 秒内没有新事件就会销毁。这会带来几个典型问题我挨个说。全局状态丢失。你在 worker 顶层声明的变量可能在任意一刻被清空。解决办法是把关键状态持久化到chrome.storage.session这个存储专门为 MV3 设计worker 重启后还能恢复生命周期和会话绑定正好符合短期状态的诉求。定时任务不可靠。以前用setInterval做轮询的在 MV3 下会随 worker 销毁而中断需要配合chrome.alarmsAPI 来做定时唤醒。这不仅是 API 换了连触发精度和最小时长都得重新适应。事件监听必须顶层注册。在 MV3 中所有事件监听器比如chrome.runtime.onMessage必须在 worker 顶层同步注册不能放到异步函数或回调里。原因很简单浏览器要唤醒 worker 时需要先把事件派发给已注册的监听器如果监听器在异步代码里还没注册上事件就丢了。这个坑排查起来特别隐蔽因为本地开发有时能复现有时不能。1.3 远程代码禁令带来的约束MV3 另一条硬规矩是不能执行远程代码。这里的远程代码不仅指eval和new Function还包括任何从远程服务器下载并执行的 JS 文件。这条规则对很多插件影响巨大因为很多团队习惯把规则文件、动态脚本放服务器上随时更新不经过商店审核。MV3 之后这条路彻底堵死。当然静态资源并不算远程代码。JSON 配置、图片、模型权重这些数据类资源通过fetch拉取是允许的前提是你在 manifest 里声明了对应 host_permissions。这就给端侧 AI 留下了空间模型文件本质是数据只要走数据通道、不执行脚本就不违反规则。后面讲端侧 AI 的时候你就能看到这个边界在哪里——推理引擎是打包在扩展里的 JS模型只是被加载的数据两者严格区分。2. 跨进程通信插件内部的信息高速公路2.1 解构插件的进程模型要理解跨进程通信首先得理解现代插件到底由哪些进程组成。MV3 插件通常包含这几块。Service Worker 负责后台逻辑、事件响应、网络请求生命周期短Content Scripts 注入到匹配的网页中可以操作 DOM但它们运行在隔离世界isolated world里和页面脚本不共享 JS 环境Popup、Side Panel、Options 是普通扩展页面有完整 DOM生命周期跟用户打开与否相关Offscreen Document 是 MV3 新增的隐藏页面专门用来处理 Service Worker 访问不了的 DOM 能力比如剪贴板、音频播放、Cookie 读取等。这些组件之间不能直接互相调函数所有的通信都只能通过消息通道进行。这就像一个小型的微服务架构只不过消息通道是浏览器帮你搭好的。理解这一点非常重要你在 background 里定义的全局函数content script 根本看不见你想让 popup 里的按钮状态和后台同步也不能直接共享变量只能靠消息同步。所以设计插件架构的第一步就是把这个通信拓扑画清楚谁跟谁说话、走哪条通道必须在动手前定下来。2.2 消息通信的基本招式短消息模型最常用的是一对一的短消息。content script 往后台发消息用chrome.runtime.sendMessage后台监听chrome.runtime.onMessage事件响应即可后台主动往某个标签页发消息则用chrome.tabs.sendMessage(tabId, ...)。值得一提的是在 content script 里往 service worker 发消息时worker 会自动被唤醒所以很多时候你不需要担心休眠问题前提是事件监听器在顶层同步注册过了。实际项目中我建议把消息做一层封装定义一个统一的消息协议。比如所有消息都带type和payload字段后台用一个显式的分发器处理这样调试的时候能一眼看明白每个消息流向哪里也方便做日志埋点。别小看这一层抽象插件规模一大消息类型一多没有协议约束就会出现content script 发了个拼错的字段名后台静默失败这种让人抓狂的 bug。封装的时候还要注意一个细节sendMessage的回调函数有返回值为 true 表示异步响应这套机制配合Promise封装时容易踩坑。我习惯把所有消息都转成Promise风格统一await出错时把错误信息放进响应体里而不是直接抛异常这样调用方至少能拿到可读的错误原因。2.3 长连接与端口复用聊天室模式短消息适合问一句答一句但如果你需要持续推送数据比如后台监听某个事件不断给页面发通知或者 content script 需要周期上报页面变化再用sendMessage就会很别扭。每次发消息都要重新建立通道后台还要判断消息来源费时费力。这时候应该用chrome.runtime.connect/chrome.tabs.connect建立长连接通过Port对象收发明文消息连接建立后就是一个持续通道不用每次重新协商。长连接有个容易被忽略的点Service Worker 平时会休眠但长连接本身就是一个活跃事件源只要连接还开着worker 就会被持续唤醒。这既是好事也是坏事——连接多了会干掉休眠机制带来的性能收益。所以我在生产项目里的习惯是能用短消息就不开长连接确实需要持续通信的只开一条在消息里做多路复用。比如后台需要同时给多个 content script 推数据我会维护一个全局的BroadcastChannel思路——虽然有浏览器的原生 BroadcastChannel API但在插件场景下统一走 runtime 消息通道调试和管理都更方便。2.4 Offscreen Document被低估的隐藏页面MV3 推出后没多久很多人就踩到一个坑Service Worker 里没有 DOM、没有document于是像读取剪贴板、播放音频、把 canvas 转成图片导出这些原来在 background page 里顺手就能做的事全做不了了。Chrome 从 109 版本开始提供的 offscreen document 就是为了填这个坑。Offscreen doc 本质上是一个隐藏页面你可以用chrome.offscreen.createDocument创建它在内部执行任何 DOM 相关操作然后通过chrome.runtime消息和 Service Worker、Content Script 通信。它的生命周期也很讲究创建后如果不手动关闭会在一定条件下被回收所以需要一套管理机制——要么复用常驻要么按需创建再用完关闭两种策略按场景选。在我的实践里offscreen doc 最常见的用途有三个一是音频播放和采集比如插件做同声传译就需要在这里播放 TTS 合成结果二是剪贴板读写比如一键复制类插件三是跑端侧 AI 推理——canvas 做预处理、拿 WebGPU 上下文都需要真正页面环境。可以说它把 MV3 失去的页面能力补回来了同时也变成工程上一个必须认真管理的独立组件。很多团队迁移 MV3 时忽略了这个组件结果做到一半发现各种 DOM API 没法用回头再补架构代价就大了。3. 端侧 AI 插件化让模型在本地跑起来3.1 为什么要在插件里做端侧 AI说到端侧 AI先明确一下端侧在这里指的是什么不经过远程服务器直接在用户浏览器里完成模型的加载和推理。那为什么要在浏览器插件里做这件事我总结有三个最实际的理由。第一是隐私。很多插件处理的数据是用户私密的比如邮件正文、聊天记录、个人文档。如果每次都要把内容发到云端去分析用户心理上过不去合规上也麻烦。模型本地跑数据完全不出设备这在隐私敏感场景下是巨大的卖点。我自己做过一个邮件分类插件云端方案的合规评审拖了两个月换成端侧推理后很多问题直接不存在了。第二是延迟和可用性。云端推理依赖网络网络一抖结果就出不来。端侧推理只要模型加载好了响应时间基本是稳定的而且即使断网也能用。这个特性对翻译插件写作助手这类需要即时反馈的产品非常关键。你可以类比一下输入法本地词库和云端词库的差异本地永远是零延迟的。第三是成本。自己搭一个推理服务GPU 要钱、带宽要钱、运维要钱如果用户量起来了这部分成本非常可观。端侧推理把算力成本转移给了用户设备对中小团队来说是性价比很高的方案。当然代价是模型不能太大、效果受设备性能限制这是一个需要根据产品定位做的权衡。3.2 技术选型Transformers.js 与 ONNX Runtime Web在浏览器里做 AI 推理主流方案是 ONNX Runtime Web 和 Transformers.js两条路底层其实是相通的。ONNX Runtime Web 是一个通用的推理引擎它支持把 ONNX 格式的模型加载到浏览器里执行底层有 WebAssembly、WebGPU、WebGL 多个执行后端。好处是灵活什么模型你都能想办法转成 ONNX 跑代价是你要自己处理输入输出的预处理、后处理工程量大一些。如果你要跑的模型是自研的、或者 HuggingFace 上没有现成封装选这条路比较合适。Transformers.js 是在 onnxruntime-web 之上封装出的一个 HuggingFace 生态的库API 设计得很像 Python 版 transformers。你可以直接用它加载各种预训练模型做文本分类、抽取式问答、摘要、翻译等任务不用自己写 tokenizer 和推理管线。对插件开发者来说如果任务在它支持的模型列表里我强烈建议直接用 Transformers.js能省掉大量不必要的工作。还有一条需要考虑的路线是 WebGPU。Chrome 对 WebGPU 的支持今年越来越成熟用 WebGPU 后端跑大一点的模型速度比纯 WASM 快很多。但 WebGPU 依赖设备 GPU老设备或者某些系统下会 fallback 到 WASM需要做性能降级方案。选型的时候我的建议是功能走 Transformers.js 快速验证瓶颈再用 ONNX Runtime Web 做定制优化先跑通再优化不要一上来就钻进性能的兔子洞。3.3 一个可落地的端侧 AI 功能示例页面摘要助手到这里我们直接做一个可以跑起来的小功能用户在网页上选中一段文字插件右键菜单点击总结端侧模型生成一段摘要。这个功能我做实测过很多次方案组合如下。模型选择我推荐一个量化为 int8 的文本摘要模型比如基于 T5 或 BART 的小型版本模型体积尽量控制在 100MB 以内这样首次加载不会让用户等太久。如果做实验甚至可以用更小的蒸馏版本效果略差但体积可以压到几十 MB。推理环境放在 offscreen document 里。Transformers.js 加载模型时可以使用 Web Worker 或主线程service worker 虽然也可以用但容易碰到模型文件加载、缓存、内存限制等问题offscreen 更接近页面环境调试也方便。交互流程content script 捕获用户选区通过消息发给 service workerworker 唤醒或复用 offscreen document把文本交给推理模块推理结束后再把摘要结果回传给 content script渲染一个提示气泡。关键技术点有两个。第一是模型加载时机。不要等用户点击了才从网络拉模型应该在插件安装后通过后台任务把模型下载好并缓存到本地比如存到 Cache Storage 或 IndexedDB。这样用户真正使用的时候模型是秒开的。如果等用户点击才加载弱网环境下用户可能盯着转圈一分钟体验直接归零。第二是模型体积和内存。浏览器环境下加载一个 200MB 的模型很可能把低端设备直接内存打爆所以要做量化、要控制上下文长度必要时对长文本做分片摘要——先摘要各个片段再把片段摘要汇总成最终摘要。我自己在调试时还发现同一个模型在不同设备上的内存占用差异很大最好在发布前拿几台低配设备实测一遍。3.4 性能优化与踩坑记录我在这块踩过的坑不算少列几个最有价值的。不要把模型加载逻辑放在 Service Worker 里长驻。worker 休眠唤醒会反复加载模型内存也不好控制。放到 offscreen document 或独立 worker 线程里通过连接管理生命周期会更稳。这个坑我一开始就踩了上线后被用户反馈每次用都要等模型重新加载排查半天才发现是 worker 休眠导致的。量化是把双刃剑。int8 量化能让模型体积降一半以上速度也有提升但精度下降在某些任务上很明显。我一般的做法是先在测试集上跑一遍评估关键指标再决定用哪个量化级别不要无脑追求小体积。比如摘要任务量化后可能出现句子不通顺、关键信息丢失这些在集成测试里很难发现得专门做一轮质量评估。首屏体验要重点设计。模型文件再小也要几 MB 到几十 MB用户可能在弱网环境安装你的插件。务必要做进度提示、断点重试、版本更新后的重新下载逻辑。别让用户装完插件发现白屏根本不知道发生了什么。我见过不少插件因为模型加载进度没提示被用户当成 bug 卸载的案例。设备能力差距太大。同一台设备上WebGPU 推理和纯 WASM 推理的速度能差 10 倍以上。代码里要做一次能力探测优先 WebGPU不支持再降级 WASM最后如果主线程明显卡顿要主动把推理任务交给 Web Worker。这个降级链路最好在架构阶段就设计好后期硬加会很痛苦。4. 工程化插件项目的现代化改造4.1 项目结构设计一个能长跑的目录当你决定把插件当成正经产品来做第一件事是抛弃单文件或三两个 JS 的写法设计一套清晰的项目结构。我常用的结构是这样的。src/ background/ # Service Worker 入口只做事件分发 content/ # 各站点注入脚本 offscreen/ # 离屏文档逻辑 popup/ # 弹出页面 options/ # 设置页面 sidebar/ # 侧边栏如需要 components/ # 跨页面共享的 UI 组件 lib/ # 工具函数、消息总线、AI 推理封装 assets/ # 图标、样式 test/ unit/ e2e/ scripts/ build.mjs package.mjs这个结构最大的好处是每个进程或组件都有一块清晰的属地跨组件的通信统一走lib/messaging总线不会出现background 里 import content 代码这种循环依赖的祖传烂账。我见过不少插件项目刚开始图省事把所有代码塞进一个 background.js结果过几个月连原作者自己都分不清哪些逻辑跑在哪个上下文里改一行崩三处。除了目录还有两个细节值得注意。一是 manifest 和 package.json 的版本号要联动用脚本自动同步避免出现代码改了一堆发布出去的还是旧版本的事故。二是构建产物目录要和源码严格区分.gitignore里把dist目录排除掉否则很容易出现打包了旧文件、忘了最新改动的情况。4.2 构建工具链选择用前端工程化武装插件现代插件开发已经可以享受前端工程化的全部红利。我比较推荐的是 WXT 这个框架它对 MV3 的目录约定、HMR、调试流程都做了优化几乎就是为插件开发量身定做的脚手架。如果你不想引入太重的东西用 Vite CRXJS 插件也可以达到类似效果只是需要自己多配一些东西。用 Vite 生态有几个直接好处支持 TS 类型检查、支持现代浏览器特性的代码转换、支持打包时自动产出 zip、支持开发环境的热更新。尤其是热更新插件开发中最痛苦的事情之一就是改一行代码就得重新在浏览器里加载扩展热更新能把这个时间从几十秒压缩到几秒。我第一次在插件项目里用上热更新后开发效率肉眼可见地提升了一大截。关于 TypeScript我的建议是必须用。插件项目涉及多上下文、多 API、多消息类型纯 JS 很容易在消息字段名这种地方出错TS 的类型系统至少能在编译期拦住一批。特别是消息协议我会用一个types.ts定义所有的消息类型消息签名推导出来这样 content script 和 background 改字段时编译器会直接告诉你哪里没同步。4.3 测试策略从手动到自动化的三层金字塔浏览器插件的测试一直是个容易被忽略的点因为牵涉到多页面多进程的交互上手确实比普通网页测试麻烦。但一旦你有了 CI 和自动化基础很多问题可以在合并之前就拦下来。我自己的测试分三层。第一层是纯单元测试用 Vitest 跑lib目录里的工具函数、消息协议解析、AI 推理的预处理逻辑这部分不依赖浏览器环境速度最快。第二层是消息集成测试用一个模拟的 chrome API——比如 jest-webextension-mock——测试 service worker 和 content script 之间的消息流转是否符合协议。第三层是端到端测试用 Playwright 的新版 Chrome 插件支持直接加载 unpacked 扩展可以模拟真实用户操作验证完整链路包括右键菜单、popup 交互、AI 结果渲染。如果你团队有自动生成测试用例和代码 review 的工程化工具链你会发现插件项目也能接入进去。把代码扫描、静态检查、AI 辅助的测试用例生成都挂到 CI 里每次提交自动跑一遍比人手点验高效得多。这在多人协作时尤其重要——大家改同一个消息协议没有自动测试兜底合入前一天可能才发现两个模块已经互相不认识了。一个很实用的小经验测试用例里一定要覆盖 service worker 的休眠重启场景。你可以主动调用chrome.runtime.getContexts()相关的模拟逻辑或者设计一个杀掉 worker 再发消息的测试步骤确保状态能在重启后恢复。这个问题在真实场景里出现频率极高但很少有人在测试里覆盖属于典型的测试盲区。4.4 发布与版本管理一键出包的自动化流水线发布环节现代插件的常态是 CI 里一键出包。用 GitHub Actions 或其他任何 CI 把 main 分支的代码构建、跑测试、打 zip 包、上传到发布通道整个过程控制在十分钟内。版本号我建议遵循 semvermanifest 里的 version 和 package.json 保持一致可以用脚本自动同步避免出现低级事故。还有一个常被忽略的点多浏览器兼容。虽然 MV3 是 Chromium 系的规范但 Firefox 和 Safari 的实现细节差别不小。比如 Firefox 对 background service worker 的支持一直不如 Chrome 完整有些 API 的差异要靠 Polyfill——比如 webextension-polyfill——或条件编译来处理。如果团队没有精力维护多端我建议先用支持度最好的 Chrome 打基础但代码里就做好跨浏览器的 API 封装避免以后迁移时大改。审核流程也要提前规划。Chrome 应用商店对插件的审核越来越严格尤其是权限理由和隐私政策。我建议在项目结构里预留一个docs/privacy.md每次权限变更都同步更新说明等真要提交审核时不会手忙脚乱。另外如果你插件内含模型文件打包体积会大不少记得确认商店对扩展包大小的限制必要时把模型拆出来做首次启动下载。5. 常见问题与排查技巧实录5.1 内容脚本失效的 5 个经典场景做插件做得越多越会发现内容脚本没生效是新手最容易卡住的问题而且原因常常五花八门。这里把最常见的几个场景列出来方便你对照排查。匹配规则没写对。manifest 里 content_scripts 的 matches 要写完整的 URL 匹配模式包括协议、域名、路径通配符。很多新人只写了域名导致部分路径匹配失败比如https://example.com/*和https://example.com/*/*在某些路径下行为就不一样。扩展页面里不能注入。chrome:// 页面、应用商店页面等特殊页面会拒绝 content script 注入这不是代码问题不用白费力气排查识别出这些页面并提示用户即可。动态页面的坑。SPA 应用使用路由切换DOM 节点反复重建你的 content script 只注入了一次事件绑定和 DOM 操作自然就失效了。需要用 MutationObserver 监听节点变化在节点重建后重新初始化逻辑。这个问题在主流站点上几乎必现尤其对方用了框架的虚拟 DOM 时。隔离世界的变量问题。content script 和页面脚本不在同一个 JS 环境里页面里的全局变量你用window.xxx访问不到反之亦然。涉及和页面脚本交互时必须走window.postMessage或自定义事件注意别把这两种通道搞混。注册时机问题。某些 API 必须在 content script 执行早期完成注册比如右键菜单、快捷键否则偶发生效。这种 bug 最麻烦因为不是必现建议把注册逻辑统一放到 content script 入口的最前面。5.2 Service Worker 休眠相关的坑前面说过 worker 会休眠但那只是开始。实际项目中还有几个更隐蔽的坑我一个个说。全局定时器不可靠。所有setInterval都要换成chrome.alarms否则重启后定时任务就消失了。之前有个需求是每 5 分钟检查一次订阅源我直接用 setInterval 写的本地测试一切正常发布后用户反馈完全不更新排查半天才发现是休眠导致的。换成 alarms 之后问题立刻解决。会话数据丢失。白名单、cookie、session 等数据在 worker 重启后可能丢失需要主动持久化到chrome.storage。这里的教训是任何重要数据都不要只放在内存里一定写成内存 存储的双写模式worker 唤醒时先从存储里恢复。异步事件要用event.waitUntil包住。事件处理函数里如果做了异步逻辑比如发消息后等一个网络响应再回执必须调用event.waitUntil(promise)告诉浏览器这个事件还没处理完先别杀 worker。否则浏览器可能在异步完成之前就误判事件处理完了直接把 worker 杀掉你眼睁睁看着消息丢在半路。我的经验是所有消息处理函数统一套一个withKeepAlive包装器内部自动调event.waitUntil这样就算某个 handler 写了异步操作至少不会因为 worker 被杀而丢消息。这个包装器很便宜但能省掉大量偶发性 bug。5.3 排查工具与调试心法调试插件有几个工具我一直离不开先列出来。第一是 chrome://extensions 页面的 service worker 入口点一下就能打开 worker 的 DevTools直接看 console 和网络面板这是排查后台问题的第一站。第二是 chrome://serviceworker-internals可以直观看到 worker 的启动、销毁、唤醒记录定位休眠相关的问题快得离谱。第三是 content script 的 console打开对应网页的 DevTools就能看到 content script 日志断点调试也完全可用。再分享一个小技巧用chrome.runtime.getManifest()的扩展字段做一个调试总开关。在 development 版本开启详细日志production 版本关掉避免一堆日志刷屏。这个开关还可以用来控制是否加载测试模型、是否输出耗时统计很实用。我一般会在 manifest 里加一个_debug字段开发环境置为 true发布前统一置为 false。另外如果你在排查消息丢失的问题不妨给每一条消息都加一个messageId日志里串起来看能快速定位是哪一跳丢了。这个习惯来自分布式系统里的 trace 思想在插件这种多进程环境里一样管用。消息量大的时候还可以做一个简单的日志面板把消息记录按时间线展示肉眼扫一遍就能看出问题。做了这些年插件开发我最大的感受是浏览器插件这个领域正在从前端的一个小玩法变成一个真正需要工程体系支撑的方向。MV3 约束了灵活性但也逼着大家把代码组织得更规范、把通信设计得更清晰、把发布流程做得更自动化——这恰恰是在向成熟软件工程看齐。端侧 AI 的出现又给这个领域加了新变量模型、推理、性能优化这些以前完全不需要前端考虑的维度现在都变成了日常。如果你正好在做一个插件产品我的建议是从架构设计开始就认真对待把消息协议定清楚把测试补上把模型加载策略想明白。这个方向能玩的深度远比你想象的大。