Service Worker:MV3 的后台心脏(生命周期与休眠机制)
MV2 时代扩展后台是一个常驻页面,浏览器开着它就活着。MV3 把它换成了 Service Worker:一个没有界面、按需启动、闲时自动休眠的事件处理器。这不是换了个名字——「后台会死」这件事,改变了扩展开发的一批基本假设。这篇把生命周期的关键事实和应对策略讲清楚。
生命周期:三个阶段
Service Worker 的一生分三段,对应三组事件:
- 安装:依次触发 web 标准的 install 事件、扩展的
chrome.runtime.onInstalled、再触发 activate 事件。与网页 Service Worker 不同,扩展的 activate 紧跟安装即触发,没有「等待接管」环节。onInstalled是做一次性初始化的正确位置,其 details.reason 有 install、update、chrome_update 三种,可用它区分首次安装与升级:
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === "install") {
chrome.storage.local.set({ initialized: true });
}
});
-
运行:由事件唤醒。任何注册过监听器的事件(消息、标签页变化、闹钟、菜单点击……)都会把休眠中的 worker 拉起来。
-
休眠与终止:Chrome 在满足以下任一条件时终止 worker——闲置 30 秒(收到事件或调用扩展 API 会重置计时器);单个事件或 API 调用处理超过 5 分钟;一次 fetch 的响应迟迟不回超过 30 秒。终止不是崩溃,下一个事件到来时它会带着全新全局状态重新启动。
最重要的规则:别依赖全局变量
worker 说死就死,醒来时全局变量全部归零。所以状态必须外置:
// 错误示范:全局变量存状态
let count = 0;
chrome.runtime.onMessage.addListener((msg) => {
count++; // worker 重启后从 0 重新数
});
// 正确做法:读写 storage
chrome.runtime.onMessage.addListener(async (msg) => {
const { count = 0 } = await chrome.storage.session.get("count");
await chrome.storage.session.set({ count: count + 1 });
});
storage.session 是内存级存储,适合存「本次浏览器会话内」的临时状态;要跨重启持久化用 storage.local。官方文档明确推荐 service worker 优先使用 storage 而非全局变量,网页的 localStorage 在 Service Worker 里根本不可用。存储选型的完整对比见 《存储方案》 篇。
监听器必须同步注册
另一个硬规则:事件监听器要在 worker 脚本的顶层同步注册,不能放在 await 之后或回调深处。因为唤醒 worker 靠的是「事件来了」,如果 Chrome 来的时候发现监听器还没挂上,这个事件就丢了。正确写法是顶层 addListener,把异步逻辑装进处理函数内部。
想让 worker 多活一会儿怎么办
先问该不该。官方明确建议不要刻意维持 worker 常驻,休眠机制就是为了省资源。但确实存在长任务场景,Chrome 给了几条官方通道:
- 30 秒计时器靠「活动」续期:持续调用扩展 API 或收发消息可保持活跃;
- Chrome 116 起,活跃的 WebSocket 收发消息会重置闲置计时器,做长连接类扩展靠它;
- Chrome 114 起,长连接端口上有消息往来时可保持存活(仅开端口不算);
- Chrome 118 起,活跃的 debugger 会话保持存活;
- 部分需要用户交互的 API(如 identity.launchWebAuthFlow、permissions.request)允许超过 5 分钟上限。
超时都扛不住的任务,考虑搬到 offscreen document 或让页面侧完成,再不然用 chrome.alarms 把任务切片成周期性小任务。alarms 最小周期从 Chrome 120 起降到 30 秒,与 worker 生命周期对齐。
架构含义
把这套机制内化后,MV3 的架构原则就顺理成章:Service Worker 只做事件路由和轻计算。重计算放页面或 offscreen,状态放 storage,跨上下文协作靠消息,定时任务靠 alarms。你写代码时脑中要始终有个画面:这个函数可能在任意时刻被腰斩,醒来后除了 storage 里什么都没有。
官方文档:https://developer.chrome.com/docs/extensions/develop/concepts/service-workers (生命周期细节在其中的 lifecycle 小节)。
一次典型休眠-唤醒周期的完整观察
把抽象规则变成可观察的事实。在 background.js 里写这段代码并加载扩展:
const t0 = Date.now();
console.log("[worker] 启动于", new Date().toLocaleTimeString());
chrome.runtime.onMessage.addListener((msg) => {
console.log("[worker] 收到消息", msg, "存活了", Math.round((Date.now() - t0) / 1000), "秒");
});
然后什么都不做,等一分钟再从 popup 或 content script 发一条消息。你会看到 DevTools 里出现新的「启动于」日志——worker 早已休眠,是消息把它重新拉起的,全局的 t0 是全新的一次计数。这个实验花两分钟,胜过十遍文档:worker 的每次启动都是全新世界。
观察唤醒的另一个窗口是 chrome://extensions 卡片上的「Service Worker」状态字样:蓝色「活动」表示正在运行,灰色「已休眠」表示已终止。开发时可以拿它验证「某个操作是否触发了后台」——点了功能没反应,先看这里有没有闪蓝。
onInstalled 与 onStartup 的分工
两个「启动类」事件极易混淆,分工如下:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
| runtime.onInstalled | 安装、更新扩展、浏览器更新时 | 初始化数据、注册菜单、迁移 |
| runtime.onStartup | 每次浏览器 Profile 启动时 | 按天重置的状态、启动检查 |
注意 onInstalled 在每次扩展更新后都会触发(reason 为 update),所以里面的初始化代码必须幂等:重复执行不能造成数据重复或覆盖。contextMenus.create 尤其典型——重复注册同 id 会报错,所以官方文档才把菜单注册放进 onInstalled 且建议先 removeAll 或判重。
onStartup 则有「每天开机做点什么」的语义,但不要拿它做定时器——定时任务的正解是 chrome.alarms,闹钟会在设定时刻唤醒 worker,与休眠机制天然配合。
休眠设计的自查清单
写完后台逻辑后,用这五个问题自查:
- 全局变量里有没有跨事件保留的状态?有则迁移到 storage;
- 事件监听器是否都在顶层同步注册?藏在 await 之后的监听器会丢事件;
- 超过 30 秒的循环任务有没有切片?长任务考虑 alarms 分批或 offscreen;
- 需要长连接保活吗?WebSocket 或 Port 消息能否覆盖;
- 数据迁移(onInstalled)是否幂等?
把这五个问题过一遍,MV3 后台的绝大多数「偶尔失灵」都能提前消灭。存储选型细节见 《存储方案》 篇,消息机制见 《消息通信》 篇。官方文档:https://developer.chrome.com/docs/extensions/develop/concepts/service-workers
这个实验的结论值得再写一遍:全局变量、顶层定时器、未外置的状态,全部会在休眠后归零。
存储与消息分别是「保状态」与「传事件」的两大支柱,深入讨论见 《存储方案》 与《chrome.storage 实战》两篇;工程化配置见 HBuilderX 效率技巧。
「保状态」的代码层落地在《chrome.storage 实战》一篇,配合本篇阅读即可覆盖 MV3 后台状态的完整知识面;规则与文本处理中正则无处不在,工具箱见 正则实战。工程化的多文件组织与类型提示见 HBuilderX 效率技巧。
本章由 WorkBuddy 原创整理,API 行为依据 Chrome 扩展官方文档(Manifest V3)。
