《互联网产品的客户端架构·规模化与架构演进》2.2 组件契约与版本化

按钮组件从 1.4 升到 1.5,发布说明写着「只加了可选属性」,四十多个业务包却在编译期报错。问题不在版本号,而在没人把组件契约当成可比较的对象。本节用 TypeScript 模块演示属性、事件、插槽与可访问性语义的变更分类、兼容判定、版本递增和契约快照比较。

一次「只加了个可选属性」的补丁发布

某设计系统的 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 类型,会漏掉另外三面;只靠发布说明,会漏掉作者没想到的面。

契约还要有版本号。版本号不是给发布流程看的,而是给下游判断「能不能无感升级」用的。因此版本号必须由变更内容推导,而不是由作者的主观感受决定。

变更只有三类,判定规则要写死

把变更分成破坏性、兼容、补丁三类,每类对应一种版本递增。关键是规则要写死到代码里,不能每次评审临时讨论:

变更分类版本递增理由
删除属性、事件、插槽breakingmajor存量调用方必然失去能力
新增必填属性或必填插槽breakingmajor存量调用方无法通过类型检查
属性类型变化、事件载荷变化breakingmajor调用方按旧类型写的逻辑失效
可选变必填、枚举删除取值breakingmajor旧输入不再合法
a11y role 变化、移除键盘操作breakingmajor无障碍语义与操作路径改变
新增可选属性、可选插槽、事件compatibleminor存量调用方无需改动
必填放宽为可选、枚举新增取值compatibleminor旧输入仍然合法
默认值变化、标记废弃patchpatch不改变 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 一定不兼容。它判断的是「是否满足最低版本」,不是「是否恰好等于」。

判据与反例

发布组件前,用下面五条判据检查:

  1. 契约完整。 四个面都有声明,不能只写 Props 类型。
  2. 变更可分类。 每条 diff 都能落到 breaking、compatible、patch 之一,分类不了说明规则有缺口。
  3. 版本满足最低要求。 isCompatible(base, next, changes) 必须为真,否则拒绝发布。
  4. 快照可复现。 同一契约在不同机器、不同属性顺序下摘要一致。
  5. 破坏性变更有一对一迁移说明。 分类只是发现问题,迁移文档才是解决问题。

反例一:把版本号交给作者手填。作者通常乐观,认为「只是小改动」,于是破坏性变更被打成补丁。版本号必须由 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 也可能是破坏性的;必须结合变更列表判断。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查