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。
如果把设计规则直接散落在产品代码里,组件库只能提供外观,无法提供一致的语义。如果把所有规则都硬编码在组件内部,又会使组件无法适应不同产品。因此需要分层:
- 规则层:规定哪些状态、语义和交互必须存在。
- Token 层:把颜色、间距、圆角、字体等设计决策编码为可引用的变量。
- 组件层:把规则和 Token 组合成可访问、可复用的交互单元。
- 产品层:通过组合和少量扩展完成业务场景。
组件库不应试图替产品决定所有布局和业务流程。它应当保证组件边界内的行为稳定,例如按钮的键盘操作、对话框的焦点管理、表单字段的错误关联。
二、组合 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;Title和Description如何生成并关联 ID;- Escape 键如何关闭;
- 焦点打开时移动到哪里,关闭时返回哪里;
open和onOpenChange如何形成受控状态流。
组合 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 后,视觉样式仍可能像标题,但文档结构已被破坏。因此应限制可选元素,或者提供语义明确的组件,如 Heading、Text、Label。
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>
);
}
这里有三个重要契约:
- 没有
value时组件是非受控的,用defaultValue初始化; - 有
value时组件是受控的,内部不应自行改变最终值; 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 解析形式化为一个函数:
其中:
- 是 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");
常见的服务端可用方案是:
- 服务端根据 Cookie 生成
data-theme; - 客户端初始化时读取同一来源;
- 用户切换后同时更新 DOM 和 Cookie;
- 服务端和客户端对首屏主题使用同一个值。
如果服务端输出 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 时,主题属性通常应放在 html 或 body 上,或者明确把 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" 通常表示更紧急的播报。组件库应避免所有动态内容都使用 assertive 或 alert,否则屏幕阅读器会被大量打断。
八、组件 API 的类型契约与 DOM 属性转发
8.1 只允许明确的属性进入 DOM
下面这种写法有风险:
function Button(props: Record<string, unknown>) {
return <button {...props} />;
}
loading、tone、size 等组件属性会被尝试传给 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>
);
}
这里 disabled 和 loading 的关系必须有定义:加载中是否禁止再次提交?如果是,就应同时改变交互和可访问状态,而不只是替换文字。
8.2 className 和样式扩展的边界
允许传入 className 可以支持局部布局,但如果调用者依赖覆盖内部关键样式,组件库便失去视觉治理能力。可以区分:
- 组件自身状态:
tone="danger"、size="sm"; - 布局扩展:
className或style; - 设计系统缺失的正式能力:新增 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;
这不仅是颜色文件的内部变更,因为它改变了按钮、链接、焦点环和选中状态。治理时需要:
- 生成 Token 差异;
- 判断哪些语义 Token 受影响;
- 运行组件截图和对比度检查;
- 检查暗色主题和高对比度场景;
- 在 changelog 中说明视觉影响;
- 必要时提供旧 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 容器位置。
恢复方式:
- 把主题属性提升到
html或body; - 或让 Portal 容器位于主题根节点;
- 或显式复制主题属性,但这会增加同步成本。
11.2 受控组件点击后不变化
表现:
<Tabs value={activeTab} onValueChange={setActiveTab} />
点击 Tab 后没有切换。
原因通常不是组件没有触发事件,而是父组件没有更新 activeTab:
<Tabs
value={activeTab}
onValueChange={(next) => {
console.log(next);
// 忘记 setActiveTab(next)
}}
/>
诊断顺序:
- 确认
onValueChange是否触发; - 确认回调参数是否为合法值;
- 确认父组件状态是否更新;
- 确认新的
value是否重新传回; - 确认组件是否错误地保留了内部状态。
11.3 SSR 首屏闪烁或 hydration 警告
表现:
- 首屏先显示浅色后变深色;
- 控制台出现 hydration mismatch;
- 日期、随机 ID 或条件内容服务端和客户端不同。
诊断:
服务端初始输入
≠
客户端首次渲染输入
检查:
- 主题是否由 Cookie 或服务端数据统一决定;
- 是否在渲染期间读取了
window、document、localStorage; - 是否使用了当前时间、随机数或不稳定本地化;
- 是否在服务端输出了一个客户端不会输出的节点。
不要通过在所有组件上加 suppressHydrationWarning 来掩盖问题,否则真实的结构错误会被隐藏。
11.4 升级后出现重复 React 或 Hooks 错误
表现:
Invalid hook call
常见原因包括:
- 应用和组件库各自包含不同 React 实例;
- 链接开发环境的包解析到不同路径;
- 组件库错误地把 React 打进 bundle;
react和react-dom版本不匹配。
诊断:
npm ls react react-dom
或使用包管理器对应的依赖树命令,确认最终是否有多个 React 实例及其版本。随后检查组件库产物和打包配置是否将 react、react-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-expanded、aria-controls、aria-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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 错误边界与可观测性:异常、日志、Web Vitals 和发布诊断
- 下一篇:React Server Components:服务端边界、序列化、缓存和客户端交互
- 延伸:React 组件与 Props:纯渲染、组合、Children 和 API 契约
- 延伸:React 可访问性:语义、键盘、焦点、ARIA、Portal 和动态内容
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论