我们给组件 API 加了版本号,结果三年没出过 breaking change

我们给每个组件的 props 加了版本号,三年了,没出过一次 breaking change。

这件事说起来挺反直觉的。2021 年我们团队在重构设计系统的时候,做了一个当时看起来有点“过度设计”的决定:在组件的 TypeScript 类型定义里,给 props 显式加上语义化版本号。三年过去,这个机制让我们在 40 多个业务项目、超过 200 个组件的持续迭代中,保持了零 breaking change 的记录。

为什么要给 props 加版本号?因为正常的语义化版本在组件库里根本跑不通

2021 年初,我们发布了组件库 v1.0,严格按照 semver 规范打 tag。但只过了两个迭代,问题就来了——semver 在组件库这种场景下,粒度太粗了。

比如我们修改了 Button 组件的 loading 逻辑,从“加载时自动禁用”改成了“加载时不强制禁用,由业务侧控制”。这个改动从库的视角看只是一个 patch 级别的行为调整,但有一个金融业务页面恰恰依赖了“加载时自动禁用”的特性来做防重复提交。升级之后,用户在 loading 期间可以连续点击,触发了 3 笔重复扣款。

另一个场景更头疼:我们把 DatePicker 的 format 属性从 string 类型扩展成了 string | Function,支持自定义格式化函数。这是标准的 minor 变更,向下兼容。但某微前端子应用在运行时检测 props 类型做条件渲染,typeof props.format === 'string' 的判断逻辑在收到函数时直接挂了。

问题出在哪?semver 是给整个包打版本的,但业务代码依赖的是具体某个组件的某个 prop 行为。库级别的版本号承载不了这个粒度的兼容性承诺。你发布 v1.2.3,业务方根本不知道该不该放心升级——Button 可能没问题,但 DatePicker 的某个冷门 prop 行为变了,他们恰好用了。

方案设计:在类型层面给每个 prop 标注它所属的版本

我们的做法是在 TypeScript 类型定义里,利用模板字面量类型给每个 prop 打上版本标记。不是运行时判断,纯粹是编译期的约束。

// 基础类型定义
type VersionedProp<V extends string, T> = T & { __version?: V };

// 组件 props 示例
interface ButtonProps {
  /** @version 1.0.0 */
  variant: VersionedProp<'1.0.0', 'primary' | 'secondary' | 'text'>;
  
  /** @version 1.2.0 */
  loading: VersionedProp<'1.2.0', boolean>;
  
  /** @version 2.0.0 @deprecated 使用 variant="text" 替代 */
  flat: VersionedProp<'1.0.0', boolean>;
  
  /** @version 1.5.0 */
  onLoadingChange: VersionedProp<'1.5.0', (loading: boolean) => void>;
}

关键设计点有三个。

第一,版本号跟着 prop 走,不是跟着组件走。 Button 组件整体可能是 v2.3,但它的 variant 属性从 1.0 就没变过,loading 在 1.2 有一次行为调整,onLoadingChange 是 1.5 新增的。每个 prop 有自己的版本号,精确表达了“这个 API 契约从哪个版本开始生效”。

第二,用 TypeScript 的 conditional types 做版本检查。 我们写了一个工具类型,业务方可以在项目里声明自己依赖的组件库最低版本,然后类型系统会自动检查:

// 业务方在项目里声明依赖版本
declare const COMPONENT_VERSION: '1.5.0';

// 工具类型:检查 prop 版本是否兼容
type EnsureCompatible<
  PropVersion extends string,
  RequiredVersion extends string
> = PropVersion extends RequiredVersion 
  ? true 
  : `Error: This prop requires component version >= ${PropVersion}`;

// 使用示例
type Check = EnsureCompatible<'1.5.0', typeof COMPONENT_VERSION>;
// 如果 COMPONENT_VERSION 低于 1.5.0,这里会报类型错误

实际落地时我们在 CLI 里加了一个命令,扫描项目中所有组件引用,自动生成一个版本兼容性报告。但这套类型机制的价值不在于跑 CI,而在于:当开发者试图使用某个 prop 时,IDE 的智能提示会明确显示这个 prop 的版本号。看到 onLoadingChange 旁边标注着 @version 1.5.0,而自己的项目还在用 1.3.2,他会立刻意识到不能用。

第三,deprecated 不删除,只标记。 flat 这个 prop 在 2.0 被标记为 deprecated,但我们没有在 3.0 删掉它。版本标记系统让它变成了一个显式的“历史遗留”:类型定义里同时保留了 @version 1.0.0@deprecated,业务方知道它还在、能用,但新代码不应该再依赖它。这比 semver 的 major 版本强制删除要温和得多。

这套机制真正起作用的是“契约可见性”

版本标记本身不会阻止任何人做 breaking change。它真正解决问题的方式,是让 API 契约的变更变得可见可讨论

以前我们的 PR 流程里,开发者改一个 prop 的类型或行为,Code Review 阶段很难判断这个改动的影响面。改了 loading 的行为逻辑,reviewer 看到的只是一个布尔值逻辑调整,不会意识到这对某些业务意味着什么。

有了版本标记之后,我们的 PR 模板里多了一项强制要求:如果修改了已有 prop 的行为或类型,必须更新该 prop 的版本号,并在 PR 描述里注明影响范围。这个规则倒逼开发者在改动之前就想清楚:我到底是在修 bug(不改版本号),还是在改契约(升版本号)?

2022 年有一次,一个同事想把 Table 组件的 dataSource 从同步渲染改成异步加载,这显然是个行为变更。他在 PR 里把 dataSource 的版本号从 1.0.0 升到了 2.0.0。Code Review 时另一个同事指出:这个改动会导致 7 个业务页面的表格出现闪烁,因为它们依赖了同步渲染的确定性。最终我们选择了新增一个 asyncDataSource prop(版本号 2.0.0),保留原有 dataSource 的行为不变。

这个案例很典型。如果没有版本标记机制,那个 PR 大概率会直接合入,然后在某次不起眼的 patch 升级里炸掉一堆页面。版本标记把“这个改动会破坏契约”这件事推到了台面上,让团队在合并之前就完成了风险讨论。

三年里的实际数据

从 2021 年 3 月到 2024 年 3 月,我们的组件库经历了:

  • 217 个组件从 1.x 演进到 3.x
  • 累计 1,200+ 次 prop 新增
  • 89 个 prop 被标记为 deprecated
  • 0 个 prop 被删除
  • 0 次 breaking change 升级

代价是类型定义文件的总行数增加了约 15%,以及每个 PR 多花了 2-3 分钟的版本标记填写时间。相比之前每次 major 升级要协调十几个业务团队做兼容性改造,这个成本几乎可以忽略。

但必须诚实地说,这套方案能跑通有一个前提条件:我们的组件库是内部用的,业务方和组件团队在同一个 monorepo 里,版本升级是强同步的。如果是开源组件库,下游用户五花八门,光靠类型标记解决不了升级意愿的问题。不过即使对于开源场景,prop 级别的版本标记也能显著降低用户评估升级风险的认知成本——他们不需要通读整个 CHANGELOG,看一眼类型定义就知道哪些 props 受到了影响。

常见问题

这套方案需要运行时支持吗?会不会增加打包体积?

不需要。版本标记完全在 TypeScript 类型层面运作,编译成 JavaScript 之后没有任何痕迹,对运行时性能和打包体积零影响。__version?: V 这个字段是可选的且在类型上不可枚举,业务代码里没法真正访问它,纯粹是给类型检查器和 IDE 看的。

已有的 props 行为变了但不改版本号会怎样?

类型系统强制不了这一点,这依赖团队规范。我们的做法是在 ESLint 里加了一条自定义规则:如果 prop 的 JSDoc 里标注了 @version,那么修改这个 prop 的类型定义或默认值时,ESLint 会要求同步更新版本号。但行为层面的变更(比如改了 loading 的内部逻辑)确实检测不到,这靠的是 Code Review 和 PR 模板里的强制检查项。说到底,工具只能让不规范的操作更显眼,不能杜绝。

如果某个 prop 必须做 breaking change 怎么办?

不删旧 prop,新增一个带新版本号的 prop,旧 prop 标记为 deprecated 并保留至少一个大版本周期。比如 valuestring 变成 string | number,我们不直接改 value 的类型,而是新增 valueV2: string | number(版本号 2.0.0),让 value 在 deprecated 状态下继续接受 string。业务方可以渐进迁移,不用在升级组件库的同时改业务代码。

业务方怎么知道该升级到哪个版本?

我们在 CI 里跑一个版本兼容性扫描脚本,分析项目中实际使用的所有 props 的版本号,生成一份报告告诉业务方:“你当前用的是 1.3.2,但你的代码里用到了 onLoadingChange(需要 >= 1.5.0)和 asyncDataSource(需要 >= 2.0.0),建议至少升级到 2.0.0”。这个报告每个迭代自动更新,业务方打开 MR 就能看到。