扩展的配置有个别人没有的难处:你没法做数据库迁移。

用户的配置在他自己浏览器的 chrome.storage 里,你既碰不到,也不知道他停在哪个版本。他可能半年没更新,一更新直接从三十几个版本前跳到最新。

这套东西的形状最后长成这样。

一个 slice 一个 key

不是一整个大对象存一个键,而是按顶层 slice 拆:

cfg:version ← 配置迁移号(整数)
cfg:general
cfg:quickTicket
cfg:autoReply
cfg:keybindings
cfg:monitor
…

好处有两个:

  • 改一个域只写一个 key,不用读回整块再写回去(减少并发覆盖的机会);
  • chrome.storage.onChanged 能精确告诉你是哪个域变了——一整块存的话,任何一处改动都会触发所有订阅者。

配置的单一真源是一个 TypeScript 文件:

src/types/config.ts
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,要改六处

这是最机械、也最容易漏的一件事。因为这些地方是显式列举的,不是遍历的:

#位置漏了会怎样
1SliceMap 类型类型报错,会被发现
2loadRaw 的 storage.get([...]) 列表读不到,永远是默认值
3loadRaw 的返回对象同上
4loadAppConfig 的 merged 对象设置页 draft.x 是 undefined → 整个 tab 渲染崩(页面空白、tab 切不动)
5persistAll 的 set({...})保存时这个键根本没写进去 → background 读不到 → 功能报「未启用」
6ConfigClient.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——代码明明改对了,但那段代码根本没被调用到。