写 Chrome 扩展时踩过的那些 MV3 的坑
事情是这样的。
我写了个 Chrome 扩展,功能听起来不算复杂,在浏览器侧边栏里接一个 AI 对话面板,能读取当前网页的内容,选中文字问 AI,翻译,总结页面,诸如此类。
我当时想的是,这不就是个嵌在浏览器里的 ChatGPT 嘛,能有多难。
结果第一个坑在写代码的第三天就来了。
有个用户反馈说,扩展用着用着就没反应了,点悬浮按钮打不开侧边栏,重启浏览器才行。我一开始以为是偶发 bug,没太在意。直到我自己也频繁遇到,才意识到这不是什么偶发问题,这是 MV3 的 service worker 生命周期在背后搞鬼。
如果你之前没写过 Chrome 扩展,或者只写过 MV2 的老扩展,可能对 service worker 这个东西没什么概念。我试着用最简单的话解释一下。
Chrome 扩展在 MV3 里有一个叫 service worker 的东西,你可以把它理解成扩展的后台进程。它不跟任何页面绑定,独立运行,负责处理那些全局性的工作,比如右键菜单、网络请求、消息转发。关键是,Chrome 会在它空闲大约 30 秒后直接把它杀掉。
不是暂停,是杀掉。下次有事件触发时再重新启动。
这个设计本意是省内存,但对开发者来说就很头疼了。你要维护的任何状态,比如当前打开的 WebSocket 连接、正在监听的端口、缓存的数据,全都没了。Worker 重启之后就是一个全新的、干净的环境,对之前发生了什么一无所知。
我当时用的就是长连接。Side Panel 打开后,会通过 chrome.runtime.connect 跟 service worker 建立一个持久化的消息通道,所有对话消息都走这个通道收发。Worker 一死,端口就断了,Side Panel 那边发什么都不可能收到回复,用户看到的现象就是「点了没反应」。
MV2 时代不是这样的。MV2 用的是 background page,一个常驻的后台页面,只要浏览器开着它就在。你可以放心地在上面挂状态、维护连接。但 MV3 强制切到了 service worker,这个便利没了。
那怎么解决呢?
思路其实不复杂,端口断了就重连。Side Panel 在 onDisconnect 回调里启动一个定时器,延迟 100 毫秒后重新 chrome.runtime.connect。重新 connect 这个动作本身就会唤醒 service worker,worker 启动后再建立新的端口,双方重新握手。
代码大概长这样。
javascript
const connectPort = () => {
const port = chrome.runtime.connect({ name: 'sidepanel' });
portRef.current = port;
port.onMessage.addListener(handleMessage);
port.onDisconnect.addListener(() => {
portRef.current = null;
// 100ms 后重连,connect 会唤醒被 Chrome 杀掉的 service worker
setTimeout(connectPort, 100);
});
};
这个 100 毫秒的重试间隔是踩过坑的。一开始我设的是 1 秒,后来发现 worker 重启通常只需要几十毫秒,1 秒的等待让用户能明显感觉到卡顿,特别是在连续发消息的场景。改成 100 毫秒后体感基本无缝。
但重连只是解决问题的一半。还有一半是,重连之后怎么恢复之前的状态?
比如用户右键选中了一段文字点「问 AI」,这时 worker 刚好死了,side panel 还没连上。等重连完成,这段上下文就丢了。
我的做法是用 pending 缓存。worker 收到上下文请求后,先尝试直接通过端口发给 side panel,如果端口不存在就存到 pendingContext 变量里。等 side panel 重新连上来的时候,在 onConnect 回调里检查有没有待下发的缓存,有就补发。
javascript
let pendingContext = null;
// side panel 重连时补发缓存的上下文
chrome.runtime.onConnect.addListener((port) => {
sidePanelPort = port;
if (pendingContext) {
port.postMessage({ type: 'ASK_WITH_SELECTION', payload: pendingContext });
pendingContext = null;
}
});
这听起来像是一个简单的模式,但它解决了 MV3 service worker 场景下最核心的问题,如何在「随时可能死掉的后台」和「需要长连接的 UI」之间保证状态不丢。
除了 service worker 的断连问题,还有另一个让我踩坑的地方,消息回复到了错的端口。
这个 bug 是 git log 里第二条提交修掉的。
说起来也有点蠢。Service worker 可以在同一个 onConnect 回调里接收多个 side panel 的连接(比如用户打开了多个窗口,每个窗口有自己的 side panel)。我在 handleSendMessage 函数里,本来应该把 API 的流式回复发回请求来源的那个端口,结果我用了全局的 sidePanelPort 变量,当有多个连接时,回复就发到了后连上来的那个端口,发起请求的那个面板什么都没收到。
修法也简单,把 port 作为参数一路传下去,而不是依赖全局变量。
javascript
async function handleSendMessage(payload, port) {
// 请求用哪个 port 来的,回复就发回哪个 port
// 不能用全局 sidePanelPort,多面板场景会串
}
说真的,Chrome 扩展开发最大的挑战不是某个 API 怎么用,而是这些生命周期和状态管理的问题。MV3 把 background page 砍掉换成 service worker 之后,所有开发者都得重新思考一个问题,在一个随时可能被销毁的上下文里,怎么保证它的行为是可靠的。
侧边栏 API 本身倒是挺简单的。
在 manifest 里声明 side_panel.default_path,Chrome 就会在侧边栏区域加载你的 HTML 页面,本质上就是一个普通的网页,你可以用 React、Vue 随便写。打开侧边栏只需要一行,chrome.sidePanel.open({ windowId })。
但注意,这个 API 有个用户手势的要求。从 MV3 开始,sidePanel.open 必须在用户手势的调用链里执行。什么是用户手势?点击、按键、右键菜单,这些算。定时器回调、网络请求回调,这些不算。
这就是为什么从 content script 打开侧边栏时需要先 sendMessage 到 service worker,让 worker 在收到消息的回调里执行 sidePanel.open。好消息是,Chrome 116 之后,content script 的 sendMessage 会保留用户手势到 worker 的 onMessage 回调里,所以这个链路是通的。
但手势是瞬时的。如果我收到消息后先去做一个耗时的操作,比如等 content script 返回页面正文,手势就失效了,sidePanel.open 就会被 Chrome 拒绝。
所以正确的顺序是先打开侧边栏,再做其他事。
javascript
async function dispatchAskWithSelection(tab, selectionText) {
await openSidePanel(tab.windowId); // 先开面板,手势有时效
const context = await collectPageContext(tab, selectionText); // 再慢慢采集上下文
// ...
}
这个顺序不可调换,调换了就报错。我也是翻了好几次 Chrome 文档才搞明白。
还有一个东西叫 content script。
它是一段注入到用户访问的网页里的 JavaScript,可以直接读 DOM,但不能用网页里的 JS 变量(两个世界是隔离的)。它是扩展跟网页交互的唯一桥梁。你需要读当前页面的文字内容,靠它。你需要在页面上加一个浮动按钮,也靠它。你想在选中文字旁边弹一个工具条,还是靠它。
但它有个限制,不能使用 ES module。所以我的 content script 是一个纯手写的 IIFE,所有代码包在一个立即执行函数里,常量也跟 TypeScript 侧的 constants 文件手动同步。每次改了共享常量,两边都得改,确实有点蠢,但 MV3 目前就这样,等它支持 module 了再说吧。
写到这,我突然意识到一件事。
很多人说 Chrome 扩展很简单,就是几个 HTML 页面拼一拼。确实,如果你只是想做一个点击图标弹个 popup 的简单扩展,一天就写完了。但一旦涉及到长连接、实时通信、状态同步,MV3 的架构约束就会让你不断撞墙。
而这些问题,文档都不会直接告诉你。文档会告诉你每个 API 的参数和返回值,但不会告诉你 worker 被杀后端口会断需要重连,不会告诉你 sidePanel.open 有手势时效,不会告诉你多个端口同时连接时需要区分请求来源。
这些东西,都是写代码的过程中一步一步踩出来的。
接下来的几篇文章,我会聊聊这个扩展的核心架构,怎么在 service worker 里做 SSE 流式请求,怎么处理模型的 reasoning 输出,以及整个上下文拼装的管线是怎么设计的。
上面这些,如果对你有用,可以随手转发给也在写 Chrome 扩展的朋友。