保持 MV3 Service Worker 持续活跃
2025年12月5日 · 2623 字
保持 MV3 Service Worker 持续活跃
核心做法:用一个 offscreen 文档每 20 秒向 SW 发一条消息,把它的空闲计时器一直顶回去。下面是原理、实现,以及几个需要注意的边界。
写 Manifest V3 扩展时,service worker 的生命周期是个绕不开的问题。在 background 里 setInterval 跑轮询,或者起一个需要持续运行几分钟的后台任务,往往跑到一半就中断:SW 被浏览器回收,定时器随之失效,内存里的状态全部丢失。
MV2 的常驻后台页(persistent background page)在 V3 已经移除。MV3 的 SW 是事件驱动模型,用完即回收,默认 30 秒无事件就被终止。对多数扩展这没有影响,但只要存在"长时间持续运行"的需求,比如一个要连续跑几十分钟、其间还要不断 sleep 以规避频控的轮询任务,30 秒的存活窗口就远远不够。
下面是一个可靠的解决办法。
先排除几个常见但不可靠的方案
正式方案之前,先说明几个搜索时容易找到、但实际有坑的方向。
chrome.alarms 是最直接的反应:定时唤醒。但它有两个限制。其一,最小周期是 1 分钟(periodInMinutes 小于 1 会被钳到 1),SW 在两次 alarm 之间早已被回收多次;其二,alarm 只负责把 SW 唤醒执行一次,执行完它仍然会被回收。它适合做兜底心跳,不适合做持续保活。
在 SW 内部自己 setInterval 也不行:SW 被回收时,interval 一并消失,它无法保住自己。
还有一类做法是反复 chrome.runtime.connect 维持长连接,利用"存在活跃连接则不回收"的特性定时重连。这一招在不同 Chrome 版本上行为不一致,历史上也撞到过 5 分钟硬上限,可靠性没有保证。
这几种方案的共同问题是:都试图让一个即将被回收的 SW 自我续命,而 SW 一旦进入回收流程就无法自救。
可靠方案:用一个不受回收约束的上下文定时唤醒它
换个思路:不让 SW 自救,而是找一个不受 30 秒规则约束的上下文,从外部定时向它发消息。
关键机制在于,每一条入站的 runtime message 都会重置 SW 的空闲计时器。因此只要有一个稳定的、间隔小于 30 秒的消息源,SW 就始终处于"有事件"的状态,不会被回收。
这个消息源用 offscreen 文档来充当。
Offscreen document 是 MV3 提供的隐藏页面(chrome.offscreen.createDocument),原本用于 SW 做不了、需要 DOM 的工作,比如解析 HTML、URL.createObjectURL、播放音频。它有一个这里正好需要的性质:它是一个普通网页,不受 SW 那套 30 秒空闲回收的约束,只要不主动关闭、且其 reason 仍然成立,它就一直存在。
整体结构如下:
offscreen 文档 ──每 20 秒发一条 'timer.ping'──▶ SW
(每条消息重置 30s 空闲计时器)
20 秒小于 30 秒,下一条消息总能在 SW 进入回收前到达,于是 SW 持续保持 active。
offscreen 侧的代码很短:
// offscreen.ts
setInterval(() => {
chrome.runtime.sendMessage({ type: 'timer.ping' })
}, 20_000)
SW 侧收到 ping 不需要做任何处理,"收到消息"这个动作本身就是目的:
// background.ts
chrome.runtime.onMessage.addListener(msg => {
if (msg?.type === 'timer.ping') {
// 无需逻辑:消息被接收,空闲计时器即被重置。
}
})
在 SW 启动时把 offscreen 创建出来:
async function ensureOffscreen() {
if (await chrome.offscreen.hasDocument?.()) return
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['BLOBS'], // 用一个正当 reason(通常本来也会用它转 blob)
justification: 'reliable timers + SW keep-alive',
})
}
只要扩展处于活跃状态,SW 就会持续存活。
进一步:把定时器也交给 offscreen
单纯保活还不够。SW 内部的 setTimeout / setInterval 本身也不可靠:会被节流,SW 被回收时丢失,长延时甚至可能不触发。更稳妥的做法是把 bg 的定时器整体委托给 offscreen,因为 offscreen 是普通页面,它的定时器是可信的。
我没有去 monkeypatch 全局 setTimeout,而是封装了一个显式的 sleep:bg 需要等待时,发消息让 offscreen 计时,到点后 offscreen 回一条消息,bg 再继续。
// bg 侧:sleep 不自己计时,发一条消息请 offscreen 计时,等它的响应
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise(resolve => {
let done = false
const finish = () => { if (!done) { done = true; resolve() } }
// sendMessage 的响应回来时 resolve;这条响应本身也重置一次 SW 空闲计时
chrome.runtime.sendMessage({ type: 'timer.sleep', ms }).then(finish, finish)
signal?.addEventListener('abort', finish, { once: true })
})
}
// offscreen 侧:真正的 setTimeout 在这里跑,到点用 sendResponse 回复请求
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === 'timer.sleep') {
setTimeout(() => sendResponse('done'), msg.ms)
return true // 返回 true 保持消息通道开着,等异步 sendResponse
}
})
chrome.runtime.sendMessage 本身就是请求/响应模型:bg 发出去拿到一个 Promise,offscreen 在 onMessage 里用 sendResponse 回复时这个 Promise 才 resolve。所以 bg 里没有任何 setTimeout,它只是 await 一个会被 offscreen 的响应 resolve 的 Promise,计时整个发生在 offscreen。需要等待的地方都走这条路径,同时拿到两点收益:计时可靠,且每次响应回来都额外重置一次 SW 的空闲计时。
厘清职责:保活、委托、续采是三件事
容易混淆的一点是,保活和委托定时器解决的不是同一个问题。
设想一个带等待的后台循环:
while (running) {
await doOneBatch() // 干一批活
await sleep(10_000) // 等一会儿再干下一批
}
这个循环、循环里的变量、await 停在哪一行,全都活在 SW 的内存里。三道机制各管一摊:
- 保活(20s 心跳) 保证 SW 在整个过程中不被回收,于是循环上下文不丢、
await停住的位置还在,sleepresolve 后能从原地继续。 - 委托定时器 保证那个"等 10 秒"按时、可靠地返回,不受 SW 节流影响,因为计时根本不在 SW 里。
- 进度持久化 是最后一道独立保险:每完成一批就把进度写进 IndexedDB,万一 SW 真在等待中途被回收,重启后能从上次进度接着跑。
可能会问:既然心跳已经让 SW 不死,bg 直接用原生 setTimeout 不就行了?在心跳焊住 SW 的前提下,对这种短等待,原生 setTimeout 大概率也能正常触发。委托给 offscreen 是更彻底的保险:它不依赖"SW 一定活着"这个高概率但非 100% 的假设(内存压力、崩溃、心跳链路的短暂空窗都可能让 SW 在某一刻仍被回收),长定时器也一视同仁;而且一旦立下"SW 里不放任何真实定时器"这条规矩,就不必每次去判断这个定时器会不会被节流或丢失。
但要分清边界:委托定时器不能替代进度持久化。SW 一旦在等待中途被回收,内存里 pending 的 Promise 和循环上下文一起消失,offscreen 到点回的响应也唤不回旧的执行位置。所以"重启后能续上"必须靠持久化进度单独兜,这跟保活、委托不是一回事。
兜底:chrome.alarms
前面说 alarms 不适合做主力,但它适合做兜底。万一 offscreen 这条链路出现异常(被某种边界情况关闭、消息丢失),需要有机制重新唤醒 SW、重新创建 offscreen。每分钟一次的 alarm 正好胜任。
chrome.alarms.create('heartbeat', { periodInMinutes: 1 })
chrome.alarms.onAlarm.addListener(a => {
if (a.name === 'heartbeat') void ensureOffscreen() // 链路断了就重建
})
chrome.runtime.onStartup.addListener(() => void ensureOffscreen())
放在前面那张职责表里看:心跳是保活的主力,alarms 是它的兜底,负责在心跳链路断掉时把 SW 唤醒、把 offscreen 重新拉起来。它和委托计时、进度持久化各管一摊,合起来才是完整的一套。
几个需要注意的边界
为什么是 20 秒而不是 29 秒。空闲阈值是 30 秒,需要给消息传递延迟和浏览器调度抖动留出余量。20 秒留有足够冗余,没有必要去逼近 30 秒的上限,省下的那点资源不足以抵偿偶发的回收风险。
offscreen 只能有一个。一个扩展同时只能存在一个 offscreen 文档,因此不能保活开一个、转 blob 再开一个。让它承担多项职责即可:心跳、定时器委托、blob URL 转换都交给同一个 offscreen。reasons 选一个本来就需要的(如 BLOBS),justification 写清楚即可。
常驻还是按需。最简单的做法是常驻:SW 启动即创建 offscreen,心跳持续运行,全程不关。我最初考虑过"有任务时才保活、空闲时放手让 SW 回收"以节省资源,但行不通:定时器既然已经委托给 offscreen,offscreen 就必须一直在,否则 bg 里任何 setTimeout 都会失效。按需关闭会破坏整套计时抽象,得不偿失。结论是采用常驻,代价是 SW 在扩展活跃期间一直驻留内存,这是长后台任务的必要成本。
效果上,这套机制搭好之后,一个跑几十分钟的循环用普通的 while + await sleep 就能完整执行,中途切走标签页、关闭面板都不受影响。
如何验证它确实没有被回收
代码逻辑之外,需要实际观察确认。两种方式。
一是查看 chrome://serviceworker-internals,或 chrome://extensions 里扩展的 service worker 状态。不做任何操作,持续观察:如果它一直是 active / running,几分钟内不转为 inactive,即为生效。作为对照,临时关闭心跳,会看到它在约 30 秒后转为 stopped。
二是打日志。在 ping 两端各加一条 dev 日志:offscreen 每 20s 打 [os] ping #N →bg,bg 收到时打 [bg] ping #N (SW kept alive @ time)。观察计数稳定递增、SW 状态不变,即可确认。
小结
MV3 的 SW 保活方案众多,真正稳定的是这一套:用一个不受 30 秒回收约束的 offscreen 文档,每 20 秒向 SW 发一条消息;再把定时器一并委托给 offscreen 以保证计时可靠(并顺带保活);最后用 alarms 做兜底与重建。三层叠加,长后台任务即可稳定运行。