扩展的配置有个别人没有的难处:你没法做数据库迁移。
用户的配置在他自己浏览器的 chrome.storage 里,你既碰不到,也不知道他停在哪个版本。他可能半年没更新,一更新直接从三十几个版本前跳到最新。
这套东西的形状最后长成这样。
一个 slice 一个 key
不是一整个大对象存一个键,而是按顶层 slice 拆:
cfg:version ← 配置迁移号(整数)cfg:generalcfg:quickTicketcfg:autoReplycfg:keybindingscfg:monitor…好处有两个:
- 改一个域只写一个 key,不用读回整块再写回去(减少并发覆盖的机会);
chrome.storage.onChanged能精确告诉你是哪个域变了——一整块存的话,任何一处改动都会触发所有订阅者。
配置的单一真源是一个 TypeScript 文件:
export const CONFIG_VERSION = 91; // 配置迁移号,用户不可见export const CONFIG_KEYS = { version: 'cfg:version', general: 'cfg:general', /* … */ } as const;export const DEFAULT_CONFIG: AppConfig = { /* … */ };⚠ 两套版本号别混。
package.json/ manifest 里那个是发布号(用户看得见、Chrome 和安装脚本认的);CONFIG_VERSION是配置迁移号(内部整数,用户不可见)。功能有变升前者,配置结构有变升后者,两者毫无关系。
deep-merge:新字段自动补
读配置时把存储值 merge 到默认值上:
function deepMerge<T>(base: T, patch: Partial<T> | undefined): T { if (!patch) return base; if (Array.isArray(base)) return (patch as unknown as T) ?? base; // ★ 看这行 if (typeof base !== 'object' || base === null) return (patch as T) ?? base;
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) }; for (const k of Object.keys(patch as Record<string, unknown>)) { const bv = (base as Record<string, unknown>)[k]; const pv = (patch as Record<string, unknown>)[k]; if (bv && typeof bv === 'object' && !Array.isArray(bv) && pv && typeof pv === 'object' && !Array.isArray(pv)) { out[k] = deepMerge(bv, pv as Record<string, unknown>); } else if (pv !== undefined) { out[k] = pv; } } return out as T;}加一个新字段,什么都不用做:老用户的存储里没这个键,merge 之后自动拿到默认值。
这覆盖了配置变更里的绝大多数情况,所以很容易产生一个错觉——「有 deep-merge 就不需要迁移了」。
但数组是整体替换的
看上面标星的那一行:数组不逐元素合并,直接整个用存储里的那份。
这个决定是对的(数组元素没有稳定的身份,逐个合并只会产生诡异的结果),但它有一个非常隐蔽的后果。
我们有个功能是数据驱动的:一个「预设」列表,每个预设里有一串要执行的动作。
某个版本我给某个预设加了一个新动作——改默认配置,加完,自测通过。
线上的表现是:老用户那个功能就是不执行新加的那一步,改代码怎么改都没用。
因为老用户存储里那个预设数组是完整的旧版(7 个动作),deep-merge 不会往里面补第 8 个,它整个用旧的。新写的那段处理代码根本没被调用到。
排查信号:日志里打出来的动作条数 ≠ 默认配置里的条数,就说明存储里那份过期了。
修法是版本化重置:
if (raw.version < 35) { // 给预设加了新动作。presets 是数组,deep-merge 不逐元素补, // 所以必须显式重置 actions —— 但保留用户改过的文案 / 开关 / 快捷键 merged.quickTicket.presets = merged.quickTicket.presets.map((p) => { const def = DEFAULT_CONFIG.quickTicket.presets.find((d) => d.id === p.id); return def ? { ...p, actions: def.actions } : p; });}注意这里只重置 actions,不整个替换预设——用户改过的文案和快捷键不该被抹掉。迁移要尽可能精准,「反正重置成默认」是最省事也最招骂的写法。
版本化重置块:改形状就得写一条
那串 if 是逐版累积的,永远不删:
if (raw.version < CONFIG_VERSION) { if (raw.version < 11) { /* … */ } if (raw.version < 12) { /* … */ } if (raw.version < 13) { // 字段改名:v12 的 syncSubjectFromDescription 换成了 lockSubjectInput。 // ★ 继承旧值,而不是用默认值 —— 用户当初关掉它是有原因的 const old = (merged.quickTicket.layout as any).syncSubjectFromDescription; merged.quickTicket.layout = { ...DEFAULT_CONFIG.quickTicket.layout, ...merged.quickTicket.layout, lockSubjectInput: old !== undefined ? !!old : DEFAULT_CONFIG.quickTicket.layout.lockSubjectInput, }; delete (merged.quickTicket.layout as any).syncSubjectFromDescription; } // … 一直到 91}用 < 而不是 ===:一个停在 v12 的用户要依次走完 13 到 91 的所有块。
什么时候需要写一条:
| 变更 | deep-merge 够吗 |
|---|---|
| 加一个新字段 | ✅ 够 |
| 改一个字段的默认值(老用户该保留自己的选择) | ✅ 够,什么都不用做 |
| 改一个字段的默认值(老用户也该跟着变) | ❌ 要显式刷 |
| 数组内容有变(加了元素、改了元素结构) | ❌ 必须重置 |
| 字段改名 / 改形状 | ❌ 要写继承逻辑 |
| 字段语义变了(同名但含义不同) | ❌ 要写转换逻辑 |
那串
if (raw.version < N)是唯一权威——它逐版记录了配置到底改过什么。文档里再抄一份只会过期,读代码就能重建完整历史。
加一个新顶层 slice,要改六处
这是最机械、也最容易漏的一件事。因为这些地方是显式列举的,不是遍历的:
| # | 位置 | 漏了会怎样 |
|---|---|---|
| 1 | SliceMap 类型 | 类型报错,会被发现 |
| 2 | loadRaw 的 storage.get([...]) 列表 | 读不到,永远是默认值 |
| 3 | loadRaw 的返回对象 | 同上 |
| 4 | loadAppConfig 的 merged 对象 | 设置页 draft.x 是 undefined → 整个 tab 渲染崩(页面空白、tab 切不动) |
| 5 | persistAll 的 set({...}) | 保存时这个键根本没写进去 → background 读不到 → 功能报「未启用」 |
| 6 | ConfigClient.init() 里 onChanged 的那串 if (CONFIG_KEYS.x in changes) | 内存里的单例不随设置页保存更新 |
第 4 和第 5 我都踩过。第 5 尤其难查——设置页显示得好好的(内存里的 draft 有值),刷新之后就没了,而报错出现在完全另一个地方(background 说功能没开)。
第 6 这条更隐蔽:功能是好的、存储是对的,只是当前页面里的那份内存副本没更新,刷新一下就正常了。于是它长期被当成「偶发」。
更值得说的是:这份清单本身长期只记了五处。直到加第十四个 slice 的时候才发现漏了 onChanged 那一项。
对于这种「必须同时改 N 处」的地方,与其指望记住,不如把清单写在代码注释里,并且写清楚每一处漏了会出现什么现象——现象比位置更容易被搜到。
加完之后全局搜一个已有 slice 的名字(比如 CONFIG_KEYS.quickReplies),把这六处对照补齐。
为什么不用遍历
有人会问:既然要改六处,为什么不写成遍历 CONFIG_KEYS 自动处理?
因为每个 slice 的类型不同,遍历会把类型信息全丢掉,退化成 Record<string, unknown>——然后你在六处都少了类型检查,换来的是「加 slice 时少改五处」。
这笔账不划算:加 slice 是低频操作(一年十几次),而类型检查是每次编译都在帮你。
显式列举的真正问题不是「要改六处」,是「没人告诉你有六处」。把清单写下来就够了。
回头看,这套配置系统里最有价值的设计只有一条:把「加字段」和「改形状」区分开。
前者交给 deep-merge,完全自动、零维护;后者必须显式写一条迁移,而且要写清楚为什么。
混在一起想,你就会得到那个经典的 bug——代码明明改对了,但那段代码根本没被调用到。