《互联网产品的客户端架构·端形态与体验基础》8.1 设计系统与组件契约

组件契约不是一份文档,而是可以被校验的数据结构:props、事件、插槽、状态与可访问性各有明确边界。本节把「回声」搜索框的契约写成 TypeScript 结构,给出契约自检与调用校验两套规则,并用真实测试输出说明重复属性、必填默认值、受控缺处理器等问题如何在编码前被拦住。

组件契约要解决的问题

设计系统最常见的失败不是“没有组件库”,而是组件库有几十个组件,每个组件的用法却靠口口相传。同一个搜索框,有人传 value,有人传 initialValue;有人监听 onChange,有人监听 onInput;有人把图标塞进 prefix 插槽,有人直接改组件内部的 CSS。代码能跑,但团队无法回答一个基本问题:这个组件到底承诺了什么?

组件契约就是把这个承诺写下来,并且写成机器能检查的形式。它不是更长的 README,而是一组带类型、带约束、可被单元测试覆盖的数据。契约一旦可校验,评审就能从“我觉得这样用不对”变成“校验器报了 unknown-prop”。

「回声」的搜索框同时出现在发现页、会话页和订单页,是本章的样本组件。它看似简单,却同时包含受控与非受控、枚举尺寸、可选插槽、键盘交互与可访问名称,足够暴露契约设计里的典型取舍。

契约的五个面

一个组件对外承诺的东西可以收敛成五个面:

  • props:调用方可以传什么,类型、是否必填、默认值、枚举候选值分别是什么。
  • 事件:组件会向外发出什么,事件名与 payload 字段必须稳定。
  • 插槽:哪些位置允许调用方插入内容,必填插槽缺失时应该报错。
  • 状态:哪些状态由组件自己管,哪些必须由调用方接管,受控状态要绑定到哪个 prop 和哪个事件。
  • 可访问性:角色是什么,可访问名称来自哪个 prop,键盘需要支持哪些键。

这五个面里,props 和事件最容易被写进类型定义,插槽与状态经常只存在于实现里,可访问性则几乎总是被漏掉。契约校验的价值,恰恰在于把后三个面拉到和类型定义同等的位置。

为什么必须是可校验结构

只写文档有三个问题。第一,文档不会随着代码更新,组件改了 prop 名,文档还停在上一版。第二,文档无法在 CI 里运行,违反契约的调用要等到用户点击才发现。第三,文档没有“严重级别”,团队分不清哪些是建议、哪些是阻断。

可校验结构把这三件事一起解决:契约和组件在同一个仓库,校验器可以在测试与构建阶段运行,每条问题都有稳定的错误码和路径。错误码比自然语言更适合做回归断言,路径则直接告诉调用方该改哪一行。

把契约写成数据结构

下面的文件把五个面定义成接口,并实现两个纯函数:validateContract 检查契约自身是否自洽,validateUsage 检查一次具体调用是否满足契约。示例契约就是「回声」搜索框。

// file: src/view/contract.ts
// tier: L1
// verify: node --test test/view/contract.test.ts

export type PropKind = "string" | "number" | "boolean" | "enum" | "node";
export type Ownership = "local" | "controlled";
export type Severity = "error" | "warning";

export interface PropContract {
  name: string;
  kind: PropKind;
  required: boolean;
  defaultValue?: string | number | boolean;
  values?: string[];
  description: string;
}

export interface EventContract {
  name: string;
  payload: string[];
  description: string;
}

export interface SlotContract {
  name: string;
  required: boolean;
  description: string;
}

export interface StateContract {
  name: string;
  ownership: Ownership;
  prop?: string;
  event?: string;
  description: string;
}

export interface A11yContract {
  role: string;
  labelProp?: string;
  keyboard: string[];
}

export interface ComponentContract {
  name: string;
  version: string;
  props: PropContract[];
  events: EventContract[];
  slots: SlotContract[];
  state: StateContract[];
  a11y: A11yContract;
}

export interface ContractIssue {
  code: string;
  path: string;
  severity: Severity;
  message: string;
}

export interface ComponentUsage {
  props: Record<string, unknown>;
  handlers: string[];
  slots: string[];
}

const NAME_RE = /^[a-z][a-z0-9-]*$/;
const VERSION_RE = /^\d+\.\d+\.\d+$/;

function issue(code: string, path: string, severity: Severity, message: string): ContractIssue {
  return { code, path, severity, message };
}

function duplicates(names: string[]): string[] {
  const seen = new Set<string>();
  const repeated = new Set<string>();
  for (const name of names) {
    if (seen.has(name)) repeated.add(name);
    seen.add(name);
  }
  return [...repeated].sort();
}

function matchesKind(kind: PropKind, value: unknown): boolean {
  if (kind === "string" || kind === "enum") return typeof value === "string";
  if (kind === "number") return typeof value === "number" && Number.isFinite(value);
  if (kind === "boolean") return typeof value === "boolean";
  return false;
}

/** 校验契约本身是否自洽,不检查某个具体用法。 */
export function validateContract(contract: ComponentContract): ContractIssue[] {
  const issues: ContractIssue[] = [];
  if (!NAME_RE.test(contract.name)) issues.push(issue("bad-name", "name", "error", "组件名必须是 kebab-case"));
  if (!VERSION_RE.test(contract.version)) issues.push(issue("bad-version", "version", "error", "版本必须是 x.y.z"));

  for (const name of duplicates(contract.props.map((p) => p.name))) {
    issues.push(issue("duplicate-prop", `prop.${name}`, "error", `prop ${name} 重复`));
  }
  for (const prop of contract.props) {
    const path = `prop.${prop.name}`;
    if (prop.kind === "enum") {
      if (!prop.values?.length) issues.push(issue("enum-without-values", path, "error", "enum 必须声明候选值"));
      else if (duplicates(prop.values).length) issues.push(issue("duplicate-enum-value", path, "error", "候选值重复"));
    } else if (prop.values !== undefined) {
      issues.push(issue("values-on-non-enum", path, "error", "只有 enum 可以声明 values"));
    }
    if (prop.defaultValue === undefined) continue;
    if (prop.required) issues.push(issue("required-with-default", path, "error", "必填 prop 不应带默认值"));
    if (prop.kind === "node") issues.push(issue("node-with-default", path, "error", "node 无法序列化默认值"));
    else if (!matchesKind(prop.kind, prop.defaultValue)) issues.push(issue("default-type-mismatch", path, "error", "默认值类型不符"));
    else if (prop.kind === "enum" && !prop.values?.includes(String(prop.defaultValue))) {
      issues.push(issue("default-not-in-enum", path, "error", "默认值不在候选值内"));
    }
  }

  for (const name of duplicates(contract.events.map((e) => e.name))) {
    issues.push(issue("duplicate-event", `event.${name}`, "error", `事件 ${name} 重复`));
  }
  for (const event of contract.events) {
    if (duplicates(event.payload).length) issues.push(issue("duplicate-payload", `event.${event.name}`, "error", "payload 重复"));
  }
  for (const name of duplicates(contract.slots.map((s) => s.name))) {
    issues.push(issue("duplicate-slot", `slot.${name}`, "error", `插槽 ${name} 重复`));
  }
  for (const name of duplicates(contract.state.map((s) => s.name))) {
    issues.push(issue("duplicate-state", `state.${name}`, "error", `状态 ${name} 重复`));
  }

  const propNames = new Set(contract.props.map((p) => p.name));
  const eventNames = new Set(contract.events.map((e) => e.name));
  for (const state of contract.state) {
    const path = `state.${state.name}`;
    if (state.ownership === "controlled") {
      if (!state.prop || !propNames.has(state.prop)) issues.push(issue("state-prop-missing", path, "error", "受控状态未指向已声明 prop"));
      if (!state.event || !eventNames.has(state.event)) issues.push(issue("state-event-missing", path, "error", "受控状态未指向已声明事件"));
    } else if (state.prop !== undefined || state.event !== undefined) {
      issues.push(issue("local-state-external", path, "error", "本地状态不应绑定 prop 或事件"));
    }
  }

  if (!contract.a11y.role.trim()) issues.push(issue("missing-role", "a11y.role", "error", "必须声明角色"));
  if (contract.a11y.labelProp !== undefined) {
    const label = contract.props.find((p) => p.name === contract.a11y.labelProp);
    if (!label) issues.push(issue("label-prop-missing", "a11y.labelProp", "error", "labelProp 未声明"));
    else if (label.kind !== "string") issues.push(issue("label-prop-not-string", "a11y.labelProp", "error", "labelProp 必须是 string"));
  }
  if (duplicates(contract.a11y.keyboard).length) issues.push(issue("duplicate-key", "a11y.keyboard", "warning", "键盘按键重复"));
  return issues.sort((a, b) => a.path.localeCompare(b.path) || a.code.localeCompare(b.code));
}

/** 校验一次具体调用是否满足契约,包括受控状态是否配了处理器。 */
export function validateUsage(contract: ComponentContract, usage: ComponentUsage): ContractIssue[] {
  const issues: ContractIssue[] = [];
  const props = new Map(contract.props.map((p) => [p.name, p]));
  const events = new Set(contract.events.map((e) => e.name));
  const slots = new Set(contract.slots.map((s) => s.name));

  for (const name of Object.keys(usage.props)) {
    if (!props.has(name)) issues.push(issue("unknown-prop", `prop.${name}`, "error", `未声明的 prop ${name}`));
  }
  for (const prop of contract.props) {
    const supplied = usage.props[prop.name];
    if (supplied === undefined) {
      if (prop.required) issues.push(issue("missing-required-prop", `prop.${prop.name}`, "error", "缺少必填 prop"));
      continue;
    }
    if (prop.kind !== "node" && !matchesKind(prop.kind, supplied)) {
      issues.push(issue("prop-type-mismatch", `prop.${prop.name}`, "error", "传入值类型不符"));
    }
    if (prop.kind === "enum" && !prop.values?.includes(String(supplied))) {
      issues.push(issue("prop-not-in-enum", `prop.${prop.name}`, "error", "传入值不在候选值内"));
    }
  }

  for (const name of usage.handlers) {
    if (!events.has(name)) issues.push(issue("unknown-handler", `event.${name}`, "error", `未声明的事件 ${name}`));
  }
  for (const state of contract.state) {
    if (state.ownership !== "controlled" || !state.prop || !state.event) continue;
    if (usage.props[state.prop] !== undefined && !usage.handlers.includes(state.event)) {
      issues.push(issue("controlled-without-handler", `state.${state.name}`, "error", "受控值缺少变更处理器"));
    }
  }

  for (const slot of contract.slots) {
    if (slot.required && !usage.slots.includes(slot.name)) {
      issues.push(issue("missing-required-slot", `slot.${slot.name}`, "error", "缺少必填插槽"));
    }
  }
  for (const name of usage.slots) {
    if (!slots.has(name)) issues.push(issue("unknown-slot", `slot.${name}`, "error", `未声明的插槽 ${name}`));
  }
  if (contract.a11y.labelProp !== undefined) {
    const name = usage.props[contract.a11y.labelProp];
    if (typeof name !== "string" || !name.trim()) {
      issues.push(issue("missing-accessible-name", "a11y.label", "error", "缺少可访问名称"));
    }
  }
  return issues.sort((a, b) => a.path.localeCompare(b.path) || a.code.localeCompare(b.code));
}

/** 「回声」搜索框的完整契约,供文章和测试共同引用。 */
export const searchFieldContract: ComponentContract = {
  name: "search-field",
  version: "1.2.0",
  props: [
    { name: "value", kind: "string", required: false, description: "受控查询词" },
    { name: "defaultValue", kind: "string", required: false, defaultValue: "", description: "非受控初始值" },
    { name: "label", kind: "string", required: true, description: "可访问名称" },
    { name: "placeholder", kind: "string", required: false, description: "占位提示" },
    { name: "size", kind: "enum", required: false, defaultValue: "md", values: ["sm", "md", "lg"], description: "尺寸档位" },
    { name: "disabled", kind: "boolean", required: false, defaultValue: false, description: "是否禁用" },
    { name: "prefix", kind: "node", required: false, description: "前置图标" },
  ],
  events: [
    { name: "change", payload: ["value"], description: "输入变化" },
    { name: "submit", payload: ["value"], description: "提交查询" },
    { name: "clear", payload: [], description: "清空" },
  ],
  slots: [
    { name: "prefix", required: false, description: "前置内容" },
    { name: "empty", required: false, description: "无结果占位" },
  ],
  state: [
    { name: "draft", ownership: "local", description: "非受控时的内部草稿" },
    { name: "query", ownership: "controlled", prop: "value", event: "change", description: "受控查询词" },
  ],
  a11y: { role: "searchbox", labelProp: "label", keyboard: ["Enter", "Escape"] },
};

这份实现有几个刻意的设计。

第一,required 与 defaultValue 互斥。必填意味着调用方必须给出值,默认值意味着调用方可以省略,两者同时出现时语义矛盾。很多组件库允许这种组合,结果是“必填校验”永远不触发。

第二,enum 的默认值必须落在候选值里。size 的候选值是 sm | md | lg,默认值写成 medium 时,类型系统未必能发现,运行时会静默退化成无样式。契约校验把这类拼写错误提前到测试阶段。

第三,受控状态必须同时指向 prop 和事件。只写 prop: "value" 而不写 event: "change",组件就变成了“只能读、不能改”的半受控状态。校验器要求两者成对出现,调用校验再检查传了受控值时有没有配处理器。

第四,node 类型不允许默认值。节点、组件实例、DOM 引用都无法可靠序列化,给它们设默认值通常意味着在模块加载时就创建了一个共享可变对象,多个实例会互相污染。

第五,可访问名称来自 label 这个必填 prop。labelProp 指向的字段必须是字符串,调用时还必须非空。空字符串和纯空格都算缺失,这一点在后面的可访问性章节还会展开。

校验器:契约自检与调用校验

两个函数分工明确。validateContract 面向组件作者,回答“这份契约本身有没有矛盾”;validateUsage 面向调用方,回答“这次传参有没有违反承诺”。它们的输出结构一致,都是带 code、path、severity、message 的数组,并按路径与错误码排序,保证同一份输入永远得到同一份输出。

常被问到的错误码可以归成四类:

类别错误码示例典型原因
重复声明duplicate-prop、duplicate-event复制粘贴后忘了改名
默认值矛盾required-with-default、default-not-in-enum必填项又给默认值,枚举值拼错
状态错配state-prop-missing、controlled-without-handler受控状态没有对应 prop 或事件处理器
调用越界unknown-prop、missing-required-prop、unknown-slot调用方按旧版本文档传参

这里不抛异常,而是返回问题列表。原因是校验器要在一次运行里报告尽可能多的问题,抛异常会让调用方每修一个错就要重跑一次。返回列表还方便测试直接比对错误码,不必匹配异常消息。

还有一个边界要说清楚:校验器检查的是结构与声明,不检查视觉与交互效果。它不能告诉你搜索框的圆角对不对,也不能证明屏幕阅读器一定念得对。它保证的是“契约里写了的约束都被执行了”,而不是“组件一定好用”。

测试与真实输出

测试覆盖两类场景:契约自身的问题(重复 prop、必填默认值、枚举默认值越界、受控状态指向缺失、标签来源缺失)与调用侧的问题(未知 prop、缺必填、类型不符、未知事件、缺插槽、受控缺处理器、空标签)。在实验工程根目录执行:

node --test test/view/contract.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 示例契约本身没有错误 (1.432ms)
✔ 重复 prop 与必填默认值会被拦住 (8.825041ms)
✔ enum 默认值必须落在候选值内 (0.12825ms)
✔ 受控状态必须指向真实的 prop 与事件 (0.117208ms)
✔ labelProp 指向不存在的 prop 会失败 (0.096833ms)
✔ 合法调用没有错误 (0.160167ms)
✔ 未知 prop、缺必填、类型不符、非法枚举值都会报错 (0.087ms)
✔ 未知事件与缺少必填插槽会被发现 (0.09275ms)
✔ 受控值存在但没有变更处理器会被拒绝 (0.148416ms)
✔ 空字符串不能充当可访问名称 (0.113125ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 185.836917

这里的耗时只用于说明测试确实运行过,不是性能指标。10/10 通过是本节的硬门禁;如果只改契约不跑测试,默认值和受控状态这两类问题很容易漏过去。

一次契约失守的复盘

某次改版把搜索框的 onChange 拆成了 onInput 和 onSubmit 两个事件,理由是“输入和提交的语义不同”。组件代码、类型定义和故事书都改了,唯独三个业务页面还在监听 onChange。TypeScript 没有报错,因为旧的 onChange 仍然作为可选属性留在类型里;运行时也没有报错,因为未知事件只是被忽略。

结果是一个很隐蔽的 bug:输入框能打字,但列表不刷新。用户以为搜索坏了,其实是变更事件没人接。复盘时发现,如果当时有 unknown-handler 校验,三个页面会在 CI 里直接失败;如果契约里把受控状态的 event 写成必填并校验,删除 onChange 也会让契约自检失败。

这件事的教训不是“改事件名要小心”,而是“兼容旧属性的类型定义会让契约失效”。类型系统允许可选属性长期存在,契约校验则应该明确标记废弃项,并在下一个大版本删除。

契约的版本演进

契约需要版本号,但版本号本身不解决兼容问题。可操作的规则是:

  • 新增可选 prop、新增可选插槽、新增事件:小版本。
  • 修改默认值、修改枚举候选值、把可选 prop 改成必填:中版本,必须走弃用周期。
  • 删除 prop、重命名事件、改变 payload 字段:大版本,旧调用必须同时迁移。

弃用周期里,旧字段保留但标记为 warning,新字段同时可用。校验器按严重级别输出,CI 可以先允许 warning 通过,到截止日期再提升为 error。这样“兼容”不是一个口号,而是一条能被观测的曲线。

小结

组件契约把 props、事件、插槽、状态与可访问性从实现细节提升为可校验的数据。validateContract 保证契约自身不自相矛盾,validateUsage 保证调用方没有越界,两者都返回稳定排序的问题列表,适合直接写进测试。

契约不能替代设计评审,也不能证明组件好用;它解决的是“承诺是否被遵守”这个更基础的问题。先把承诺变成结构,团队才有条件讨论更高层的体验。

阅读导航:上一节:7.3 冲突解决与同步队列 · 下一节:8.2 受控与非受控 。

常见错误与边界

  • 把契约写成 README。文档不会随代码更新,也无法在 CI 里拦住违规调用。
  • 允许必填 prop 带默认值。语义矛盾会让必填校验形同虚设。
  • 用类型系统代替契约校验。可选属性、枚举拼写、插槽缺失都不在类型检查范围内。
  • 受控状态只声明 prop 不声明事件。组件会变成只能读不能改的半受控状态。
  • 给 node 类型设默认值。共享可变对象会在多个实例之间互相污染。
  • 只校验契约、不校验调用。组件作者自测通过,不代表业务方没有按旧文档传参。
  • 把校验器写成抛异常。一次只报一个错,修复成本高,也不利于测试断言。
  • 把 warning 永久保留。弃用没有截止日期,等于没有弃用。
  • 认为契约能保证体验。它保证承诺被遵守,不保证设计正确。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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