优秀的组件并非“把逻辑塞进一个文件”那么简单,而是围绕清晰的职责、稳定的 API、良好的可组合性与可访问性构建出来的可复用最小单元。本文给出一套从设计目标到工程实践的准则,并附上可直接复用的示例。
一、设计目标与基本原则
- 单一职责(Single Responsibility):一个组件只解决一类问题;超出边界的能力通过组合扩展。
- 显式 API:输入输出清晰,避免“魔法”与隐式副作用;缺省值合理且可覆盖。
- 可组合性优先:通过组合(children、slots、render props)扩展能力,而非内置过多开关。
- 受控/非受控双模:同时支持受控(value/onChange)与非受控(defaultValue)用法。
- 可访问性(A11y)内建:ARIA 语义、键盘可达、焦点管理默认可用。
- 样式可定制:不强绑技术栈,支持 className/style/自定义变量/主题化;避免样式泄漏。
- 稳定性与可测试:API 语义稳定,具备单测/快照/可视回归基础。
二、API 设计:Props、事件与透传
- 命名规范:
- 值/回调:
value
+ onChange
、open
+ onOpenChange
。
- 状态布尔:使用肯定式
disabled
、loading
。
- 必填与默认值:最小必填集;其他提供合理
defaultXxx
。
- 透传策略:使用
...rest
透传到根元素;className
/style
/id
/data-*
允许覆盖。
- 可扩展位点:children、
renderXxx
、slots
(如 header
、footer
)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| import React, { useState, forwardRef, useImperativeHandle, useRef } from 'react';
type TextInputProps = { value?: string; defaultValue?: string; onChange?: (value: string) => void; placeholder?: string; disabled?: boolean; className?: string; style?: React.CSSProperties; } & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange'>;
export type TextInputRef = { focus: () => void };
export const TextInput = forwardRef<TextInputRef, TextInputProps>(function TextInput( { value, defaultValue = '', onChange, className, style, ...rest }, ref ) { const [inner, setInner] = useState(defaultValue); const inputRef = useRef<HTMLInputElement>(null); const isControlled = value !== undefined; const displayed = isControlled ? value! : inner;
useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), }));
return ( <input ref={inputRef} value={displayed} onChange={(e) => { if (!isControlled) setInner(e.target.value); onChange?.(e.target.value); }} className={className} style={style} {...rest} /> ); });
|
三、可组合性:children / slots / render props
- children:最通用的组合方式,适合简单布局插槽。
- slots:命名插槽,适合多插入点结构化布局(如
header
、footer
)。
- render props:向下提供状态与动作,子节点以函数接收,适合“状态容器 + 视图解耦”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| type ModalProps = { open: boolean; onOpenChange: (next: boolean) => void; title?: React.ReactNode; footer?: React.ReactNode; children?: React.ReactNode; };
export function Modal({ open, onOpenChange, title, footer, children }: ModalProps) { if (!open) return null; return ( <div role="dialog" aria-modal="true" className="modal"> <div className="modal-header"> <div className="modal-title">{title}</div> <button aria-label="Close" onClick={() => onOpenChange(false)}>×</button> </div> <div className="modal-body">{children}</div> {footer && <div className="modal-footer">{footer}</div>} </div> ); }
|
1 2 3 4 5 6 7 8
| <!-- Vue 命名插槽示例 --> <template> <div class="card"> <header><slot name="header" /></header> <section><slot /></section> <footer><slot name="footer" /></footer> </div> </template>
|
四、可访问性(A11y):语义与键盘优先
- 语义与角色:使用符合语义的元素或补充
role
。
- ARIA 状态:
aria-expanded
、aria-selected
、aria-disabled
等与真实状态同步。
- 键盘可达:
Tab
巡航、Enter/Space
触发、方向键导航;焦点可见(focus ring)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( <button role="switch" aria-checked={checked} onClick={() => onChange(!checked)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onChange(!checked); } }} className={`switch ${checked ? 'on' : 'off'}`} > {checked ? 'On' : 'Off'} </button> ); }
|
五、样式策略:隔离、主题与约束
- 隔离:优先模块化(CSS Modules)、BEM 或 CSS-in-JS,避免全局污染。
- 主题化:用 CSS 变量承载设计 Token(颜色、间距、圆角、阴影)。
- 可覆盖:对外暴露 className/自定义变量,支持按需覆盖。
1 2 3 4 5 6 7 8 9 10
| :root { --btn-bg: #1f6feb; --btn-fg: #ffffff; --btn-radius: 8px; } .btn { background: var(--btn-bg); color: var(--btn-fg); border-radius: var(--btn-radius); }
|
1 2 3 4
| <div style="--btn-bg:#0ea5e9; --btn-radius:6px"> <button class="btn">Action</button> </div>
|
六、状态与数据流:受控优先,副作用外置
- 受控/非受控:对外暴露受控优先;内部提供非受控兜底,保持一致事件语义。
- 副作用外置:网络/缓存/路由放到容器层;纯视图组件保持函数式。
- Ref 能力:通过
forwardRef
+ useImperativeHandle
暴露必要命令(如 focus()
)。
七、性能优化:更少渲染、更小工作量
- 避免不必要的重渲染:
React.memo
、useMemo/useCallback
;列表项带稳定 key
。
- 延迟加载:
import()
懒加载;条件挂载隐藏内容;虚拟列表处理长清单。
- 长任务切片:Web Worker/
requestIdleCallback
处理重计算。
1 2 3 4 5 6 7 8 9 10 11 12
| import React, { memo } from 'react';
type Item = { id: string; title: string }; export const ItemList = memo(function ItemList({ items }: { items: Item[] }) { return ( <ul> {items.map((it) => ( <li key={it.id}>{it.title}</li> ))} </ul> ); });
|
八、测试与文档:可验证、可发现、可演示
- 单元测试:覆盖受控/非受控、边界值、键盘交互。
- 可视/快照:关键结构与状态快照;视觉回归用于主题/布局。
- 文档与用例:Storybook/Playground,展示 API、交互与可定制点。
1 2 3 4 5 6 7 8 9 10
| import { render, screen, fireEvent } from '@testing-library/react'; test('switch toggles by keyboard', () => { const onChange = vi.fn(); render(<Switch checked={false} onChange={onChange} />); const btn = screen.getByRole('switch'); btn.focus(); fireEvent.keyDown(btn, { key: ' ' }); expect(onChange).toHaveBeenCalledWith(true); });
|
九、版本与兼容:语义化发布与变更控制
- SemVer:破坏性变更走 major;新增功能 minor;修复 patch。
- Deprecation 流程:先标记弃用 + 警告,再在下一个 major 移除。
- 迁移指南:提供变更列表、替代方案、Codemod(可选)。
十、落地清单(Checklist)
- API 完整且命名统一;受控/非受控一致;事件返回稳定。
- 具名插槽/children/透传可用;样式可覆盖且不污染全局。
- A11y 基本到位:语义、ARIA、键盘、焦点环;无阻断可达性问题。
- 性能策略生效:最长渲染路径可控;大列表虚拟化;重计算移出主线程。
- 测试覆盖关键路径并具备演示用例;版本发布遵循 SemVer。
结语
组件封装是一种长期主义:以最小的 API 面积覆盖最大的通用场景,把可扩展点留给组合,把复杂度留在内部。遵循上述准则,可以在不同技术栈与业务环境下产出更稳定、更易用、也更经得起演进的组件。
Happy Coding!