React 基础体系 · 第 20/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 组件库与设计系统:组合 API、Token、主题和版本治理

组件库解决的是“如何复用一组可运行的 UI 代码”,设计系统解决的是“产品应该使用哪些视觉、交互和语义规则”。两者相关,但不是同一个层次:

  • 组件库通常包含 React 组件、样式、类型、测试和构建产物。
  • 设计系统还包含设计原则、设计资源、Token、无障碍约束、内容规范、组件使用边界和变更流程。
  • 组件是运行时的实现单元;组件 API是调用方与组件之间的契约;版本治理负责让契约可以演进而不失控。

这篇文章以 React 19、现代 TypeScript 和主流框架为基础,讨论如何设计可组合的组件 API,如何用 Token 和主题承载视觉约束,以及如何对组件库进行版本、依赖和迁移治理。涉及服务端组件、SSR、Portal、动态内容和浏览器交互时,会明确客户端与服务端边界。


一、先确定系统的边界

一个典型的设计系统可以分成四层:

flowchart TB
  A[设计原则与无障碍规则] --> B[Design Tokens]
  B --> C[基础样式与主题]
  C --> D[React 组件库]
  D --> E[产品应用]
  A --> E
  F[版本、测试、变更和迁移治理] --> B
  F --> C
  F --> D
  F --> E

1. 设计原则不是组件 API

例如“危险操作必须明确反馈”是设计规则,“删除按钮使用 danger 语义”是组件约定,而:

<Button tone="danger" onClick={deleteItem}>
  删除
</Button>

是具体 API。

如果把设计规则直接散落在产品代码里,组件库只能提供外观,无法提供一致的语义。如果把所有规则都硬编码在组件内部,又会使组件无法适应不同产品。因此需要分层:

  1. 规则层:规定哪些状态、语义和交互必须存在。
  2. Token 层:把颜色、间距、圆角、字体等设计决策编码为可引用的变量。
  3. 组件层:把规则和 Token 组合成可访问、可复用的交互单元。
  4. 产品层:通过组合和少量扩展完成业务场景。

组件库不应试图替产品决定所有布局和业务流程。它应当保证组件边界内的行为稳定,例如按钮的键盘操作、对话框的焦点管理、表单字段的错误关联。


二、组合 API:让组件暴露结构,而不是暴露内部实现

2.1 什么是组合

组合是指父组件通过 children、插槽属性或复合组件,把多个相互协作的子组件组织成一个更高层的 UI。

例如,一个对话框可以由以下部分组成:

<Dialog open={open} onOpenChange={setOpen}>
  <Dialog.Trigger>打开设置</Dialog.Trigger>

  <Dialog.Content>
    <Dialog.Title>账户设置</Dialog.Title>
    <Dialog.Description>
      修改后会影响下一次登录。
    </Dialog.Description>

    <Dialog.Footer>
      <Dialog.Close>取消</Dialog.Close>
      <Button type="submit">保存</Button>
    </Dialog.Footer>
  </Dialog.Content>
</Dialog>

这里的 Dialog 不只是一个带 open 属性的盒子。它还需要协调:

  • Trigger 如何打开对话框;
  • Content 如何渲染到 Portal;
  • TitleDescription 如何生成并关联 ID;
  • Escape 键如何关闭;
  • 焦点打开时移动到哪里,关闭时返回哪里;
  • openonOpenChange 如何形成受控状态流。

组合 API 的价值在于,调用者可以调整结构,而组件仍然掌握必要的状态和语义。

2.2 组合与“传一堆配置”不是一回事

下面这种 API 看似简单:

<Dialog
  title="账户设置"
  description="修改后会影响下一次登录。"
  footer={<Button>保存</Button>}
/>

但它把结构固定成了组件作者预设的布局。如果产品需要在标题旁加入徽标、把操作区放到顶部或插入额外说明,就必须继续增加属性:

<Dialog
  title="账户设置"
  titleExtra={<Badge>Beta</Badge>}
  description="..."
  footer={<Button>保存</Button>}
  topActions={...}
/>

这类 API 的问题不是属性数量多本身,而是每个新增需求都在扩大公共契约。组合 API 则把稳定结构和可变内容分开:

<Dialog.Content size="medium">
  <Dialog.Header>
    <Dialog.Title>
      账户设置 <Badge>Beta</Badge>
    </Dialog.Title>
    <Dialog.Description>修改后会影响下一次登录。</Dialog.Description>
  </Dialog.Header>

  <SettingsForm />

  <Dialog.Footer>
    <Dialog.Close>取消</Dialog.Close>
    <Button type="submit">保存</Button>
  </Dialog.Footer>
</Dialog.Content>

2.3 children 的契约必须明确

children 在 React 中可以是:

  • 单个 React 元素;
  • 字符串、数字;
  • null、布尔值;
  • 数组或可迭代集合;
  • 函数形式的 render prop。

因此,组件不能默认假设 children 一定是一个元素,更不能随意使用 React.Children.only

例如以下代码要求调用者必须传入恰好一个 React 元素:

function OnlyChild({ children }: { children: React.ReactElement }) {
  return React.cloneElement(children, { className: "item" });
}

它会拒绝字符串、多个子节点和条件渲染结果。若这正是 API 要求,应在类型和文档中明确说明;否则更安全的实现是包裹一层:

function Stack({
  children,
}: {
  children: React.ReactNode;
}) {
  return <div className="stack">{children}</div>;
}

cloneElement 还会带来属性覆盖、事件合并、ref 传递和子元素类型判断等问题。组件库不应为了“少一层 DOM”而广泛依赖它。只有当组件明确拥有“增强单个子元素”的契约时,才适合使用。

2.4 as、render prop 和复合组件的取舍

as:适合少量语义变化

type TextProps<C extends React.ElementType = "p"> = {
  as?: C;
  children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>;

function Text<C extends React.ElementType = "p">({
  as,
  children,
  ...rest
}: TextProps<C>) {
  const Component = as ?? "p";
  return <Component {...rest}>{children}</Component>;
}

as="span" 可以改变最终元素,但它不能自动保证语义正确。把标题改成 div 后,视觉样式仍可能像标题,但文档结构已被破坏。因此应限制可选元素,或者提供语义明确的组件,如 HeadingTextLabel

render prop:适合把状态交给调用者渲染

<Disclosure>
  {({ open, toggle }) => (
    <>
      <button aria-expanded={open} onClick={toggle}>
        详细信息
      </button>
      {open && <div>内容</div>}
    </>
  )}
</Disclosure>

它适合高度定制的展示,但 JSX 嵌套更深,且调用者必须理解状态协议。

复合组件:适合一组固定协作关系

<Tabs value={value} onValueChange={setValue}>
  <Tabs.List aria-label="账户区域">
    <Tabs.Trigger value="profile">资料</Tabs.Trigger>
    <Tabs.Trigger value="security">安全</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="profile">...</Tabs.Panel>
  <Tabs.Panel value="security">...</Tabs.Panel>
</Tabs>

复合组件通常通过 React Context 传递内部状态。Context 适合传递“属于同一个组件实例的隐式协议”,不适合承载整个应用的数据仓库。

2.5 Context 不是状态管理的替代品

一个简化的 Tabs 实现如下:

type TabsContextValue = {
  value: string;
  setValue: (value: string) => void;
};

const TabsContext = React.createContext<TabsContextValue | null>(null);

function useTabsContext() {
  const context = React.useContext(TabsContext);

  if (!context) {
    throw new Error("Tabs.Trigger must be used inside <Tabs>");
  }

  return context;
}

function Tabs({
  value,
  defaultValue,
  onValueChange,
  children,
}: {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  children: React.ReactNode;
}) {
  const [internalValue, setInternalValue] = React.useState(
    defaultValue ?? "",
  );

  const currentValue = value ?? internalValue;

  const setValue = (next: string) => {
    if (value === undefined) {
      setInternalValue(next);
    }
    onValueChange?.(next);
  };

  const context = React.useMemo(
    () => ({ value: currentValue, setValue }),
    [currentValue],
  );

  return (
    <TabsContext.Provider value={context}>
      {children}
    </TabsContext.Provider>
  );
}

这里有三个重要契约:

  1. 没有 value 时组件是非受控的,用 defaultValue 初始化;
  2. value 时组件是受控的,内部不应自行改变最终值;
  3. onValueChange 是通知调用者,而不是在受控模式下替调用者更新外部状态。

一个常见错误是同时修改内部状态和调用外部回调:

// 容易造成两个状态源
setInternalValue(next);
onValueChange?.(next);

在受控模式下,外部 value 可能仍未更新,内部状态和显示值会短暂或长期不一致。状态流应明确为:

用户操作
  ↓
组件计算 nextValue
  ↓
非受控:更新内部状态
受控:只调用 onValueChange
  ↓
受控父组件更新 value
  ↓
组件重新渲染

2.6 React 19 中的 ref 边界

React 19 支持把 ref 作为普通属性传给函数组件,组件库可以写成:

type InputProps = React.ComponentProps<"input">;

function Input({ ref, ...props }: InputProps) {
  return <input ref={ref} {...props} />;
}

如果组件库还需要兼容 React 18,应继续使用 forwardRef,因为 React 18 中普通函数组件不能直接接收 ref

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  function Input(props, ref) {
    return <input ref={ref} {...props} />;
  },
);

这不是“把 ref 当作普通业务属性”那么简单。ref 是 React 负责的特殊通道,错误地把它从 ...props 展开到 DOM,可能导致无效属性或丢失引用。组件库需要在 peerDependencies、类型声明和构建产物中明确 React 支持范围。


三、设计 Token:把视觉决策变成可计算的契约

3.1 Token 的定义

Design Token 是带有名称、值和语义的设计变量。它不是简单的 CSS 变量名,而是设计决策的稳定引用。

例如:

:root {
  --ds-color-blue-600: #2563eb;
  --ds-color-gray-900: #111827;
  --ds-space-4: 1rem;
  --ds-radius-md: 0.5rem;
}

这些是较接近原始值的基础 Token。组件更适合引用语义 Token:

:root {
  --ds-color-action-primary-bg: var(--ds-color-blue-600);
  --ds-color-action-primary-fg: #ffffff;
  --ds-color-surface-default: #ffffff;
  --ds-color-text-primary: var(--ds-color-gray-900);
}

两层之间的关系是:

原始值 Token
  ↓ 映射
语义 Token
  ↓ 使用
组件 Token 或组件样式

如果按钮直接使用 --ds-color-blue-600,产品切换品牌颜色时必须修改所有组件。如果按钮使用 --ds-color-action-primary-bg,品牌映射可以集中变化。

3.2 Token 的解析模型

可以把 Token 解析形式化为一个函数:

resolve(t,theme,mode,state)valueresolve(t, theme, mode, state) \rightarrow value

其中:

  • tt 是 Token 名称;
  • theme 是品牌或产品主题;
  • mode 是 light、dark 等显示模式;
  • state 是 hover、disabled、focus 等组件状态;
  • value 是最终 CSS 值。

例如:

resolve(action.primary.background, acme, dark, default)
= #3b82f6

完整的解析链可能是:

组件 Button
  → --ds-button-primary-bg
  → --ds-color-action-primary-bg
  → --ds-color-blue-500
  → #3b82f6

链条越长,抽象能力越强,但调试成本也越高。因此 Token 名称必须表达语义,且不应形成循环引用:

/* 错误:循环引用 */
:root {
  --a: var(--b);
  --b: var(--a);
}

浏览器在计算时会把无效自定义属性传播为无效值,最终样式可能回退或消失。Token 构建阶段应检查未定义引用、循环引用和类型不匹配。

3.3 不要把所有值都抽象成 Token

Token 的目标是统一有设计意义的决策,不是把每个 2px 都命名。

适合成为 Token 的值包括:

  • 品牌色和语义色;
  • 间距尺度;
  • 字体族、字号、行高;
  • 圆角、阴影;
  • 组件状态颜色;
  • 响应式断点;
  • 动画时长和缓动函数。

局部实现细节不一定需要进入公共 Token。例如组件内部用于对齐图标的 1px 修正,如果不构成跨组件约束,可以保留在组件样式中。

3.4 一个可运行的 CSS Token 主题示例

/* tokens.css */
:root {
  color-scheme: light;

  --ds-color-surface-default: #ffffff;
  --ds-color-surface-muted: #f3f4f6;
  --ds-color-text-primary: #111827;
  --ds-color-text-on-accent: #ffffff;
  --ds-color-accent: #2563eb;
  --ds-color-focus-ring: #1d4ed8;

  --ds-space-2: 0.5rem;
  --ds-space-3: 0.75rem;
  --ds-space-4: 1rem;

  --ds-radius-md: 0.5rem;
  --ds-control-height: 2.5rem;
}

[data-theme="dark"] {
  color-scheme: dark;

  --ds-color-surface-default: #111827;
  --ds-color-surface-muted: #1f2937;
  --ds-color-text-primary: #f9fafb;
  --ds-color-text-on-accent: #ffffff;
  --ds-color-accent: #60a5fa;
  --ds-color-focus-ring: #93c5fd;
}

组件使用语义 Token:

/* button.css */
.button {
  min-height: var(--ds-control-height);
  padding-inline: var(--ds-space-4);
  border: 0;
  border-radius: var(--ds-radius-md);
  background: var(--ds-color-accent);
  color: var(--ds-color-text-on-accent);
}

.button:focus-visible {
  outline: 3px solid var(--ds-color-focus-ring);
  outline-offset: 2px;
}

这里的关键不是 CSS 变量本身,而是组件不需要知道浅色和深色的具体颜色。主题只要保证同名语义 Token 存在,组件就能工作。

3.5 主题切换的时序和闪烁问题

主题通常由一个 DOM 属性表达:

<html data-theme="dark">

React 客户端可以切换它:

function ThemeToggle() {
  const [theme, setTheme] = React.useState<"light" | "dark">("light");

  React.useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  return (
    <button onClick={() => setTheme((current) =>
      current === "light" ? "dark" : "light"
    )}>
      当前主题:{theme}
    </button>
  );
}

但如果初始主题来自 localStorage,服务端渲染阶段没有 window,不能直接读取:

// 错误边界:服务端渲染时 window 不存在
const theme = localStorage.getItem("theme");

常见的服务端可用方案是:

  1. 服务端根据 Cookie 生成 data-theme
  2. 客户端初始化时读取同一来源;
  3. 用户切换后同时更新 DOM 和 Cookie;
  4. 服务端和客户端对首屏主题使用同一个值。

如果服务端输出 data-theme="light",客户端首轮却认为是 "dark",可能产生 hydration 不匹配或首屏闪烁。主题属性本身应尽量在 React hydration 之前确定,而不是等待 useEffect 执行后才设置。

3.6 Token 与可访问性

颜色 Token 不能只验证“看起来像设计稿”。文字与背景的对比度应满足项目采用的 WCAG 目标;焦点指示器也不能仅依赖颜色变化。

例如,下面的状态在视觉上可能变化很小:

.button:focus {
  color: var(--ds-color-accent);
}

如果背景不变,用户可能无法识别焦点。更明确的是:

.button:focus-visible {
  outline: 3px solid var(--ds-color-focus-ring);
  outline-offset: 2px;
}

Token 系统还应覆盖:

  • 禁用状态是否仍有足够辨识度;
  • 错误状态是否同时使用文字或图标,而非只使用红色;
  • 深色主题中的文本对比度;
  • prefers-reduced-motion 下的动画降级;
  • 键盘焦点与鼠标点击状态是否区分。

四、主题:Token 的运行时选择机制

4.1 主题不是“给组件传一个颜色对象”

以下方式会把主题变成每个组件都要处理的运行时参数:

<Button
  background={theme.colors.primary}
  textColor={theme.colors.onPrimary}
/>

它有几个问题:

  • 组件调用方需要理解 Token 结构;
  • 每个组件都要处理主题对象;
  • 大量内联样式可能削弱 CSS 层叠和缓存;
  • 服务端与客户端需要保持主题对象一致;
  • 主题变化可能导致整棵 React 子树重新计算样式。

对于静态或主要由 CSS 控制的主题,CSS 自定义属性通常更合适。React Context 可以负责“当前主题名称”或“切换动作”,CSS 负责最终样式。

type Theme = "light" | "dark";

const ThemeContext = React.createContext<{
  theme: Theme;
  setTheme: (theme: Theme) => void;
} | null>(null);

function ThemeProvider({
  theme,
  onThemeChange,
  children,
}: {
  theme: Theme;
  onThemeChange: (theme: Theme) => void;
  children: React.ReactNode;
}) {
  return (
    <ThemeContext.Provider
      value={{ theme, setTheme: onThemeChange }}
    >
      <div data-theme={theme}>{children}</div>
    </ThemeContext.Provider>
  );
}

如果主题属性放在额外的 <div> 上,Portal 内容可能不在该元素的后代树中,因而看不到主题变量。对话框、Popover 等通过 document.body 创建 Portal 时,主题属性通常应放在 htmlbody 上,或者明确把 Portal 容器放入主题根节点。

4.2 静态主题与运行时主题的取舍

静态主题在构建期生成 CSS:

[data-brand="a"] {
  --ds-color-accent: #2563eb;
}

[data-brand="b"] {
  --ds-color-accent: #9333ea;
}

优点是简单、可缓存、运行时开销小。缺点是所有主题规则可能同时进入页面。

运行时主题通过 JS 计算或注入 Token:

document.documentElement.style.setProperty(
  "--ds-color-accent",
  "#9333ea",
);

优点是可以动态配置,缺点是更容易出现首屏闪烁、服务端不一致和样式注入边界问题。

如果主题主要是颜色、间距和字体,优先使用 CSS 自定义属性;如果主题还决定组件结构或功能,则应把结构条件显式建模,而不是把大量逻辑藏在主题对象中。


五、组件状态、数据流和并发边界

5.1 纯渲染组件与交互组件

纯渲染组件接收输入并返回 UI,不自行产生副作用:

function Price({
  amount,
  currency,
}: {
  amount: number;
  currency: string;
}) {
  return (
    <span>
      {new Intl.NumberFormat("zh-CN", {
        style: "currency",
        currency,
      }).format(amount)}
    </span>
  );
}

相同输入应得到相同输出。不要在渲染阶段:

  • 修改全局变量;
  • 读取并修改 DOM;
  • 发起请求;
  • 调用 setState
  • 依赖当前时间或随机数却没有稳定策略。

交互组件可以有状态,但状态变化应通过事件、受控属性或 React 提供的状态机制发生。

5.2 受控和非受控必须从初始阶段保持一致

一个折叠组件可以支持两种模式:

type DisclosureProps = {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: React.ReactNode;
};

function Disclosure({
  open,
  defaultOpen = false,
  onOpenChange,
  children,
}: DisclosureProps) {
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);

  const isControlled = open !== undefined;
  const currentOpen = isControlled ? open : internalOpen;

  const setOpen = (next: boolean) => {
    if (!isControlled) {
      setInternalOpen(next);
    }
    onOpenChange?.(next);
  };

  return (
    <section data-open={currentOpen}>
      <button
        aria-expanded={currentOpen}
        onClick={() => setOpen(!currentOpen)}
      >
        切换
      </button>
      {currentOpen && children}
    </section>
  );
}

生产实现还应处理“组件从非受控切换到受控”的情况。因为此时状态源发生改变,开发环境应发出警告,或者在类型和文档中明确禁止这种切换。受控值也可能是 undefined,不能仅凭某一次渲染就模糊判断契约。

5.3 React 并发渲染下的设计要求

React 可以在渲染被中断、重新开始或丢弃时执行组件函数。组件库因此不能把“渲染函数执行一次”当成提交成功。

错误示例:

let registered = false;

function Component() {
  registered = true; // 渲染阶段修改模块变量
  return <div />;
}

如果这次渲染被中断,registered 已经被修改,但对应 DOM 并没有提交。副作用应放在合适的生命周期中:

function Component() {
  React.useEffect(() => {
    const unsubscribe = subscribe();
    return unsubscribe;
  }, []);

  return <div />;
}

useEffect 也不是“所有逻辑的后备位置”。如果逻辑只是根据 props 计算 UI,应直接在渲染中计算;如果需要在 DOM 提交后同步读取布局,才考虑 useLayoutEffect,并注意它不适合服务端渲染环境。

对于需要让外部存储与 React 保持一致的组件,应使用 useSyncExternalStore,而不是自行在 useEffect 中订阅,以降低并发渲染下读到撕裂数据的风险。


六、服务端、客户端、SSR 与 Portal

6.1 服务端组件和客户端组件的职责

React Server Components 是框架能力的一部分,具体文件边界由 Next.js 等框架约定。一般而言:

  • 服务端组件可以读取服务端数据、生成 HTML,并避免把交互代码发送到浏览器;
  • 客户端组件可以使用状态、事件处理器、浏览器 API、Portal 和大多数交互 Hook;
  • 组件库中的按钮外壳可以被服务端渲染,但带 onClick 的交互行为必须位于客户端边界内。

例如:

// Server component
export default async function Page() {
  const user = await loadUser();

  return (
    <main>
      <h1>{user.name}</h1>
      <ClientMenu userId={user.id} />
    </main>
  );
}
// ClientMenu.tsx
"use client";

import { useState } from "react";

export function ClientMenu({ userId }: { userId: string }) {
  const [open, setOpen] = useState(false);

  return (
    <button onClick={() => setOpen(!open)}>
      {open ? "关闭" : "打开"}菜单:{userId}
    </button>
  );
}

"use client" 是模块边界指令,不是让所有导入者都自动变成客户端代码的通用注释。把整个组件库入口标记为客户端,可能导致本可服务端渲染的静态组件及其依赖被发送到浏览器。因此应尽量把客户端指令放在真正需要状态和事件的叶子模块。

6.2 SSR 的一致性条件

SSR hydration 要求服务端输出和客户端首次渲染在结构及关键文本上保持一致。以下内容容易破坏一致性:

function Time() {
  return <span>{new Date().toLocaleString()}</span>;
}

服务端和客户端可能得到不同时间或不同本地化结果。改进方式是:

  • 服务端生成固定值并作为 props 传入;
  • 或先渲染稳定占位内容,客户端挂载后再更新;
  • 或在明确的客户端边界内渲染,并接受首屏变化。

不要把 suppressHydrationWarning 当成修复机制。它只抑制特定警告,不能解决 DOM 结构、事件绑定或状态不一致。

6.3 Portal 与对话框的故障路径

Portal 通过 createPortal 把子节点渲染到 DOM 的另一个位置,但 React 树中的父子关系仍保持不变。因此:

  • React Context 仍可从逻辑父树传递;
  • React 事件仍按 React 树传播,而不完全按 DOM 树传播;
  • CSS 继承和 DOM 祖先选择器则按实际 DOM 位置决定。

一个最小客户端 Portal:

"use client";

import { createPortal } from "react-dom";

function ModalPortal({ children }: { children: React.ReactNode }) {
  if (typeof document === "undefined") {
    return null;
  }

  return createPortal(children, document.body);
}

生产组件还要处理容器不存在、服务端首轮返回 null 后客户端插入内容、滚动锁定、焦点返回和层级管理。对话框关闭流程应至少是:

打开触发器获得焦点
  ↓
设置 open=true
  ↓
Portal 内容提交
  ↓
焦点移动到对话框内可操作元素
  ↓
用户按 Escape 或点击关闭
  ↓
调用 onOpenChange(false)
  ↓
解除滚动锁定
  ↓
焦点返回原触发器

如果触发器已经卸载,焦点返回必须判断 ref.current?.isConnected,不能无条件调用 .focus()


七、可访问性是组件 API 的一部分

可访问性不是组件渲染完成后的装饰层,而是 API 契约的一部分。组件如果需要标签、错误说明或状态描述,应让调用者能够提供这些信息,并由组件生成稳定关联。

7.1 用 useId 建立稳定关联

React 的 useId 用于生成与服务端渲染兼容的稳定 ID:

function Field({
  label,
  error,
  children,
}: {
  label: string;
  error?: string;
  children: React.ReactElement;
}) {
  const id = React.useId();
  const errorId = `${id}-error`;

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      {React.cloneElement(children, {
        id,
        "aria-invalid": error ? true : undefined,
        "aria-describedby": error ? errorId : undefined,
      })}
      {error && (
        <p id={errorId} role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

这段示例为了说明关联关系使用了 cloneElement。更稳健的组件库实现可能直接渲染 <input>,或通过 Context 让 Field.Input 读取字段上下文,从而避免任意子元素被注入属性。

useId 不能用于列表的业务 key。列表 key 应来自数据身份:

items.map((item) => <Row key={item.id} item={item} />)

7.2 语义元素优先于 ARIA

如果需求是按钮,优先使用:

<button type="button">保存</button>

而不是:

<div role="button" tabIndex={0}>保存</div>

后者还要自行实现 Enter、Space、禁用、焦点和事件行为,遗漏任意一项都会造成键盘访问问题。ARIA 主要用于表达原生 HTML 无法表达的关系和状态,不应替代原生元素。

7.3 动态内容需要说明“变化给谁听”

错误信息通常需要与输入关联:

<input aria-describedby="email-error" aria-invalid="true" />
<p id="email-error">邮箱格式不正确</p>

异步保存结果则可能使用:

<div role="status" aria-live="polite">
  已保存
</div>

role="alert" 通常表示更紧急的播报。组件库应避免所有动态内容都使用 assertivealert,否则屏幕阅读器会被大量打断。


八、组件 API 的类型契约与 DOM 属性转发

8.1 只允许明确的属性进入 DOM

下面这种写法有风险:

function Button(props: Record<string, unknown>) {
  return <button {...props} />;
}

loadingtonesize 等组件属性会被尝试传给 DOM,产生无效属性或控制台警告。应拆分组件属性和原生属性:

type ButtonProps = {
  tone?: "primary" | "secondary" | "danger";
  loading?: boolean;
} & Omit<React.ComponentProps<"button">, "color">;

function Button({
  tone = "primary",
  loading = false,
  disabled,
  children,
  ...buttonProps
}: ButtonProps) {
  return (
    <button
      {...buttonProps}
      className={`button button--${tone}`}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
    >
      {loading ? "处理中…" : children}
    </button>
  );
}

这里 disabledloading 的关系必须有定义:加载中是否禁止再次提交?如果是,就应同时改变交互和可访问状态,而不只是替换文字。

8.2 className 和样式扩展的边界

允许传入 className 可以支持局部布局,但如果调用者依赖覆盖内部关键样式,组件库便失去视觉治理能力。可以区分:

  • 组件自身状态:tone="danger"size="sm"
  • 布局扩展:classNamestyle
  • 设计系统缺失的正式能力:新增 Token 或变体,而不是要求产品用高优先级 CSS 覆盖。

如果公开 style,应明确它是最后一层内联样式,可能覆盖主题 Token。对于需要动态尺寸的组件,可以使用受控 CSS 自定义属性:

<div
  className="progress"
  style={{ "--progress-value": `${value}%` } as React.CSSProperties}
/>

对应 CSS:

.progress__bar {
  width: var(--progress-value);
}

九、版本治理:管理的不只是 npm 版本号

9.1 语义化版本的前提

语义化版本通常表示:

MAJOR.MINOR.PATCH

在组件库中可以这样理解:

  • PATCH:修复不改变预期 API 的错误;
  • MINOR:向后兼容地增加能力;
  • MAJOR:删除 API、改变行为或要求调用方迁移。

但“视觉变化一定是 PATCH”并不成立。若产品依赖截图测试、品牌 Token 或布局尺寸,改变默认间距、颜色、字体或焦点样式也可能是破坏性变更,即使 TypeScript 没有报错。

反过来,新增可选属性通常是小版本变更:

type ButtonProps = {
  tone?: "primary" | "secondary" | "danger";
};

删除一个变体、改变默认 type、改变 onOpenChange 的触发时机,则需要迁移说明。

9.2 公共 API 包含哪些内容

公共 API 不只有导出的函数名,还包括:

  • TypeScript 类型和泛型;
  • DOM 属性是否透传;
  • 默认值;
  • CSS 类名和 CSS 变量;
  • 事件触发顺序;
  • 键盘行为;
  • focus 行为;
  • SSR 输出结构;
  • Token 名称;
  • 主题可覆盖的语义;
  • 包的 exports 路径;
  • 对等依赖版本范围。

例如,把:

<Button>保存</Button>

的默认 type"button" 改为 "submit",可能导致表单意外提交。这是行为上的破坏性变更,即使组件类型完全不变。

9.3 包入口和 peer dependencies

组件库通常应将 React 放入 peerDependencies,避免应用同时安装多个 React 实例:

{
  "name": "@acme/ui",
  "version": "3.2.0",
  "peerDependencies": {
    "react": "^18.3.0 || ^19.0.0",
    "react-dom": "^18.3.0 || ^19.0.0"
  },
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./button": {
      "types": "./dist/button.d.ts",
      "import": "./dist/button.js"
    },
    "./styles.css": "./dist/styles.css"
  }
}

exports 可以防止调用者依赖未公开的内部路径:

// 稳定入口
import { Button } from "@acme/ui";

// 如果没有在 exports 中声明,就不应被视为公共 API
import { internalMergeClassNames } from "@acme/ui/dist/internal";

如果构建工具把 React 打包进库,可能出现 Hooks 使用不同 React 实例的问题;如果只发布 ESM 而消费方仍使用不兼容的构建工具,也可能产生解析失败。因此发布前要用实际支持的框架和 bundler 验证。

9.4 变更文件、迁移和自动化

一个可靠的变更流程至少包含:

代码或 Token 变更
  ↓
单元测试、类型检查、无障碍测试、视觉回归
  ↓
记录变更级别和迁移说明
  ↓
生成版本与 changelog
  ↓
发布候选版本
  ↓
在示例应用和真实消费者中验证
  ↓
正式发布

对于可机械替换的变更,可以提供 codemod。例如:

// 旧
<Button variant="danger">删除</Button>

// 新
<Button tone="danger">删除</Button>

如果只是修改文档而没有自动迁移,工程师仍需搜索仓库并人工确认;如果是大规模使用的库,自动化迁移通常比长篇说明更可靠。

9.5 视觉变更也需要版本治理

Token 的变化可能影响所有组件:

- --ds-color-accent: #2563eb;
+ --ds-color-accent: #1d4ed8;

这不仅是颜色文件的内部变更,因为它改变了按钮、链接、焦点环和选中状态。治理时需要:

  1. 生成 Token 差异;
  2. 判断哪些语义 Token 受影响;
  3. 运行组件截图和对比度检查;
  4. 检查暗色主题和高对比度场景;
  5. 在 changelog 中说明视觉影响;
  6. 必要时提供旧 Token 的兼容映射。

十、一个小型端到端组件:Button 的状态、Token 和类型

下面的示例把前面的几个原则组合起来:

import * as React from "react";

type ButtonProps = {
  tone?: "primary" | "secondary" | "danger";
  loading?: boolean;
  children: React.ReactNode;
} & Omit<
  React.ComponentPropsWithoutRef<"button">,
  "children" | "color"
>;

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  function Button(
    {
      tone = "primary",
      loading = false,
      disabled = false,
      className,
      children,
      ...rest
    },
    ref,
  ) {
    const isDisabled = disabled || loading;

    return (
      <button
        {...rest}
        ref={ref}
        type={rest.type ?? "button"}
        className={[
          "ds-button",
          `ds-button--${tone}`,
          className,
        ]
          .filter(Boolean)
          .join(" ")}
        disabled={isDisabled}
        aria-busy={loading || undefined}
      >
        {loading ? "处理中…" : children}
      </button>
    );
  },
);

对应样式:

.ds-button {
  min-height: var(--ds-control-height);
  padding-inline: var(--ds-space-4);
  border: 1px solid transparent;
  border-radius: var(--ds-radius-md);
  cursor: pointer;
}

.ds-button--primary {
  background: var(--ds-color-accent);
  color: var(--ds-color-text-on-accent);
}

.ds-button--secondary {
  background: var(--ds-color-surface-muted);
  color: var(--ds-color-text-primary);
}

.ds-button--danger {
  background: var(--ds-color-danger);
  color: var(--ds-color-text-on-danger);
}

.ds-button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

.ds-button:focus-visible {
  outline: 3px solid var(--ds-color-focus-ring);
  outline-offset: 2px;
}

使用时:

function SaveForm() {
  const [saving, setSaving] = React.useState(false);

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSaving(true);

    try {
      await saveSettings();
    } catch (error) {
      console.error("保存失败", error);
    } finally {
      setSaving(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <Button type="submit" loading={saving}>
        保存
      </Button>
    </form>
  );
}

几个细节很重要:

  • 默认 type="button" 防止普通按钮意外提交表单;
  • 显式传入 type="submit" 时仍可提交;
  • loading 同时影响文本、禁用状态和 aria-busy
  • 样式引用语义 Token,而不是直接引用某个蓝色色阶;
  • 异步失败由业务层决定如何展示错误,按钮本身不应吞掉异常;
  • finally 确保成功和失败路径都恢复加载状态。

如果项目只支持 React 19,可以采用普通 ref prop;如果需要 React 18,则 forwardRef 是兼容边界。组件库应为这一选择提供明确的 peer dependency 和类型策略,而不是让消费者通过错误信息猜测。


十一、故障诊断:从表现反推契约断裂点

11.1 主题生效但 Portal 不生效

表现:页面按钮变成深色,但 Dialog 或 Tooltip 仍是浅色。

原因路径

主题属性位于 #app
  ↓
Portal 节点位于 document.body
  ↓
Portal DOM 不在 #app 后代
  ↓
无法继承 #app 下的 CSS 自定义属性

诊断

const portal = document.querySelector("[data-dialog-content]");
getComputedStyle(portal).getPropertyValue("--ds-color-surface-default");

如果结果为空或使用了错误值,检查主题属性所在元素和 Portal 容器位置。

恢复方式

  • 把主题属性提升到 htmlbody
  • 或让 Portal 容器位于主题根节点;
  • 或显式复制主题属性,但这会增加同步成本。

11.2 受控组件点击后不变化

表现

<Tabs value={activeTab} onValueChange={setActiveTab} />

点击 Tab 后没有切换。

原因通常不是组件没有触发事件,而是父组件没有更新 activeTab

<Tabs
  value={activeTab}
  onValueChange={(next) => {
    console.log(next);
    // 忘记 setActiveTab(next)
  }}
/>

诊断顺序

  1. 确认 onValueChange 是否触发;
  2. 确认回调参数是否为合法值;
  3. 确认父组件状态是否更新;
  4. 确认新的 value 是否重新传回;
  5. 确认组件是否错误地保留了内部状态。

11.3 SSR 首屏闪烁或 hydration 警告

表现

  • 首屏先显示浅色后变深色;
  • 控制台出现 hydration mismatch;
  • 日期、随机 ID 或条件内容服务端和客户端不同。

诊断

服务端初始输入
  ≠
客户端首次渲染输入

检查:

  • 主题是否由 Cookie 或服务端数据统一决定;
  • 是否在渲染期间读取了 windowdocumentlocalStorage
  • 是否使用了当前时间、随机数或不稳定本地化;
  • 是否在服务端输出了一个客户端不会输出的节点。

不要通过在所有组件上加 suppressHydrationWarning 来掩盖问题,否则真实的结构错误会被隐藏。

11.4 升级后出现重复 React 或 Hooks 错误

表现

Invalid hook call

常见原因包括:

  • 应用和组件库各自包含不同 React 实例;
  • 链接开发环境的包解析到不同路径;
  • 组件库错误地把 React 打进 bundle;
  • reactreact-dom 版本不匹配。

诊断

npm ls react react-dom

或使用包管理器对应的依赖树命令,确认最终是否有多个 React 实例及其版本。随后检查组件库产物和打包配置是否将 reactreact-dom 标记为 external,并检查 peerDependencies 范围。


十二、测试设计系统,而不是只测试快照

组件库测试至少应覆盖四类契约。

12.1 行为测试

验证状态和事件:

it("controlled dialog calls onOpenChange", async () => {
  const onOpenChange = vi.fn();

  render(
    <Dialog open={false} onOpenChange={onOpenChange}>
      <Dialog.Trigger>打开</Dialog.Trigger>
      <Dialog.Content>内容</Dialog.Content>
    </Dialog>,
  );

  await user.click(screen.getByRole("button", { name: "打开" }));

  expect(onOpenChange).toHaveBeenCalledWith(true);
});

这里测试的是受控协议,而不是内部实现细节。

12.2 可访问性测试

验证:

  • 角色是否正确;
  • 名称是否存在;
  • aria-expandedaria-controlsaria-describedby 是否一致;
  • 键盘是否能完成操作;
  • 对话框焦点是否进入并返回;
  • 动态内容是否使用适当的 live region。

自动化工具可以发现部分问题,但无法替代真实键盘和屏幕阅读器验证。

12.3 SSR 和客户端边界测试

至少应构建一个服务端渲染示例,验证:

  • 服务端组件可以导入静态组件;
  • 客户端组件边界没有意外扩大;
  • 首次 hydration 没有警告;
  • Portal 只在浏览器可用时访问 document
  • 主题 Token 在服务端首屏已存在。

12.4 Token 和视觉回归测试

视觉测试不应只截图浅色默认状态,还应覆盖:

light / dark
default / hover / focus-visible / disabled / error
中文长文本 / 空内容 / 极端尺寸
键盘操作后的焦点
Portal 内部内容
prefers-reduced-motion

截图差异不是自动等于缺陷。需要判断差异来自有意的 Token 变更、浏览器渲染差异,还是布局回归,并把结论写入变更记录。


十三、设计系统的演进原则

一个可持续的组件库通常遵循以下因果关系:

稳定语义
  → 稳定 Token
  → 稳定组件行为
  → 可预测的组合 API
  → 可执行的迁移
  → 可控的版本升级

如果先暴露大量内部实现,例如要求产品依赖内部 CSS 类名、DOM 层级或未声明的导出路径,后续即使只想重构,也会变成破坏性变更。

反之,若组件 API 只暴露稳定的语义和状态:

<Button tone="danger" loading={saving}>
  删除
</Button>

内部可以从一层 DOM 改成两层 DOM,也可以更换 Token 映射或 CSS 构建方式,只要仍然满足:

  • 类型契约不变;
  • 默认行为不变;
  • 键盘和焦点行为不变;
  • 可访问性关系不变;
  • 已公开的主题变量仍然有效,或提供迁移;
  • SSR 和客户端边界仍然成立。

这就是组合 API、Token、主题和版本治理之间的共同目标:把变化放在明确的层中,把稳定性留在调用者真正依赖的契约上。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。