原来有一台服务器跑着 Puppeteer,开一个无头浏览器,登录、定时抓几个接口、算指标、告警。它工作得挺好,代价是:一台常驻机器、一套登录态维护、一个没人看的进程。
把它搬进浏览器扩展的 Service Worker 之后,所有数据抓取都在用户自己的浏览器里发生,登录态天然就是用户的,服务器可以关掉。
第一版跑起来很顺——然后过了几分钟,看板就不动了。
MV3 的 Service Worker 三十秒没事干就会被回收。 这不是 bug,是设计。而它会让你所有「持续做某件事」的假设一次性全部失效。
三种定时手段,各有各的边界
| 最小周期 | SW 被回收后 | 真实用途 | |
|---|---|---|---|
setInterval | 任意 | 跟着一起没了 | 有活跃 Port 时的高频轮询 |
chrome.alarms | 30 秒(periodInMinutes: 0.5) | 会唤醒 SW | 冷启唤醒兜底 |
chrome.runtime.Port | — | — | 保活:有活跃 Port 时 SW 不被回收 |
第一行和第二行合起来是个死结:
setInterval能跑任意频率,但 SW 一死它就没了;chrome.alarms能把 SW 叫醒,但它的最小有效周期是 0.5 分钟。
我要的是 5 秒级的轮询。alarms 根本做不到。
解法:Port 才是保命绳
关键的一条是:只要有活跃的 chrome.runtime.Port,Chrome 就不会回收 SW。
所以真正可靠的不是 setInterval,而是「有 UI 连着」这个前提。调度策略因此长成这样:
UI 打开 → content script chrome.runtime.connect(...) → SW 因为有活跃 Port 不被回收 → setInterval(5s) 可靠地跑UI 关闭 → Port 断开 → 没有订阅者,直接跳过轮询(省请求) → SW 该睡就睡SW 万一还是被回收 → chrome.alarms(1min) 把它唤醒 → 重建 timer三件事各司其职:Port 保活、setInterval 干活、alarms 兜底。
const ALARM_NAME = 'keepalive';const ALARM_PERIOD_MIN = 1; // 仅在 SW 被回收时唤醒,正常运行用不到
/** 间隔下限,保护上游别被压垮 */const FAST_MIN_MS = 3_000;const SLOW_MIN_MS = 15_000;监听器必须在 SW 顶层同步注册
这条是硬要求,违反了症状还很奇怪。
// src/background/index.ts —— 顶层,同步chrome.runtime.onConnect.addListener((port) => { /* … */ });chrome.alarms.onAlarm.addListener((a) => { /* … */ });如果你把它们写在某个 async init() 里,等配置加载完再注册:
- Chrome 在 SW 冷启时看不到任何事件监听器 → 判定这个 SW 没用 → 回收;
- 更糟的是,唤醒它的那次连接根本到不了你的处理函数——SW 被唤醒、脚本从头执行、但你的
addListener还在等一个await,而事件已经派发完了。
症状是「有时候连得上、有时候连不上」,且完全没有报错。
SW 里所有的
addListener都必须在模块顶层同步完成。 需要配置的话,在 handler 里再去读。
状态要能过冻结
SW 被回收,所有模块级变量清零。等它被唤醒,你手里什么都没有。
对一个看板来说这意味着:用户切回标签页,面板先闪一下空白,等第一轮数据回来才有东西。
修法是每次状态变更都写一份快照:
const STORAGE_KEY = 'hub:snapshot';
function persistToSession(): void { // fire-and-forget:持久化失败不该影响业务 chrome.storage.session.set({ [STORAGE_KEY]: hub }).catch((e) => { log.warn('persist 失败:', (e as Error).message); });}
/** SW 冷启时调一次,让 UI 立刻拿到上次的可见状态,不闪空 */export async function restoreFromSession(): Promise<void> { try { const r = await chrome.storage.session.get(STORAGE_KEY); if (r?.[STORAGE_KEY]) hub = r[STORAGE_KEY]; } catch (e) { log.warn('restore 失败:', (e as Error).message); }}为什么是 storage.session 而不是 storage.local:
session 在浏览器完全关闭后自动清空,但能跨 SW 重启存活——正好是这个场景需要的两个性质。用 local 的话,用户第二天打开浏览器会先看到昨天的数据,还得自己判断它过期没有。
顺带一提,这个特性也让 storage.session 成了 SW 调试开关的理想载体:SW 里没有 localStorage,而调试开关本来就该是临时的。
content 侧的重连,以及什么时候该放弃
SW 被回收时 Port 会断。content script 要能自动重连,但必须区分两种断开:
let backoffMs = 1000;let invalidated = false; // ★ 一旦上下文失效就永久停止
function isContextInvalidated(err: unknown): boolean { return /Extension context invalidated/i.test((err as Error)?.message ?? '');}
function scheduleReconnect(): void { if (invalidated) return; // ← 没有这一行就是死循环 setTimeout(connect, backoffMs); backoffMs = Math.min(backoffMs * 2, 30_000); // 1s → 2s → 4s … 封顶 30s}两种断开完全不同:
| 断开原因 | 该做什么 |
|---|---|
| SW 被回收 | 重连。连上之后 SW 就被唤醒了,一切照常 |
| 扩展被重新加载(dev 热更、手动 reload、自动更新) | 永久停止。这个 content script 已经是孤儿了 |
第二种是这样的:扩展重载之后,旧的 content script 还留在页面里,但它所属的扩展上下文已经没了。它再调任何 chrome.* API 都会同步抛错。
不区分的话,你会得到一个永不停止的重连循环——每次重连都抛一次错,控制台以 30 秒一条的节奏刷到天荒地老,而且它永远不可能成功。
判据很简单:
export function isExtensionContextValid(): boolean { try { return !!chrome.runtime?.id; // 上下文没了,这个就是 undefined } catch { return false; }}这种情况下用户唯一的出路是刷新页面,所以要给一条提示。而那条提示必须用 error 级别:
/* ⚠ 故意用 log.error 而不是 log.warn: * 这条提示出现的时刻,logger 自己读 chrome.storage 也会失败 * → 调试开关状态退回默认的「关」→ 走 warn 的话这句「请按 F5」永远不会被看见, * 用户只会觉得「扩展突然没反应了」。error 恒开,不受开关约束。 */log.error('扩展已重新加载,旧页面脚本失效。请按 F5 刷新本页后再操作。');判日志级别时问一句:开关关着的时候,用户还需不需要看见它?
快慢两档,各自去重
不是所有接口都能 5 秒跑一次。有的要翻页,一轮下来好几秒。
所以分两档,各带一个 in-flight 标志:
let fastInflight = false;let slowInflight = false;
async function runFast(): Promise<void> { if (fastInflight) return; // ★ 上一轮还没回来,这一轮直接跳过 fastInflight = true; try { const [a, b] = await Promise.allSettled([fetchA(), fetchB()]); // 失败的那个 section 这轮就不更新,不要用空值覆盖上一轮的好数据 if (a.status === 'fulfilled' && a.value.ok) setSectionA(a.value.parsed); } finally { fastInflight = false; }}两个细节:
① 没有 in-flight 去重,慢接口会堆积。 定时器不管你上一轮回没回来,到点就再发一轮。一个 8 秒才回来的接口配 5 秒的定时器,请求会越积越多,最后把上游打垮。
② 用 Promise.allSettled 而不是 Promise.all。 一个接口挂了不该让整轮作废——其它接口的数据仍然是新的、仍然该展示。失败的那个保留上一轮的值。
没人看就别跑
if (getSubscriberCount() === 0) return;一行,但它管住了大部分无谓的请求。没有 UI 连着的时候,数据抓回来也没人看;而这个判断顺带也让 SW 有机会真的睡过去。
(这和之前另一篇里写的「一个 30 秒的 setInterval 比几百个真实用户还费」是同一笔账。)
顺带:跨域请求必须从 SW 发
这算是 SW 的一个正当用途:几个数据端点是跨域的,从 content script 发会被 CORS 拦,从 background 发配上 host_permissions 就行。
所以「把抓取放 background」不只是为了后台运行,也是唯一能发这些请求的地方。
小结
MV3 的 Service Worker 不是一个「常驻进程」,它是一组随时会被杀掉、随时会被唤醒的事件处理函数。
从这句话出发,前面那些设计就都是推论:
- 顶层同步注册监听器 —— 因为 Chrome 要在冷启的第一毫秒看到它们;
- 状态写快照 —— 因为内存随时会清零;
- Port 保活 + alarms 兜底 —— 因为你没法要求它一直活着,只能要求它能被叫醒;
- 重连要能区分「SW 睡了」和「扩展没了」—— 因为前者该等,后者该停。
这些约束看起来很烦,但它们换来的是:没有那台常驻服务器了。