14 · 组件 API 设计与设计系统
本篇解决什么问题: 写组件不难,写出"别人愿意用、半年后还敢改"的组件 API 才难。 本篇讲受控/非受控、复合组件、插槽策略,以及设计系统的分层与样式方案选型。
📌 核心标准:好的 API 应该让"正确的用法容易,错误的用法困难"。
开篇速览
| 主题 | 关键决策 |
|---|---|
| 受控 vs 非受控 | 谁持有状态:父组件还是组件自己 |
| 复合组件 | 用 <Select><Select.Option/></Select> 还是 options 数组 |
| 内容分发 | children / render props / 插槽 / 作用域插槽 |
| 样式方案 | CSS Modules / CSS-in-JS / 原子化(Tailwind)怎么选 |
| 设计系统 | Token → 基础组件 → 业务组件 的分层 |
一、受控与非受控:组件设计的第一个分岔口
| 受控(Controlled) | 非受控(Uncontrolled) | |
|---|---|---|
| 状态持有者 | 父组件(通过 props 传入 + 回调更新) | 组件内部(通过 ref 读取) |
| React 写法 | <Input value={v} onChange={setV}/> | <Input defaultValue="x" ref={r}/> |
| Vue 写法 | v-model | v-model 的 .lazy 或独立 ref |
| 优点 | 单一数据源,值可被外部校验/格式化/联动 | 父组件代码少,性能好(不每次重渲染) |
| 缺点 | 每次输入都重渲染 | 外部拿不到实时值,难以联动校验 |
选型判断:
| 场景 | 选 |
|---|---|
| 表单(要校验、联动、提交) | 受控 |
| 文件上传、第三方编辑器(值很大/由库管) | 非受控 |
| 不确定 | 同时支持(见下) |
最佳实践:同时支持两种模式
function Input({ value, defaultValue, onChange }: Props) {
const isControlled = value !== undefined
const [inner, setInner] = useState(defaultValue ?? '')
const current = isControlled ? value : inner
const setCurrent = (v: string) => {
if (!isControlled) setInner(v)
onChange?.(v) // ✅ 无论受控与否都通知父级
}
return <input value={current} onChange={e => setCurrent(e.target.value)} />
}💡 判据:
value !== undefined即受控。这是 React 生态的通用约定(也是 MUI/Antd 的做法)。
二、复合组件 vs 配置式 API
2.1 两种风格
// A. 配置式(data-driven)
<Select options={[{ label: '北京', value: 'bj' }, ...]} />
// B. 复合组件(Compound Components)
<Select value={v} onChange={setV}>
<Select.Option value="bj">北京</Select.Option>
<Select.Option value="sh">上海</Select.Option>
</Select>| 维度 | 配置式 | 复合组件 |
|---|---|---|
| 简单场景 | ✅ 更简洁 | 略啰嗦 |
| 每个选项要自定义渲染 | ❌ 要加 renderOption 之类的口子 | ✅ 天然支持,直接写 JSX |
| 选项需要分组/穿插其他节点 | ❌ 难 | ✅ 容易 |
| 类型推导 | 好 | 好 |
| 典型用例 | 简单下拉 | Tabs/Menu/Table.Column/Form.Item |
选型判断:
选项结构固定、数量多、来自接口 → 配置式 选项需要自定义内容、布局灵活、JSX 化 → 复合组件
复合组件的实现要点:用 Context 在父子间隐式通信。
const SelectCtx = createContext<{
value: string; onSelect: (v: string) => void
} | null>(null)
function Select({ value, onChange, children }) {
return <SelectCtx.Provider value={{ value, onSelect: onChange }}>{children}</SelectCtx.Provider>
}
Select.Option = function Option({ value: v, children }) {
const ctx = useContext(SelectCtx)! // 从 Context 拿选中态,无需层层传 props
return <div onClick={() => ctx.onSelect(v)} data-active={ctx.value === v}>{children}</div>
}⚠️ 注意 Context 的重渲染问题(见 A07): 复合组件里 Context 值变化会让所有子项重渲染,选项极多时要配合 memo 或改用 atom 方案。
三、内容分发:children / Render Props / 插槽
| 方案 | 适用 | 示例 |
|---|---|---|
children(默认插槽) | 简单内容 | <Card>{content}</Card> |
多具名插槽(Vue slot name / React props 传 JSX) | 多个区域 | <Card header={...} footer={...}/> |
| Render Props(React) | 需要把内部状态暴露给调用方 | <Mouse>{({x,y}) => <p>{x},{y}</p>}</Mouse> |
作用域插槽(Vue v-slot 带参数) | 同上 | <List v-slot="{ item }"> |
Render Props / 作用域插槽解决的核心问题:
组件有状态(如排序、选中项、坐标),但渲染什么由调用方决定。
// Render Props 模式
<DataTable data={rows}>
{({ sorted, sortBy }) => sorted.map(r => <Row key={r.id} data={r} highlight={sortBy}/>)}
</DataTable>💡 React Hooks 普及后,自定义 Hook 在多数场景可以替代 Render Props(更干净,不产生嵌套地狱), 但需要把状态同时暴露给多个区域渲染时,Render Props / 作用域插槽仍不可替代。
四、样式方案选型
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| CSS Modules | 构建时给类名加哈希 | 零运行时、作用域隔离、性能好 | 动态样式要靠类名拼接 |
| CSS-in-JS(styled-components / emotion) | 运行时生成 <style> | 动态样式极强、主题方便、与 props 天然结合 | 运行时开销、SSR 配置复杂、bundle 变大 |
| 原子化(Tailwind / UnoCSS) | 预生成原子类 | 无命名负担、产物小、一致性好 | HTML 类名长、复杂样式可读性差 |
| Sass/Less + BEM | 约定式命名 | 老项目友好、生态成熟 | 靠人遵守约定,无强制隔离 |
选型判断:
| 场景 | 推荐 |
|---|---|
| 新项目 + 有设计系统 | 原子化(Tailwind/UnoCSS) + 少量 CSS Modules |
| 组件库(要被别人用、不强依赖构建) | CSS Modules 或 CSS-in-JS |
| 强动态主题 / 运行时换肤 | CSS-in-JS 或 CSS 变量 + 原子化 |
| 老项目维护 | 沿用原有(Sass + BEM),不要混多套 |
🎯 现代最佳实践:CSS 变量(Custom Properties)做主题 Tokens + 原子化类做布局, 换肤只需改
:root变量,无需运行时样式引擎。 详见 A21 · 构建工具原理 中 Tree Shaking 与产物体积的关系。
五、设计系统:三层结构
③ 业务组件层 <UserSelector/> <OrderTable/> ← 业务耦合,不通用
② 基础组件层 <Button/> <Input/> <Modal/> ← 通用、无业务语义
① 设计 Token --color-primary / --space-4 ← 唯一事实来源Layer ① 设计 Token(最重要)
:root {
--color-primary: #1677ff;
--color-danger: #ff4d4f;
--space-2: 8px; --space-4: 16px;
--radius-md: 6px; --font-size-base: 14px;
}原则:
| 原则 | 说明 |
|---|---|
| Token 是唯一事实来源 | 组件里不写死颜色/间距,一律 var(--xxx) |
| 命名要语义化 | --color-primary 而非 --blue-6(换主题时才知道改哪个) |
| 跨端复用 | Token 用 JSON/TS 存,可同时喂给 Web、Flutter(见 B 域) |
Layer ② 基础组件的 API 一致性原则:
| 原则 | 例子 |
|---|---|
| 统一的受控模型 | 所有表单类组件都支持 value + onChange |
| 统一的尺寸/状态命名 | `size="sm |
| 透传原生属性 | <Button {...rest}/> 要能把 disabled/aria-* 传下去 |
| 支持 ref 转发 | forwardRef,否则外面没法拿 DOM 做定位/聚焦 |
六、可访问性(A11y)——不该是事后补的
| 要点 | 做法 |
|---|---|
| 语义化标签 | 用 <button> 而不是 <div onClick> |
| 键盘可达 | Tab 能到、Enter/Space 能触发、Esc 能关闭弹窗 |
| 焦点管理 | 弹窗打开时焦点进入,关闭后归还给触发元素 |
| aria 属性 | aria-label、aria-expanded、role="dialog" |
| 对比度 | 文本对比度 ≥ 4.5:1 |
💡 用
div做按钮是最常见的 API 设计缺陷—— 它天然丢掉了键盘操作、聚焦、语义,事后要用tabIndex+role+ 键盘事件一点点补回来。
自检清单
- [ ] 能说清受控/非受控的取舍,并能写出"同时支持两种模式"的组件
- [ ] 能判断何时用配置式 API、何时用复合组件
- [ ] 能说出 Render Props / 作用域插槽解决的核心问题,以及自定义 Hook 何时可替代
- [ ] 能按场景选型样式方案,并知道现代做法(CSS 变量 + 原子化)
- [ ] 能说出设计系统三层结构与 Token 的三条原则
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "受控组件一定更好" | 大表单/第三方编辑器用非受控更合适 |
| "复合组件只是为了好看" | 它解决选项需要自定义渲染的问题 |
| "CSS-in-JS 是唯一现代方案" | 它有运行时开销与 SSR 复杂度;原子化 + CSS 变量往往更优 |
"组件里直接写 #1677ff" | 违反 Token 单一来源,换肤时改到崩溃 |
"用 div + onClick 做按钮" | 丢失键盘与语义,A11y 直接不合格 |
动手练习
- 实现一个
Input,同时支持受控与非受控,并支持forwardRef。 - 把项目里一个"传 options 数组"的组件重构成复合组件,体会它在自定义渲染上的优势。
- 用 CSS 变量搭一组 Token(颜色/间距/圆角),实现明/暗主题切换,且组件里不出现任何硬编码颜色。
相关文档
- 状态该放哪(受控的底层)→ A07 · 状态管理演进与选型
- 布局底层规则 → A06 · CSS 布局体系
- 样式方案与构建产物 → A21 · 构建工具原理
- 跨端 Token 复用 → Flutter 11 · 组件与主题