一次「只加了个可选属性」的补丁发布
某设计系统的 Button 从 1.4.0 升到 1.4.1。发布说明只有一句:「新增 loading 可选属性,不影响存量调用方。」改动确实只加了一个 loading?: boolean,评审也通过了。
发布后两天,接入方陆续报错:
- 电商业务包的按钮点击事件回调拿到的是
PointerEvent,而它按MouseEvent写的类型收窄逻辑全部失效,涉及 12 个调用点。 - 会员业务包依赖
size属性做埋点,1.4.1 里size被悄悄删除,埋点报表出现 3 天空白。 - 客服业务包给
icon插槽传了图标,但插槽被移除,图标无处渲染,页面出现 7 处空白按钮。 - 无障碍扫描发现按钮的
role从button变成了switch,键盘操作也少了一个键。
四类改动分别落在属性、事件、插槽和可访问性语义上,但没有一份可比较的契约,发布说明只能靠人回忆改了什么。版本号写的是补丁,实际影响是主版本。
组件契约有四个面
组件契约不是 TypeScript 的 Props 类型。类型只覆盖属性,漏掉事件、插槽和无障碍语义,而后者恰恰最容易在重构中被无声改掉:
| 契约面 | 描述什么 | 典型破坏方式 | 调用方症状 |
|---|---|---|---|
| 属性 props | 输入的名字、类型、必填性、枚举取值、默认值 | 删除、类型变化、可选变必填 | 编译失败或运行值错位 |
| 事件 events | 事件名与载荷类型 | 载荷改名、删除事件 | 回调类型不匹配、监听静默失效 |
| 插槽 slots | 可插入内容的位置与必填性 | 删除插槽、可选变必填 | 内容不渲染、布局缺块 |
| 可访问性 a11y | 角色、标签关联、键盘操作 | role 变化、移除键盘键 | 屏幕阅读器语义错误、键盘不可用 |
把这四个面放在同一个结构里,才能做整体比较。只比较 Props 类型,会漏掉另外三面;只靠发布说明,会漏掉作者没想到的面。
契约还要有版本号。版本号不是给发布流程看的,而是给下游判断「能不能无感升级」用的。因此版本号必须由变更内容推导,而不是由作者的主观感受决定。
变更只有三类,判定规则要写死
把变更分成破坏性、兼容、补丁三类,每类对应一种版本递增。关键是规则要写死到代码里,不能每次评审临时讨论:
| 变更 | 分类 | 版本递增 | 理由 |
|---|---|---|---|
| 删除属性、事件、插槽 | breaking | major | 存量调用方必然失去能力 |
| 新增必填属性或必填插槽 | breaking | major | 存量调用方无法通过类型检查 |
| 属性类型变化、事件载荷变化 | breaking | major | 调用方按旧类型写的逻辑失效 |
| 可选变必填、枚举删除取值 | breaking | major | 旧输入不再合法 |
| a11y role 变化、移除键盘操作 | breaking | major | 无障碍语义与操作路径改变 |
| 新增可选属性、可选插槽、事件 | compatible | minor | 存量调用方无需改动 |
| 必填放宽为可选、枚举新增取值 | compatible | minor | 旧输入仍然合法 |
| 默认值变化、标记废弃 | patch | patch | 不改变 API 形状,但改变行为 |
一个容易争议的点是默认值变化。它不改变类型,却会改变运行时表现,所以既不能算 breaking,也不能完全忽略。把它定为 patch,要求发布说明单独标注,是折中方案。
另一个争议点是枚举新增取值。对使用方来说,旧值仍然合法;对实现方来说,可能需要在 switch 里补分支。把它定为 compatible 而不是 patch,是因为它确实扩展了契约,调用方应当知道。
把契约比较写成代码
下面这个模块把契约结构、变更分类、版本递增、兼容判定和快照摘要都做成纯函数。它比较的是契约对象,不是源码文本,因此重构内部实现不会误报:
// file: src/design/component-contract.ts
// tier: L1
// verify: node --test test/design/component-contract.test.ts
export type PropType = "string" | "number" | "boolean" | "enum" | "function";
export type PropSpec = {
name: string;
type: PropType;
required: boolean;
values?: string[];
defaultValue?: string;
deprecated?: boolean;
};
export type EventSpec = { name: string; payload: string };
export type SlotSpec = { name: string; required: boolean };
export type A11ySpec = {
role: string;
labelProp?: string;
describedByProp?: string;
keyboard: string[];
};
export type ComponentContract = {
name: string;
version: string;
props: PropSpec[];
events: EventSpec[];
slots: SlotSpec[];
a11y: A11ySpec;
};
export type ChangeSeverity = "breaking" | "compatible" | "patch";
export type ContractChange = {
kind: string;
target: string;
severity: ChangeSeverity;
detail: string;
};
export type BumpKind = "none" | "patch" | "minor" | "major";
export type Semver = { major: number; minor: number; patch: number };
export const BUTTON_V1: ComponentContract = {
name: "Button",
version: "1.4.0",
props: [
{ name: "label", type: "string", required: true },
{ name: "variant", type: "enum", required: true, values: ["primary", "secondary", "ghost"] },
{ name: "size", type: "enum", required: false, values: ["sm", "md", "lg"], defaultValue: "md" },
{ name: "disabled", type: "boolean", required: false, defaultValue: "false" },
],
events: [{ name: "click", payload: "MouseEvent" }, { name: "focus", payload: "FocusEvent" }],
slots: [{ name: "default", required: true }, { name: "icon", required: false }],
a11y: {
role: "button",
labelProp: "aria-label",
describedByProp: "aria-describedby",
keyboard: ["Enter", "Space"],
},
};
export const BUTTON_MINOR: ComponentContract = {
...BUTTON_V1,
version: "1.5.0",
props: [
...BUTTON_V1.props,
{ name: "loading", type: "boolean", required: false, defaultValue: "false" },
{ name: "variant", type: "enum", required: true, values: ["primary", "secondary", "ghost", "danger"] },
],
events: [...BUTTON_V1.events, { name: "press", payload: "PressEvent" }],
slots: [...BUTTON_V1.slots, { name: "badge", required: false }],
};
export const BUTTON_BREAKING: ComponentContract = {
...BUTTON_V1,
version: "2.0.0",
props: BUTTON_V1.props.filter((prop) => prop.name !== "size"),
events: [{ name: "click", payload: "PointerEvent" }, { name: "focus", payload: "FocusEvent" }],
slots: BUTTON_V1.slots.filter((slot) => slot.name !== "icon"),
a11y: { role: "switch", labelProp: "aria-label", describedByProp: "aria-describedby", keyboard: ["Enter"] },
};
export function diffContracts(
base: ComponentContract,
next: ComponentContract,
): ContractChange[] {
const changes: ContractChange[] = [];
const add = (kind: string, target: string, severity: ChangeSeverity, detail: string): void =>
changes.push({ kind, target, severity, detail });
const baseProps = new Map(base.props.map((prop) => [prop.name, prop]));
const nextProps = new Map(next.props.map((prop) => [prop.name, prop]));
for (const [name, before] of baseProps) {
const after = nextProps.get(name);
const target = `props.${name}`;
if (after === undefined) {
add("prop-removed", target, "breaking", `属性 ${name} 被删除`);
continue;
}
if (before.type !== after.type) add("prop-type-changed", target, "breaking", `属性 ${name} 类型变化`);
if (!before.required && after.required) add("prop-optional-to-required", target, "breaking", `属性 ${name} 变为必填`);
if (before.required && !after.required) add("prop-required-to-optional", target, "compatible", `属性 ${name} 放宽为可选`);
const oldValues = new Set(before.values ?? []);
const newValues = new Set(after.values ?? []);
const removed = [...oldValues].filter((value) => !newValues.has(value));
const added = [...newValues].filter((value) => !oldValues.has(value));
if (removed.length > 0) add("prop-enum-value-removed", target, "breaking", `枚举删除 ${removed.join(", ")}`);
if (added.length > 0) add("prop-enum-value-added", target, "compatible", `枚举新增 ${added.join(", ")}`);
if (before.defaultValue !== after.defaultValue) add("prop-default-changed", target, "patch", `属性 ${name} 默认值变化`);
if (before.deprecated !== true && after.deprecated === true) add("prop-deprecated", target, "patch", `属性 ${name} 标记废弃`);
}
for (const [name, after] of nextProps) {
if (baseProps.has(name)) continue;
add(after.required ? "prop-added-required" : "prop-added-optional", `props.${name}`,
after.required ? "breaking" : "compatible",
after.required ? `新增必填属性 ${name}` : `新增可选属性 ${name}`);
}
const baseEvents = new Map(base.events.map((event) => [event.name, event]));
const nextEvents = new Map(next.events.map((event) => [event.name, event]));
for (const [name, before] of baseEvents) {
const after = nextEvents.get(name);
if (after === undefined) add("event-removed", `events.${name}`, "breaking", `事件 ${name} 被删除`);
else if (before.payload !== after.payload) add("event-payload-changed", `events.${name}`, "breaking", `事件 ${name} 载荷变化`);
}
for (const name of nextEvents.keys()) {
if (!baseEvents.has(name)) add("event-added", `events.${name}`, "compatible", `新增事件 ${name}`);
}
const baseSlots = new Map(base.slots.map((slot) => [slot.name, slot]));
const nextSlots = new Map(next.slots.map((slot) => [slot.name, slot]));
for (const [name, before] of baseSlots) {
const after = nextSlots.get(name);
if (after === undefined) add("slot-removed", `slots.${name}`, "breaking", `插槽 ${name} 被删除`);
else if (!before.required && after.required) add("slot-required-changed", `slots.${name}`, "breaking", `插槽 ${name} 变为必填`);
else if (before.required && !after.required) add("slot-required-changed", `slots.${name}`, "compatible", `插槽 ${name} 放宽为可选`);
}
for (const [name, after] of nextSlots) {
if (baseSlots.has(name)) continue;
add(after.required ? "slot-required-added" : "slot-optional-added", `slots.${name}`,
after.required ? "breaking" : "compatible",
after.required ? `新增必填插槽 ${name}` : `新增可选插槽 ${name}`);
}
if (base.a11y.role !== next.a11y.role) add("a11y-role-changed", "a11y.role", "breaking", `角色由 ${base.a11y.role} 改为 ${next.a11y.role}`);
if (base.a11y.labelProp !== next.a11y.labelProp) {
add("a11y-label-changed", "a11y.labelProp",
base.a11y.labelProp === undefined ? "compatible" : "breaking", "标签属性关联变化");
}
const oldKeys = new Set(base.a11y.keyboard);
const newKeys = new Set(next.a11y.keyboard);
const keysRemoved = [...oldKeys].filter((key) => !newKeys.has(key));
const keysAdded = [...newKeys].filter((key) => !oldKeys.has(key));
if (keysRemoved.length > 0) add("a11y-keyboard-removed", "a11y.keyboard", "breaking", `移除键盘操作 ${keysRemoved.join(", ")}`);
if (keysAdded.length > 0) add("a11y-keyboard-added", "a11y.keyboard", "compatible", `新增键盘操作 ${keysAdded.join(", ")}`);
return changes.sort((a, b) =>
a.target === b.target ? a.kind.localeCompare(b.kind) : a.target.localeCompare(b.target));
}
export function requiredBump(changes: ContractChange[]): BumpKind {
if (changes.some((change) => change.severity === "breaking")) return "major";
if (changes.some((change) => change.severity === "compatible")) return "minor";
return changes.length > 0 ? "patch" : "none";
}
export function parseVersion(version: string): Semver | null {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
return match === null
? null
: { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
}
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
const left = parseVersion(a);
const right = parseVersion(b);
if (left === null || right === null) throw new Error(`非法版本号: ${left === null ? a : b}`);
if (left.major !== right.major) return left.major < right.major ? -1 : 1;
if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1;
if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1;
return 0;
}
export function bumpVersion(version: string, kind: BumpKind): string {
const parsed = parseVersion(version);
if (parsed === null) throw new Error(`非法版本号: ${version}`);
if (kind === "major") return `${parsed.major + 1}.0.0`;
if (kind === "minor") return `${parsed.major}.${parsed.minor + 1}.0`;
if (kind === "patch") return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`;
return version;
}
export function suggestVersion(base: string, changes: ContractChange[]): string {
return bumpVersion(base, requiredBump(changes));
}
export function isCompatible(
baseVersion: string,
nextVersion: string,
changes: ContractChange[],
): boolean {
if (parseVersion(baseVersion) === null || parseVersion(nextVersion) === null) return false;
return compareVersions(nextVersion, suggestVersion(baseVersion, changes)) >= 0;
}
export function contractSnapshot(contract: ComponentContract): string {
const props = [...contract.props]
.sort((a, b) => a.name.localeCompare(b.name))
.map((prop) => ({
name: prop.name,
type: prop.type,
required: prop.required,
values: prop.values === undefined ? null : [...prop.values].sort(),
defaultValue: prop.defaultValue ?? null,
deprecated: prop.deprecated === true,
}));
const events = [...contract.events]
.sort((a, b) => a.name.localeCompare(b.name))
.map((event) => ({ name: event.name, payload: event.payload }));
const slots = [...contract.slots]
.sort((a, b) => a.name.localeCompare(b.name))
.map((slot) => ({ name: slot.name, required: slot.required }));
const a11y = {
role: contract.a11y.role,
labelProp: contract.a11y.labelProp ?? null,
describedByProp: contract.a11y.describedByProp ?? null,
keyboard: [...contract.a11y.keyboard].sort(),
};
return JSON.stringify({ name: contract.name, props, events, slots, a11y }, null, 2);
}
export function snapshotDigest(contract: ComponentContract): string {
const text = contractSnapshot(contract);
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
export function sameContract(a: ComponentContract, b: ComponentContract): boolean {
return contractSnapshot(a) === contractSnapshot(b);
}
模块里有四个关键设计。
第一,diffContracts 的输出按 target 排序。同一份契约无论属性声明顺序如何,变更列表都稳定,CI 可以直接做快照对比。
第二,requiredBump 取所有变更里的最高等级。一个版本里既有补丁又有破坏性变更,结果必须是 major,不能取平均。
第三,contractSnapshot 故意不包含版本号。版本号是结论,不是契约内容;只改版本号不应产生快照差异。这条规则能抓住「改了版本号但忘了改契约」和「改了契约但忘了改版本号」两种错误。
第四,isCompatible 允许下游跳得比最低要求更高。最低要求是 1.5.0,发布 2.0.0 也算兼容;但发布 1.4.9 一定不兼容。它判断的是「是否满足最低版本」,不是「是否恰好等于」。
判据与反例
发布组件前,用下面五条判据检查:
- 契约完整。 四个面都有声明,不能只写
Props类型。 - 变更可分类。 每条 diff 都能落到 breaking、compatible、patch 之一,分类不了说明规则有缺口。
- 版本满足最低要求。
isCompatible(base, next, changes)必须为真,否则拒绝发布。 - 快照可复现。 同一契约在不同机器、不同属性顺序下摘要一致。
- 破坏性变更有一对一迁移说明。 分类只是发现问题,迁移文档才是解决问题。
反例一:把版本号交给作者手填。作者通常乐观,认为「只是小改动」,于是破坏性变更被打成补丁。版本号必须由 diff 计算,手填只能作为对照。
反例二:用源码 diff 判断兼容性。格式化、变量重命名、注释调整都会产生 diff,但契约没变;反过来,动态生成的属性可能不出现在源码 diff 里。比较契约对象比比较文本可靠。
反例三:只比较类型定义。TypeScript 类型能表达属性,却表达不了「事件载荷从 MouseEvent 变成 PointerEvent」这种运行时契约,也表达不了插槽和 a11y。
小结
组件版本化的核心不是版本号,而是可比较的契约。
- 契约有属性、事件、插槽、可访问性四个面,缺一面就会漏一类破坏性变更。
- 变更分 breaking、compatible、patch 三类,版本递增由最高等级决定。
- 快照比较要忽略版本号、稳定排序,并在发布前强制检查兼容性。
先有契约,再有版本号。顺序反了,版本号只是发布说明里的装饰。
阅读导航:上一节:2.1 设计 token 与主题化 · 下一节:2.3 多品牌与多业务复用 。
常见错误与边界
- 把
Props类型当完整契约。 事件载荷、插槽和 a11y 语义不在类型里,重构时最容易被无声改掉。 - 版本号手填。 作者倾向低估影响,破坏性变更常被打成补丁;应由 diff 计算最低版本。
- 快照包含版本号。 只改版本号也会产生差异,快照失去「契约是否变化」的判断力。
- 变更列表不排序。 属性声明顺序变化会导致快照抖动,CI 频繁误报。
- 把默认值变化当 breaking。 它不改变 API 形状,但会改变行为;定为 patch 并要求发布说明标注更合适。
- 忽略 a11y 的版本影响。 role 和键盘操作属于契约,不是实现细节;变化必须走 major。
- 只做分类不做迁移。 分类告诉你「不能发」,迁移文档才告诉调用方「怎么改」。
- 兼容判定只比较主版本号。 1.4.0 到 1.4.1 也可能是破坏性的;必须结合变更列表判断。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。