功能开关:它是负债,不是配置

每个开关都会让代码路径翻倍,而开关本身永远不会被删。给每个开关定一个移除日期,比设计一套精巧的开关系统重要得多。

功能开关解决的问题很明确:让部署与发布解耦,能灰度、能回滚。它的问题同样明确:每加一个开关,可能的状态组合就翻一倍,而没人负责删掉它。

三种开关,寿命完全不同

类型 存活时间 例子
发布开关 几天到几周 新首页灰度
运维开关 几个月,可能永久 紧急降级某个依赖
实验开关 一次实验的周期 A/B 测试

把三者混在一个系统里管理,是混乱的起点。发布开关必须被删除,它只是上线过程中的脚手架。

用日期而不是布尔值

给开关加一个到期字段,比任何文档约定都有效:

const FLAGS = {
  newCheckout: { enabled: false, expiresAt: '2026-10-31' },
  legacySearch: { enabled: true, expiresAt: '2026-12-01' },
} as const;

配合一个测试:过期的开关让构建失败。

for (const [name, f] of Object.entries(FLAGS)) {
  if (Date.parse(f.expiresAt) < Date.now()) {
    throw new Error('flag ' + name + ' is past its removal date');
  }
}

这条断言把「以后清理」变成了「现在必须处理」。没有它,开关会永久留在代码里。

开关的读取要有默认值

function isEnabled(name: keyof typeof FLAGS): boolean {
  const override = process.env['FLAG_' + name];
  return override ? override === '1' : FLAGS[name].enabled;
}

默认值写在代码里,环境变量只做临时覆盖。反过来(默认关闭、靠配置打开)会导致新环境忘记配置就静默关掉功能。

代码形状:尽早分支,合并回收

开关只该出现在一个地方。散落的 if (flag) 会让删除变得几乎不可能。

// 好:一个地方决定实现
export const checkout = isEnabled('newCheckout') ? newCheckout : legacyCheckout;

// 差:实现内部到处都是分支
export function checkout(cart) {
  if (isEnabled('newCheckout')) { /* ... */ }
  if (isEnabled('newCheckout')) { /* ... */ }
}

前者的删除是删一行加删一个文件;后者要在几十处做撤并。

开关不该承载业务逻辑

如果某个开关已经开了三个月且没有任何关闭计划,它就不是开关了 —— 它是一项配置,应该合并进正式代码路径。继续叫它「开关」只会让人以为它可以随时删掉。

开关的价值在上线那几天。之后每多留一天,它就多一分让代码难以理解和测试的负债。

← 返回文章列表

评论

…