这些年我一直在折腾浏览器插件,从最早的 MV2 时代写常驻后台页,到后来全面切换到 Manifest V3,最大的感受是:这个领域早就不再是“写几行 content script 改改页面”就能交差的小脚本了。现在的现代浏览器插件,牵扯到 MV3 架构设计、跨进程通信、甚至端侧 AI 推理,整套东西已经是一个标准的前端工程化项目,甚至比不少后台系统还要复杂。
我借着前阵子做的一款“网页内容摘要助手”插件,把这个过程中的思考、选型和踩坑完整复盘一遍。它不是那种能跑就行的 Demo,而是从 manifest.json 设计到消息路由,再到端侧模型推理和 CI 发布一路趟过来的实战记录。如果你打算认真做一款插件,或者正在从 MV2 迁移到 MV3,这篇文章应该能帮你少走不少弯路。
1. 为什么说现代浏览器插件不再是“小脚本”
1.1 从“改页面”到“做一个应用”
早期浏览器插件的典型形态,就是在页面上注入一段脚本,调整 DOM、抓点数据、或者给网页加个按钮。这个阶段的插件确实像“小脚本”:manifest.json 里写一个 content_scripts 匹配规则,其他事情都在页面上下文里完成,开发和发布都很轻。
但一旦功能变复杂,问题就来了。比如我的摘要助手,需要在网页加载后提取正文、需要保持用户配置、需要把摘要结果展示到独立面板,还要支持后台任务队列。这些需求如果全部堆在 content script 里,会立刻遇到两个痛点:一是 content script 和页面共享 DOM,但不共享 JavaScript 执行环境,任何稍微复杂的状态管理都会被割裂;二是浏览器的隐私模型对 content script 的权限限制越来越严格,很多 API 根本不能直接在页面上下文里调用。
所以现代插件实际上被拆成了“多个独立上下文协同工作”的分布式应用。Service Worker 负责后台逻辑,content script 负责与页面交互,扩展页面负责 UI,Offscreen Document 做 DOM 操作,各干各的,再通过消息机制串联起来。这不是小脚本能驾驭的形态。
1.2 MV3 是分水岭:Service Worker 与权限收紧
Manifest V3 最核心的变化,是把原来可以常驻的 background page 换成了事件驱动的 Service Worker。这个设计的初衷很明确:浏览器希望扩展在不需要的时候能够被完全回收,减少常驻内存和后台开销。
但代价也很直接,MV2 时代那种“在后台页里保存全局变量、维持长连接、或者定时轮询”的写法全部失效。Service Worker 随时可能被终止,所有需要持久化的状态都必须放到chrome.storage或 IndexedDB,所有长连接都必须通过消息端口机制处理,所有定时任务都要考虑被唤醒和再次休眠的问题。
权限模型也随之收紧。远程代码(eval、远程 script)被彻底禁止,跨域请求必须通过host_permissions声明,而且很多敏感 API 需要用户手势才能触发。这些限制表面上是“变麻烦了”,实际上是在倒逼开发者用更规范的方式组织代码。如果一个插件能在 MV3 下保持稳定,那它的代码结构大概率已经具备工程化的雏形。
1.3 不工程化,根本跑不动
我在迁移早期项目时,最大的感受不是 API 变难了,而是原有的“零散脚本”组织方式完全撑不住。一个 Service Worker 生命周期里的状态恢复、一个 content script 的多页面适配、加上消息路由的异常处理,如果只靠几个互相引用、没有类型约束的 JS 文件,排查问题会非常痛苦。
工程化不是一个形式问题,而是 MV3 架构下的刚需。比如我需要构建工具来做打包和静态资源指纹,需要 TypeScript 来约束消息协议的类型,需要单元测试来覆盖 Service Worker 的纯逻辑,需要 lint 来保证消息通道两端的配置一致。没有这些东西,跨进程通信里一个拼写错误就能让你调试一整天。
所以从这版项目开始,我把插件当成一个真正的前端工程来做:模块化、构建链、类型检查、自动化测试,一个都不能少。
2. Manifest V3 架构下的插件解剖
2.1 manifest.json 不是配置文件,而是“应用入口”
很多人把 manifest.json 当成一个普通的配置文件,随手写上 permissions 和 content_scripts 就完事。但 MV3 下,这份文件更像是整个插件的模块清单,它决定了浏览器会把哪些能力暴露给你的代码,也决定了你的代码能在什么时机运行。
我建议在一个插件项目进入开发前,先完整梳理一遍运行场景,再回头填 manifest.json。比如我的摘要助手需要这几个能力:读取当前标签页内容、在页面加载后自动注入提取脚本、点击插件图标打开弹窗、使用 IndexedDB 保存历史摘要。对应的 manifest 声明大概是:
{ "manifest_version": 3, "name": "网页内容摘要助手", "version": "2.1.0", "permissions": ["storage", "scripting", "activeTab"], "host_permissions": ["<all_urls>"], "background": { "service_worker": "src/background/index.ts", "type": "module" }, "action": { "default_popup": "src/popup/index.html" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["src/content/index.ts"], "run_at": "document_idle" } ], "web_accessible_resources": [ { "resources": ["assets/models/*.wasm"], "matches": ["<all_urls>"] } ] }注意这里有个细节:service_worker可以声明为"type": "module",这意味着 Service Worker 里可以直接使用 ES Module 的import。这个特性对工程化帮助很大,我后面会专门说。
2.2 五种执行环境的定位与分工
在 MV3 架构里,一个完整的插件通常包含下面几种执行环境,它们各自有独立的全局对象、能力和生命周期:
- Service Worker:后台大脑。负责事件监听、消息路由、网络请求、定时任务等。没有 DOM,生命周期由事件驱动。
- Content Scripts:注入到网页里的脚本,可以读取和修改页面 DOM,但只能使用有限的 chrome API,不能访问页面自己的 JS 变量(除非通过共享 DOM 通信)。
- 扩展页面(Popup / Options / 自定义页面):完整的 HTML 页面,拥有完整 DOM、所有扩展 API 权限,用于渲染 UI。
- Offscreen Document:MV3 新增的隐藏页面,专门处理 Service Worker 做不了的事情,比如音频播放、剪贴板操作、DOM 解析。
- DevTools Page / Panel:只在开发者工具场景下加载,适合做调试相关功能。
理解这些环境的差异,是设计插件架构的第一步。我见过很多人一上来就在 content script 里调用chrome.storage,结果发现部分浏览器版本支持不稳定,或者把跨域请求逻辑丢到扩展页面里,导致关闭弹窗就断任务。
2.3 被砍掉的后台页,我用 Offscreen Document 补位
MV3 在去掉持久后台页后,最尴尬的问题就是:如果需要在后台处理 HTML 字符串,做 DOM 解析怎么办?Service Worker 没有document,你不能直接创建一个DOMElement。
我的摘要助手里有一个功能:把网页正文提取出来之后,需要先做 HTML 清洗,再计算可读性评分。这个逻辑放在 content script 里也不是不行,但 content script 每次注入都是一份独立实例,重复解析很浪费。后来我选择了 Offscreen Document,专门做富文本预处理。
创建 Offscreen Document 的代码是这样的:
chrome.offscreen.createDocument({ url: chrome.runtime.getURL("src/offscreen/index.html"), reasons: ["DOM_PARSING"], justification: "用于在后台解析网页正文HTML" });需要注意的是,Offscreen Document 有明确的使用场景限制,必须声明reasons,而且同一时刻一个扩展通常只能创建一个。它不是用来无限开后台页的,只是给 Service Worker 打补丁。
2.4 权限设计:least privilege 的实践
MV3 对权限的收紧,逼着开发者养成了“最小权限”的习惯。我刚写第一版时,permissions里写了tabs、storage、unlimitedStorage、scripting、webRequest,还加了<all_urls>的 host permissions。结果 Chrome 商店审核回来,要求我解释为什么需要tabs权限。
后来我把权限缩到activeTab+scripting+storage,宿主权限改为在用户点击插件图标时才通过chrome.permissions.request临场申请。这样做的好处很明显:用户信任度高、审核通过率也高。更关键的是,权限越少,插件被恶意利用的攻击面就越小,这个在 MV3 时代尤其重要。
3. 跨进程通信与消息路由实战
3.1 不同上下文之间的“隔离墙”
跨进程通信是 MV3 插件开发绕不开的核心难点。我在画架构图时,喜欢把 Service Worker、Content Script、Popup 想象成三个独立的国家,它们之间没有直接调用通道,只能靠“外交邮件”通信。这个邮件系统就是chrome.runtime和chrome.tabs下的消息 API。
消息通信有几个最基本的约束:Content Script 只能向它所在的标签页和后台发送消息;Popup 可以向后台发送消息,也可以请求后台转发到指定标签页;Service Worker 是全局消息中心,可以主动向任意标签页注入并发送消息。
这个约束意味着,设计消息通道时,不能简单地“谁想调用谁就直接发消息”,而是要先定义清楚消息的流向和转发规则。
3.2 我的消息通道设计方案
在摘要助手里,我定义了一套统一的消息协议。核心思路是把所有请求都设计成type + payload的结构,避免直接传递函数或者复杂对象。
type RequestMessage = | { type: "EXTRACT_ARTICLE_FROM_TAB"; tabId: number } | { type: "GENERATE_SUMMARY"; text: string; maxLength: number } | { type: "SAVE_HISTORY"; record: SummaryRecord }; type ResponseMessage<T = unknown> = | { ok: true; data: T } | { ok: false; error: { code: string; message: string } };Content Script 负责和页面打交道,比如提取正文、高亮关键词;Service Worker 负责调用端侧 AI、管理 IndexedDB;Popup 只负责发出指令和展示结果。实际发送时,我会封装一个sendMessageToTab函数,统一处理 Promise 和错误。
async function sendMessageToTab<T>(tabId: number, message: RequestMessage): Promise<ResponseMessage<T>> { try { return await chrome.tabs.sendMessage(tabId, message); } catch (err) { return { ok: false, error: { code: "NO_RECEIVER", message: String(err) } }; } }这里最容易踩的坑是:如果目标标签页没有运行 content script,chrome.tabs.sendMessage会直接抛错,而不是返回 null。所以我在发送前会先通过chrome.scripting.executeScript做一次注入,确保接收端存在。
3.3 时序、并发与重试策略
跨进程通信另一个典型问题是时序。Service Worker 可能在任何时刻被休眠,长时间没有消息时,它的全局状态会被清空。如果你在 message listener 里引用了某个初始化时才创建的变量,第二次唤醒后可能就变成了undefined。
我解决这个问题的方式是:所有需要长期保存的数据都写入chrome.storage.session或 IndexedDB,消息处理函数每次只从存储中读取状态,不依赖内存变量。另外,凡是涉及端侧 AI 这种耗时操作,都设计了超时和重试机制。
频道型通信也有讲究。如果只是“发一次请求、收一次响应”,用sendMessage就够了。但如果 content script 要持续上报提取进度,就需要chrome.runtime.connect建立长连接。长连接有个好处,可以保持 Service Worker 活跃,但也意味着你需要更小心地管理关闭时机,否则连接泄漏会让插件无法休眠。
3.4 排查通信问题时我用的三板斧
跨进程通信问题很难直接断点调试,我通常会按下面的顺序排查:
- 第一,确认接收方是否注册。最经典的问题是 content script 还没注入,消息就已经发出去了。先在
chrome://extensions的 Service Worker 控制台里看有没有 listener。 - 第二,检查消息结构是否可序列化。所有消息都必须能被 structured clone,不能带 DOM 节点、函数、Symbol。
- 第三,用日志链路追踪。我会在
sendMessage和 listener 入口各加一行结构化日志,带上requestId,就能看出消息是在哪一段断掉的。
还有一个小技巧,Chrome 插件调试台的 “Service Worker” 面板会自动保活一段时间,方便你在里面打断点。但如果你的消息是异步的,而 Service Worker 已经处于休眠边缘,可以临时在代码开头加一个chrome.storage.session.get保持活跃,但正式版本千万别这么写。
4. 端侧 AI 集成:让插件拥有本地推理能力
4.1 为什么要做端侧,而不是直接调云 API
摘要助手最核心的功能是给网页生成摘要。最初我的方案是调云端大模型 API,效果当然不错,但存在几个很现实的问题:首先是隐私,用户访问的网页内容会被发送到第三方服务,这在很多场景下是不可接受的;其次是成本,一个活跃用户一天可能生成几十次摘要,按调用量计费的话,项目根本撑不住;最后是延迟,网络请求往返一次,往往比本地推理更慢。
端侧 AI 的优势就在于:模型文件下载到本地之后,推理完全在浏览器内进行,没有网络请求,不会泄露用户数据,也没有按次计费的问题。虽然模型体积小了,效果相比云端大模型有一定差距,但对于“摘要”这个特定任务,端侧小模型已经能做到可用水平。
4.2 模型选型与体积控制
浏览器端能跑的模型,已经不是以前那种玩具级分类器了。主流的思路有几种:用 ONNX Runtime Web 跑转换后的模型,用 Transformers.js 跑 HuggingFace 上的模型,或者用 WebLLM 跑纯浏览器端的 lLM。
我的做法是先用 Transformers.js 做原型验证,因为它的 API 最简单:
import { pipeline } from '@xenova/transformers'; const summarizer = await pipeline( 'summarization', 'Xenova/distilbart-cnn-6-6' ); const output = await summarizer(articleText, { max_length: 180, min_length: 40 });但 Transformers.js 的默认模型是 800MB 级别的,直接打进插件不现实。后来我换成了基于 ONNX Runtime Web 的量化模型,把体积压到了 40MB 以内。对于摘要这个任务,量化后的效果损失在可接受范围内,换来的是首次加载速度大幅提升。
4.3 推理流程与生命周期管理
端侧 AI 的推理不能在 Service Worker 里裸奔。模型下载、加载、推理都是异步耗时操作,如果恰好赶上 Service Worker 被休眠,整个任务就断了。我在项目里做了一个专门的任务队列,把推理请求统一丢到 Offscreen Document 的页面上处理,因为 Offscreen Document 和普通页面一样,生命周期比 Service Worker 更稳定。
流程大致是这样:
- Content Script 提取到正文,通过消息发送给 Service Worker。
- Service Worker 把任务写入 IndexedDB 队列。
- Offscreen Document 定时从队列取任务,加载模型,执行推理。
- 推理完成后,把结果写回 IndexedDB,再通知 Service Worker 更新存储和 UI。
这个设计的核心思路是:不要让端侧 AI 的加载状态影响主流程。用户不会因为模型还没加载好就卡住,可以先看到“内容提取成功,摘要生成中”的状态,等推理完成后异步刷新。
4.4 性能优化与硬件加速实测
端侧 AI 在浏览器里能不能跑得快,很大程度上取决于硬件加速。WebGPU 在 Chrome 113 之后已经默认可用,对于显卡支持的用户,推理速度提升非常明显。我在实测中发现,同样一个 INT8 量化摘要模型,纯 CPU 推理需要 6 秒左右,开启 WebGPU 后能压到 2 秒以内。
如果你想开启 WebGPU,需要做两件事:一是等待navigator.gpu可用,二是给 occluder 分配 buffer。Transformers.js 和 ONNX Runtime Web 都开始支持 WebGPU EP,但还不是所有算子都能跑。我的建议是永远保留 CPU fallback:
const deviceType = 'webgpu'; const isWebGpuSupported = !!navigator.gpu; const execProvider = isWebGpuSupported ? deviceType : 'wasm';另外,模型文件一定要开启缓存。ONNX Runtime Web 和 Transformers.js 默认会用 Cache Storage 缓存 wasm 和权重,但前提是你的扩展页面有足够的存储空间和权限。首次加载耗时不可避免,但之后可以做到秒开。
5. 工程化落地的完整路径
5.1 从“能跑”到“可维护”的构建链路
MV3 项目如果要认真做,第一件事就是上构建工具。我用的是 Vite,配合@crxjs/vite-plugin,可以自动处理 manifest.json、HMR 和资源路径。Vite 的生态成熟,对 TypeScript 支持也友好,社区里有很多 MV3 模板可以参考。
构建链路里最需要注意的是资源路径处理。插件里所有静态资源最终都会被chrome-extension://协议加载,如果直接用/assets/xxx.png这种绝对路径,开发环境能跑,打包后大概率 404。我一般会配置一个resolve.alias,把所有静态资源都当作模块引入,交给 Vite 处理。
还有一个细节:Vite 默认按模块拆分打包,但 Service Worker 的入口文件必须是一个单一文件。要在 manifest 的service_worker字段里直接指向构建产物,不要在 Service Worker 的代码里用动态import(),否则浏览器的加载时序会有问题。
5.2 模块化与类型安全设计
插件代码横跨多个执行环境,最怕的就是各写各的,消息协议和类型定义散落各处。我项目里的目录结构大致是这样:
src/ background/ # Service Worker 入口 content/ # Content Script 入口 popup/ # 弹窗页面 options/ # 设置页 offscreen/ # Offscreen Document shared/ # 类型定义、消息协议、常量、工具函数shared目录是我整个项目的命脉。所有消息类型、存储 key、枚举值都收敛在这里,content script 和 Service Worker 都从同一个模块 import,这样 TypeScript 会在编译期告诉我消息是否拼错、字段是否对不上。
如果你不想引入太重的状态管理,尽量用纯函数处理业务逻辑,再薄薄包一层 chrome API 调用。这样可以方便地做单元测试,也方便以后把某个能力抽成独立模块复用。
5.3 自动化测试与发布流水线
插件开发另一个容易被忽略的点是测试。跨进程通信很难做端到端测试,但至少要做三件事:
- 单元测试:用 Vitest 测 shared 模块的纯函数,比如摘要结果排序、历史记录去重、存储 key 拼接。
- 构建校验:在 CI 里跑一次
vite build,确保所有入口文件都能够正确打包,manifest 里引用的每个文件都存在。 - 发布冒烟:用一个 Playwright 或者 Puppeteer 脚本加载未打包的插件,调用几个核心接口,确保没有明显的运行时异常。
发布流程方面,Chrome 商店和 Edge 商店的审核周期不同。我会先把代码打成一个 zip,自己用chrome --load-extension在干净 profile 下测一遍,然后再提交。版本号严格遵循 SemVer,每次发版前更新version字段,同时要把update_url留给浏览器商店自动处理,不要在代码里自己实现什么更新检查。
5.4 真实项目里反复踩过的坑
最后整理几个我在这个项目里印象最深的坑,希望你能绕开。
第一个坑:chrome.storage.local的写入是异步的,但很多人默认成同步。我有一段代码在storage.set之后立刻storage.get,结果拿到旧数据。后来统一封装成了 Promise 风格的数据访问层,所有写入都返回 Promise,再也没出过这种问题。
第二个坑:Content Script 的window不等于页面自己的window。如果你要在页面里执行一些需要访问页面变量的代码,不能直接在 content script 里写,而要通过executeScript注入到主世界,或者利用window.postMessage做一个通信桥。
第三个坑:不要把所有逻辑都塞进 Service Worker,但它又必须能随时恢复。我一开始在 Service Worker 里维护一个“当前选中的标签页 ID”,结果每次休眠后这个状态就丢了。后来改成每次请求都从存储中读,虽然多几次异步读取,但稳定性提高了一个量级。
第四个坑:Offscreen Document 的使用是有数量限制的,而且不能随便关闭再创建。在低版本 Chromium 上频繁创建和关闭会导致资源泄漏。我的做法是启动插件时创建一次,整个生命周期里只复用这一个文档,所有后台 DOM 操作都通过消息并发请求。
如果让我重来一次,我会在项目第一天就把这些约束写进团队的开发规范里。毕竟 MV3 带来的不是某个 API 的变化,而是一整套开发思维的转变。踩过几次坑之后,我现在看任何插件项目,第一件事就是打开 manifest.json,先看它的权限清单和执行环境划分,这个习惯帮我省下了大量调试时间。