Skip to content

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-modelv-model 的 .lazy 或独立 ref
优点单一数据源,值可被外部校验/格式化/联动父组件代码少,性能好(不每次重渲染)
缺点每次输入都重渲染外部拿不到实时值,难以联动校验

选型判断:

场景选
表单(要校验、联动、提交)受控
文件上传、第三方编辑器(值很大/由库管)非受控
不确定同时支持(见下)

最佳实践:同时支持两种模式

tsx
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 两种风格 ​

jsx
// 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 在父子间隐式通信。

tsx
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 / 作用域插槽解决的核心问题:

组件有状态(如排序、选中项、坐标),但渲染什么由调用方决定。

tsx
// 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(最重要)

css
: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 直接不合格

动手练习 ​

  1. 实现一个 Input,同时支持受控与非受控,并支持 forwardRef。
  2. 把项目里一个"传 options 数组"的组件重构成复合组件,体会它在自定义渲染上的优势。
  3. 用 CSS 变量搭一组 Token(颜色/间距/圆角),实现明/暗主题切换,且组件里不出现任何硬编码颜色。

相关文档 ​