跳转至

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(运行时状态)

grep -n "^let \|^var " src/entrypoints/background/<file>.ts

对每处问: - 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

grep -n "addListener" src/entrypoints/background/<file>.ts

对每处问: - listener 注册在 SW startup 顶层?(如 defineBackground(() => { ... addListener(...) }))→ OK(SW 重启重新注册) - listener 注册在异步函数里? → ⚠️ 可能注册不到 / 多次注册 - listener 内调 async 函数返回 Promise? → ⚠️ chrome 类型可能 reject(如 runtime.onMessage 期望 true/void)

4️⃣ 任何类型扩展(union member)

grep -n "type.*Status.*=.*'.*'" src/utils/<file>.ts

对扩展的每个 member 问: - 会被实际写入数据吗(持久化)? → OK 加入 union - 只在展示/运行时派生用? → ❌ 不要加进持久化 type,单独在使用点 union 扩展

反模式

// MapTask.status 持久化层永远不会是 'intercepted',加进去是 footgun
type TaskStatus = '...' | 'intercepted';

正确

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/

触发场景

  1. 每次写完 background 新功能(强制跑)
  2. 每次回看 1-2 个版本前的 background 改动(review 时跑)
  3. 怀疑"自动 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 周期实现参考