Service Worker:MV3 的后台心脏(生命周期与休眠机制)

MV2 时代扩展后台是一个常驻页面,浏览器开着它就活着。MV3 把它换成了 Service Worker:一个没有界面、按需启动、闲时自动休眠的事件处理器。这不是换了个名字——「后台会死」这件事,改变了扩展开发的一批基本假设。这篇把生命周期的关键事实和应对策略讲清楚。

生命周期:三个阶段

Service Worker 的一生分三段,对应三组事件:

  1. 安装:依次触发 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 });
  }
});
  1. 运行:由事件唤醒。任何注册过监听器的事件(消息、标签页变化、闹钟、菜单点击……)都会把休眠中的 worker 拉起来。

  2. 休眠与终止: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,与休眠机制天然配合。

休眠设计的自查清单

写完后台逻辑后,用这五个问题自查:

  1. 全局变量里有没有跨事件保留的状态?有则迁移到 storage;
  2. 事件监听器是否都在顶层同步注册?藏在 await 之后的监听器会丢事件;
  3. 超过 30 秒的循环任务有没有切片?长任务考虑 alarms 分批或 offscreen;
  4. 需要长连接保活吗?WebSocket 或 Port 消息能否覆盖;
  5. 数据迁移(onInstalled)是否幂等?

把这五个问题过一遍,MV3 后台的绝大多数「偶尔失灵」都能提前消灭。存储选型细节见 《存储方案》 篇,消息机制见 《消息通信》 篇。官方文档:https://developer.chrome.com/docs/extensions/develop/concepts/service-workers

这个实验的结论值得再写一遍:全局变量、顶层定时器、未外置的状态,全部会在休眠后归零。

存储与消息分别是「保状态」与「传事件」的两大支柱,深入讨论见 《存储方案》 与《chrome.storage 实战》两篇;工程化配置见 HBuilderX 效率技巧。

「保状态」的代码层落地在《chrome.storage 实战》一篇,配合本篇阅读即可覆盖 MV3 后台状态的完整知识面;规则与文本处理中正则无处不在,工具箱见 正则实战。工程化的多文件组织与类型提示见 HBuilderX 效率技巧。

本章由 WorkBuddy 原创整理,API 行为依据 Chrome 扩展官方文档(Manifest V3)。