Rule — MV3 持久化陷阱清单¶
适用场景:写完任何
src/entrypoints/background/或src/utils/scrape-*的新功能后,跑一遍本清单。
为什么需要这个清单?¶
历史教训:
| 版本 | 引入 bug | 类型 |
|---|---|---|
| v0.10.23 | watchdog 自动恢复用 setTimeout | SW kill 丢 |
| v0.10.37 | interception 用 setTimeout + module-let | SW kill 丢 + 状态丢 |
修复版本: - v0.10.40 修 interception - v0.10.41 扫同清单找到 watchdog 同款
根本原因:好模式(chrome.alarms / storage persist)项目里早就存在,但每次写新功能时没复用。"局部最优,全局重蹈"。
自查清单(必跑)¶
1️⃣ 任何 setTimeout / setInterval¶
grep -n "setTimeout\|setInterval" src/entrypoints/background/<my-new-file>.ts src/utils/<my-new-file>.ts
对每处问: - 延迟时间 ≤ 5 秒? → OK(SW 大概率还活着) - 延迟时间 5 秒~30 秒? → 边界(SW 可能 kill)→ 加 alarm 兜底 - 延迟时间 > 30 秒? → ❌ 必须用 chrome.alarms 替代 - 是 idempotent 重试机制?(如每次任务结束 finally 都会再调)→ OK(丢一次不致命) - 是 SW 保活心跳?(如 keepAliveTimer 每 20s ping)→ OK(目的就是 SW 活着才有效)
正确模式:
const MY_ALARM = 'my-feature-alarm';
// 设置延迟
browser.alarms.create(MY_ALARM, { when: Date.now() + delayMs });
// background/index.ts onAlarm
if (alarm.name === MY_ALARM) {
handleMyAlarm().catch(...);
}
2️⃣ 任何 module-level let / var(运行时状态)¶
对每处问: - SW kill 后此变量回到初始值,会破坏什么? - 是临时缓存(如 isPumping 锁)? → OK(重启就重置正常) - 是关键运行时状态(如 verifyTabId、lastInterceptedAt)? → ❌ 必须 persist - 重启后能从其他数据源重建吗?(如 tasks Map 从 storage.TASKS_KEY 恢复)→ OK,但确保 restoreBatch 调用了
正确模式:
const MY_STATE_KEY = 'local:my-feature:state' as `local:${string}`;
interface MyState { ... }
let myState: MyState = defaultState;
async function persistMyState() { await storage.setItem(MY_STATE_KEY, myState); }
async function loadMyState() {
const s = await storage.getItem<MyState>(MY_STATE_KEY);
if (s) myState = s;
}
// 在 restoreBatch / SW startup 调 loadMyState
3️⃣ 任何 addListener¶
对每处问:
- listener 注册在 SW startup 顶层?(如 defineBackground(() => { ... addListener(...) }))→ OK(SW 重启重新注册)
- listener 注册在异步函数里? → ⚠️ 可能注册不到 / 多次注册
- listener 内调 async 函数返回 Promise? → ⚠️ chrome 类型可能 reject(如 runtime.onMessage 期望 true/void)
4️⃣ 任何类型扩展(union member)¶
对扩展的每个 member 问: - 会被实际写入数据吗(持久化)? → OK 加入 union - 只在展示/运行时派生用? → ❌ 不要加进持久化 type,单独在使用点 union 扩展
反模式:
正确:
type TaskStatus = '...';
const STATUS_META: Record<TaskStatus | 'intercepted', ...> = {...}; // 展示层 union
5️⃣ 任何"等 X 时间后做 Y"的逻辑¶
最关键的一条 — 问:
如果 SW 在这段时间被 kill 会怎样?
如果答案是"永远不触发 Y" → ❌ 用 alarm 重写 如果答案是"会再次触发兜底" → OK 如果答案是"延迟一次没事,后续轮次会补"(如周期任务)→ 看周期是否 alarm-based
自查命令(v0.10.42 起一键;v0.10.43 加 baseline diff)¶
# 完整扫描,列所有命中给人工判定
pnpm scan:mv3
# v0.10.43:把当前所有命中存为基线(已审过的"已知 OK")
pnpm scan:mv3 -- --save-baseline
# 生成 .mv3-scan-baseline.json,commit 到 git 让所有 dev 共享
# v0.10.43:只显示比基线**新增**的命中(噪音少)—— pre-commit 用这个
pnpm scan:mv3 -- --diff
# CI 模式:有命中即 exit 1
pnpm scan:mv3 -- --strict
v0.10.43 自动化:pre-commit hook 在 src/entrypoints/background/ /
src/utils/scrape-*.ts / src/utils/engine-manager.ts 改动时自动跑
scan:mv3 --diff,发现新引入陷阱即阻止 commit。
工作流:
- 首次:跑 --save-baseline 记录 baseline
- 写新代码:pre-commit 自动跑 --diff,发现新命中阻止 commit
- 命中是真陷阱 → 改成 alarm/persist
- 命中是合理设计 → 跑 --save-baseline 重存基线
也可手动 grep:
grep -rn "setTimeout\|setInterval" src/entrypoints/background/ src/utils/scrape-*.ts
grep -n "^let \|^var " src/entrypoints/background/*.ts src/utils/scrape-*.ts
grep -rn "addListener" src/entrypoints/background/
grep -rn "type.*Status.*=" src/utils/ src/types/
触发场景¶
- 每次写完 background 新功能(强制跑)
- 每次回看 1-2 个版本前的 background 改动(review 时跑)
- 怀疑"自动 XX 机制不灵"时(debug 时跑)
相关¶
- [[0026-captcha-resume-sw-restart-lost-state|0026-拦截恢复SW重启丢状态]] — 第一次发现这套陷阱
- [[0027-watchdog-resume-settimeout-bug|0027-watchdog自动恢复setTimeout同款Bug]] — 用清单扫到的同款
- 扩展reload生命周期 — MV3 SW 生命周期沉淀
- Tab生命周期与看门狗 — alarm-based 周期实现参考