前端 React 组件通用设计
问题
如何设计一个高质量、可复用、可扩展的 React 组件?通用组件设计有哪些原则和模式?
答案
组件是 React 应用的基本构建单元。好的组件设计能提高代码复用性、降低维护成本、提升开发体验。本文从设计原则、常用模式、API 设计到测试策略,系统性地讲解 React 组件的通用设计方法论。
一、组件设计原则
| 原则 | 说明 | 示例 |
|---|---|---|
| 单一职责(SRP) | 一个组件只做一件事 | Button 只管按钮渲染,不管请求 |
| 开闭原则(OCP) | 对扩展开放,对修改关闭 | 通过 props/slot 扩展,不改源码 |
| 组合优于继承 | 用组合构建复杂 UI | <Card><CardHeader/><CardBody/></Card> |
| 关注点分离 | 逻辑、UI、状态分离 | Hooks 抽逻辑,组件只管渲染 |
| 最小惊讶 | API 设计符合直觉 | onClick 而非 handlePress |
| 最小 Props | 必传 Props 越少越好 | 合理默认值,可选 Props 覆盖 |
组件设计的本质是 API 设计。好的组件 API 让使用者无需阅读源码就能正确使用。
二、组件分类
按职责分类
| 类型 | 职责 | 状态 | 示例 |
|---|---|---|---|
| 展示组件 | 纯 UI 渲染 | 无/极少 | Avatar、Badge、Tag |
| 容器组件 | 数据获取与逻辑 | 有 | UserListContainer |
| 通用组件 | 与业务无关的基础组件 | 可控 | Button、Modal、Select |
| 业务组件 | 封装特定业务逻辑 | 有 | PaymentForm、UserCard |
受控 vs 非受控
// 受控组件 — 外部完全控制状态
<Input value={name} onChange={setName} />
// 非受控组件 — 内部管理状态,外部读取
<Input defaultValue="初始值" ref={inputRef} />
// 最佳实践:同时支持两种模式
<Select value={value} defaultValue="option1" onChange={onChange} />
原子组件 vs 复合组件
三、API 设计
API 设计是组件设计中最重要的环节。遵循以下原则:
Props 设计原则
interface SelectProps<T = string> {
// ---- 核心 Props(最少必传) ----
options: SelectOption<T>[]; // 必传:选项列表
// ---- 受控/非受控 ----
value?: T; // 受控模式
defaultValue?: T; // 非受控模式
onChange?: (value: T) => void; // 值变更回调
// ---- 通用 Props ----
placeholder?: string; // 占位文本
disabled?: boolean; // 禁用状态
size?: 'sm' | 'md' | 'lg'; // 尺寸,默认 'md'
className?: string; // 样式扩展
// ---- 高级 Props ----
renderOption?: (option: SelectOption<T>) => React.ReactNode; // 自定义渲染
filterOption?: (input: string, option: SelectOption<T>) => boolean;
onSearch?: (keyword: string) => void;
loading?: boolean;
// ---- 扩展点 ----
classNames?: { trigger?: string; dropdown?: string; option?: string };
children?: React.ReactNode; // 组合模式
}
interface SelectOption<T = string> {
label: React.ReactNode;
value: T;
disabled?: boolean;
}
- 核心层:最少必传 props(如
options),决定组件能否工作 - 控制层:受控/非受控 props(
value/defaultValue/onChange) - 外观层:样式和尺寸(
size、className、classNames) - 扩展层:自定义渲染和行为(
renderOption、filterOption)
Ref 暴露策略
interface ModalRef {
open: () => void;
close: () => void;
}
const Modal = forwardRef<ModalRef, ModalProps>((props, ref) => {
const [visible, setVisible] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setVisible(true),
close: () => setVisible(false),
}));
if (!visible) return null;
return <div className="modal">{props.children}</div>;
});
// 使用
const modalRef = useRef<ModalRef>(null);
modalRef.current?.open();
四、组合模式(Compound Components)
组合模式通过 Context 在父子组件间共享状态,让用户以声明式的方式灵活组合 UI。
// ---- Context ----
interface TabsContextValue {
activeKey: string;
onChange: (key: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
// ---- 父组件 ----
function Tabs({ defaultActiveKey, children, onChange }: TabsProps) {
const [activeKey, setActiveKey] = useState(defaultActiveKey);
const handleChange = (key: string) => {
setActiveKey(key);
onChange?.(key);
};
return (
<TabsContext.Provider value={{ activeKey, onChange: handleChange }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// ---- 子组件 ----
function TabPanel({ tabKey, label, children }: TabPanelProps) {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('TabPanel must be used within Tabs');
return (
<>
<button
className={ctx.activeKey === tabKey ? 'active' : ''}
onClick={() => ctx.onChange(tabKey)}
>
{label}
</button>
{ctx.activeKey === tabKey && <div className="panel">{children}</div>}
</>
);
}
// ---- 挂载子组件 ----
Tabs.Panel = TabPanel;
// ---- 使用 ----
<Tabs defaultActiveKey="1">
<Tabs.Panel tabKey="1" label="Tab 1">Content 1</Tabs.Panel>
<Tabs.Panel tabKey="2" label="Tab 2">Content 2</Tabs.Panel>
</Tabs>
配置式 vs 组合式对比:
| 维度 | 配置式 | 组合式(Compound) |
|---|---|---|
| API 风格 | items={[{key, label, content}]} | <Tabs.Panel> 声明式 |
| 灵活性 | 低,固定渲染模板 | 高,自由组合排列 |
| 条件渲染 | 需特殊处理 | 天然支持 {show && <Panel/>} |
| TypeScript | 配置对象类型复杂 | 各子组件独立类型 |
| 适用场景 | 简单、标准化 | 复杂、需定制化 |
五、Headless 组件模式
Headless 组件将逻辑与 UI 完全分离,用 Hook 暴露状态和行为,让用户完全掌控渲染。
interface UseSelectOptions<T> {
options: SelectOption<T>[];
defaultValue?: T;
onChange?: (value: T) => void;
}
function useSelect<T>({ options, defaultValue, onChange }: UseSelectOptions<T>) {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(defaultValue);
const [highlightIndex, setHighlightIndex] = useState(0);
const select = (value: T) => {
setSelected(value);
setIsOpen(false);
onChange?.(value);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
setHighlightIndex(i => Math.min(i + 1, options.length - 1));
break;
case 'ArrowUp':
setHighlightIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
select(options[highlightIndex].value);
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return {
isOpen,
selected,
highlightIndex,
toggle: () => setIsOpen(!isOpen),
select,
getTriggerProps: () => ({ onClick: () => setIsOpen(!isOpen), onKeyDown: handleKeyDown }),
getOptionProps: (index: number) => ({
onClick: () => select(options[index].value),
'aria-selected': options[index].value === selected,
}),
};
}
// ---- 使用:用户完全控制 UI ----
function MyCustomSelect() {
const { isOpen, selected, toggle, getTriggerProps, getOptionProps } = useSelect({
options: [{ label: 'React', value: 'react' }, { label: 'Vue', value: 'vue' }],
});
return (
<div>
<button {...getTriggerProps()}>{selected || 'Select...'}</button>
{isOpen && (
<ul>
{options.map((opt, i) => (
<li key={opt.value} {...getOptionProps(i)}>{opt.label}</li>
))}
</ul>
)}
</div>
);
}
Headless vs 传统组件库对比:
| 维度 | 传统组件库(Ant Design) | Headless(Radix UI) |
|---|---|---|
| 自定义 UI | 受限,需要覆盖样式 | 完全自由 |
| 包体积 | 较大,包含样式 | 极小,无样式 |
| 设计系统 | 需要与库的视觉匹配 | 原生适配任何设计 |
| 可访问性 | 内置但不可修改 | 内置且可扩展 |
| 学习成本 | 低,开箱即用 | 中,需要自己写 UI |
| 适用场景 | 中后台标准化 | C 端定制化 |
更多关于组件库建设的内容参考 组件库建设。
六、逻辑复用:HOC → Render Props → Hooks
React 的逻辑复用经历了三代演进:
// ---- 1. HOC(高阶组件) ----
function withAuth<P>(Component: React.ComponentType<P & { user: User }>) {
return function AuthWrapper(props: P) {
const user = useUser();
if (!user) return <Login />;
return <Component {...props} user={user} />;
};
}
const ProtectedPage = withAuth(Dashboard);
// ---- 2. Render Props ----
function Auth({ children }: { children: (user: User) => React.ReactNode }) {
const user = useUser();
if (!user) return <Login />;
return <>{children(user)}</>;
}
<Auth>{(user) => <Dashboard user={user} />}</Auth>
// ---- 3. Custom Hook(推荐) ----
function useAuth() {
const user = useUser();
return { user, isAuthenticated: !!user };
}
function Dashboard() {
const { user, isAuthenticated } = useAuth();
if (!isAuthenticated) return <Login />;
return <div>Welcome, {user.name}</div>;
}
| 维度 | HOC | Render Props | Custom Hook |
|---|---|---|---|
| 可读性 | 低(嵌套包裹) | 中(回调嵌套) | 高(线性代码) |
| TypeScript | 泛型复杂 | 中等 | 简单 |
| 调试体验 | 差(组件名丢失) | 中等 | 好(直接在组件中) |
| 组合性 | 多层嵌套 | 回调地狱 | 简单组合 |
| Props 冲突 | 容易冲突 | 不冲突 | 不冲突 |
| 现状 | 遗留代码 | 少量使用 | 主流推荐 |
更多 Hooks 知识详见 React Hooks 原理。
七、受控与非受控统一
优秀的组件应同时支持受控和非受控模式:
function useControllableState<T>(
value: T | undefined, // 外部受控值
defaultValue: T, // 默认值(非受控)
onChange?: (value: T) => void // 变更回调
): [T, (value: T) => void] {
// 判断是否受控
const isControlled = value !== undefined;
const [internal, setInternal] = useState(defaultValue);
const currentValue = isControlled ? value : internal;
const setValue = useCallback((next: T) => {
if (!isControlled) {
setInternal(next);
}
onChange?.(next);
}, [isControlled, onChange]);
return [currentValue, setValue];
}
// ---- 使用 ----
function Toggle({ value, defaultValue = false, onChange }: ToggleProps) {
const [checked, setChecked] = useControllableState(value, defaultValue, onChange);
return (
<button onClick={() => setChecked(!checked)}>
{checked ? 'ON' : 'OFF'}
</button>
);
}
// 受控
<Toggle value={isOn} onChange={setIsOn} />
// 非受控
<Toggle defaultValue={true} />
// 非受控 + 监听
<Toggle defaultValue={false} onChange={(v) => console.log(v)} />
受控/非受控模式不应在组件生命周期中切换。如果初始传了 value,后续也必须保持传入;如果初始用 defaultValue,就不应再传 value。
八、样式方案设计
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
className 透传 | 所有场景 | 简单通用 | 只有一个入口 |
classNames 多 slot | 复杂组件 | 精细控制 | API 增多 |
| CSS Variables | 主题定制 | 运行时切换 | 需要预定义变量 |
style / styles | 动态样式 | 灵活 | 性能差 |
| Tailwind variants | Tailwind 项目 | 类型安全 | 依赖 Tailwind |
interface ButtonProps {
className?: string;
classNames?: {
root?: string;
icon?: string;
label?: string;
spinner?: string;
};
}
function Button({ className, classNames, icon, children, loading }: ButtonProps) {
return (
<button className={cn('btn', className, classNames?.root)}>
{loading && <Spinner className={classNames?.spinner} />}
{icon && <span className={cn('btn-icon', classNames?.icon)}>{icon}</span>}
<span className={cn('btn-label', classNames?.label)}>{children}</span>
</button>
);
}
九、可访问性(A11Y)
function Dialog({ open, onClose, title, children }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
// 1. 焦点陷阱 — 打开时聚焦,Tab 键循环在 Dialog 内
useEffect(() => {
if (open) {
const prevFocus = document.activeElement as HTMLElement;
dialogRef.current?.focus();
return () => prevFocus?.focus(); // 关闭时恢复焦点
}
}, [open]);
// 2. ESC 关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (open) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<h2 id="dialog-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="关闭对话框">×</button>
</div>
</div>,
document.body
);
}
A11Y 核心要素:
| 要素 | 说明 | 示例 |
|---|---|---|
| ARIA 角色 | 告知辅助技术组件类型 | role="dialog" |
| ARIA 属性 | 描述状态和关系 | aria-expanded、aria-selected |
| 键盘导航 | 所有操作可通过键盘完成 | Tab/Enter/Escape/Arrow |
| 焦点管理 | 打开/关闭时正确移动焦点 | Dialog 打开聚焦,关闭恢复 |
| 语义化 HTML | 优先使用原生元素 | <button> 而非 <div onClick> |
十、组件测试策略
import { render, screen, fireEvent } from '@testing-library/react';
describe('Toggle', () => {
// 1. 渲染测试
it('renders with default value', () => {
render(<Toggle defaultValue={false} />);
expect(screen.getByRole('button')).toHaveTextContent('OFF');
});
// 2. 交互测试 — 测试用户行为,不测实现细节
it('toggles on click', () => {
const onChange = vi.fn();
render(<Toggle defaultValue={false} onChange={onChange} />);
fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('button')).toHaveTextContent('ON');
expect(onChange).toHaveBeenCalledWith(true);
});
// 3. 受控模式测试
it('works in controlled mode', () => {
const { rerender } = render(<Toggle value={false} onChange={() => {}} />);
expect(screen.getByRole('button')).toHaveTextContent('OFF');
rerender(<Toggle value={true} onChange={() => {}} />);
expect(screen.getByRole('button')).toHaveTextContent('ON');
});
// 4. 可访问性测试
it('has correct ARIA attributes', () => {
render(<Toggle defaultValue={true} />);
expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
});
});
| 测试类型 | 工具 | 关注点 | 比重 |
|---|---|---|---|
| 单元测试 | Vitest + RTL | 组件逻辑、状态、回调 | 70% |
| 交互测试 | RTL + user-event | 用户行为模拟 | 20% |
| 可访问性 | jest-axe | ARIA、键盘导航 | 5% |
| 视觉回归 | Storybook + Chromatic | 样式一致性 | 5% |
更多测试策略详见 前端测试策略。
常见面试问题
Q1: 如何设计一个通用的 Modal/Dialog 组件?需要考虑哪些方面?
答案:
一个完善的 Modal 组件需要考虑以下方面:
- 渲染位置 —
createPortal挂载到document.body,避免被父元素overflow: hidden裁剪 - 受控/非受控 — 同时支持
open(受控)和defaultOpen(非受控),通过useControllableState统一 - 焦点管理 — 打开时自动聚焦到 Modal 内第一个可交互元素;关闭时恢复之前的焦点
- 焦点陷阱 — Tab 键只在 Modal 内循环,不会跳到背后的页面元素
- ESC 关闭 — 监听
keydown事件,Escape键关闭 - 遮罩层点击关闭 — 点击 overlay 关闭,点击内容区
stopPropagation - 动画 — 入场/退场 CSS 动画(
opacity+transform),需在动画结束后再卸载 DOM - A11Y —
role="dialog"、aria-modal="true"、aria-labelledby关联标题 - 滚动锁定 — 打开时
body设为overflow: hidden,关闭时恢复 - 嵌套 Modal — 多个 Modal 叠加时的
z-index管理
Q2: 什么是 Compound Components 模式?举例说明
答案:
Compound Components(复合组件)是一种通过 Context 在父子组件间隐式共享状态的模式。用户以声明式的方式组合子组件,而非传递复杂的配置对象。
典型场景如 <Select> + <Option>:
// 配置式 — 灵活性低
<Select options={[{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }]} />
// 组合式 — 灵活性高
<Select>
<Select.Option value="a">A</Select.Option>
<Select.Option value="b" disabled>B</Select.Option>
<Select.Divider />
<Select.Option value="c">C(自定义 UI)</Select.Option>
</Select>
实现要点:父组件通过 Context.Provider 暴露状态(如 selectedValue、onChange),子组件通过 useContext 消费。优势是用户可以自由排列、条件渲染、插入自定义元素。
Q3: 什么是 Headless 组件?和传统组件库有什么区别?
答案:
Headless 组件只提供逻辑和状态管理,不包含任何 UI 渲染。通常以 Hook 形式暴露,用户完全掌控渲染层。
代表库:Radix UI Primitives、React Aria(Adobe)、Headless UI(Tailwind)、Downshift。
与传统组件库的核心区别:传统组件库(如 Ant Design)是"逻辑 + UI 一体",开箱即用但定制成本高;Headless 是"只给逻辑",需要自己写 UI 但完全自由。
选择建议:中后台标准化项目用传统组件库(Ant Design),C 端有独立设计系统的项目用 Headless(Radix UI + Tailwind CSS)。
Q4: 如何设计一个同时支持受控和非受控的组件?
答案:
核心是实现一个 useControllableState Hook:
function useControllableState<T>(
controlledValue: T | undefined,
defaultValue: T,
onChange?: (v: T) => void
): [T, (v: T) => void] {
const isControlled = controlledValue !== undefined;
const [internal, setInternal] = useState(defaultValue);
const value = isControlled ? controlledValue : internal;
const setValue = useCallback((next: T) => {
if (!isControlled) setInternal(next);
onChange?.(next);
}, [isControlled, onChange]);
return [value, setValue];
}
组件 API 遵循 value / defaultValue / onChange 三件套约定。传了 value 就是受控模式(外部驱动),传了 defaultValue 就是非受控模式(内部驱动)。
Q5: 组件的 Props API 应该如何设计?有哪些原则?
答案:
- 最少必传 — 必传 props 越少越好,降低使用门槛
- 合理默认值 —
size默认'md'、disabled默认false - 类型安全 — 用 TypeScript 泛型约束,联合类型限制取值范围
- 分层设计 — 基础 props → 控制 props → 外观 props → 扩展 props
- 命名一致 — 事件用
onXxx,布尔用isXxx或形容词(disabled、loading) - 扩展点 —
className/classNames样式扩展、renderXxx自定义渲染 - 透传支持 — 使用
...rest透传原生 HTML 属性
// 好的 API
<Button size="lg" loading disabled onClick={handleClick}>提交</Button>
// 不好的 API — 太多必传,命名不直觉
<Button btnSize={2} isLoading={true} isDisabled={true} handleClick={fn}>提交</Button>
Q6: HOC、Render Props 和 Custom Hooks 分别是什么?如何选择?
答案:
| 维度 | HOC | Render Props | Custom Hook |
|---|---|---|---|
| 原理 | 函数接收组件返回新组件 | 函数作为 prop/children | 函数内调用 Hooks |
| 示例 | withAuth(Page) | <Auth>{(user) => ...}</Auth> | const { user } = useAuth() |
| 优点 | 装饰器语法 | 明确数据来源 | 最简洁、可组合 |
| 缺点 | props 冲突、难调试 | 嵌套地狱 | 只能在函数组件用 |
| 推荐度 | 遗留场景 | 特殊场景 | 首选 |
结论:新项目统一用 Custom Hook。HOC 仅在类组件或装饰器场景使用。Render Props 在需要控制子组件渲染范围时使用(如 Slot 模式)。
更多通信方案参考 组件通信方案。
Q7: 如何给组件添加可访问性支持?
答案:
- 语义化 HTML — 用
<button>而非<div onClick>,用<nav>而非<div className="nav"> - ARIA 角色和属性:
role="dialog"/role="menu"/role="tablist"aria-expanded展开状态、aria-selected选中状态aria-labelledby关联标题、aria-describedby关联描述
- 键盘导航 — Tab 聚焦、Enter 确认、Escape 关闭、Arrow 切换
- 焦点管理 — 打开弹窗聚焦、关闭恢复、焦点陷阱
- 颜色对比度 — 文字与背景对比度不低于 4.5:1(WCAG AA)
- 屏幕阅读器测试 — 用 VoiceOver/NVDA 验证朗读效果
Q8: 如何设计组件的样式方案使其易于定制?
答案:
推荐多层次组合方案:
- CSS Variables — 定义组件 token(
--btn-bg、--btn-radius),用户通过变量覆盖主题 className透传 — 简单场景,整个组件覆盖classNames多 slot — 复杂组件,精细控制各部件样式data-*属性 — 用data-state="open"标记状态,用 CSS 属性选择器定制
// CSS Variables 主题定制
<Button style={{ '--btn-bg': '#ff0000' } as React.CSSProperties} />
// classNames 多 slot 精细控制
<Select classNames={{ trigger: 'my-trigger', dropdown: 'my-dropdown' }} />
// data-state 状态驱动样式
<div data-state={isOpen ? 'open' : 'closed'} className="accordion" />
// CSS: .accordion[data-state="open"] { max-height: 1000px; }
Q9: 如何测试一个 React 组件?测试策略是什么?
答案:
核心原则:测试用户行为,不测实现细节。
// ❌ 测试实现细节(脆弱)
expect(component.state.count).toBe(1);
expect(wrapper.find('.btn-class')).toHaveLength(1);
// ✅ 测试用户行为(稳定)
fireEvent.click(screen.getByRole('button', { name: '提交' }));
expect(screen.getByText('提交成功')).toBeInTheDocument();
测试优先级:
- 核心功能 — 受控/非受控模式切换、状态变更、回调触发
- 用户交互 — 点击、键盘操作、表单输入
- 边界情况 — 空数据、加载中、错误状态、禁用状态
- 可访问性 — ARIA 属性正确性、键盘可操作性
更多策略参考 前端测试策略。
Q10: 设计一个通用 Table 组件需要考虑哪些功能?如何分层设计?
答案:
功能分层:
各层职责:
| 层级 | 职责 | 示例 |
|---|---|---|
| Headless | 数据处理、排序/筛选/分页逻辑 | useTable、useSortBy、usePagination |
| 基础 Table | 表格渲染、列配置、虚拟滚动 | <Table columns={[...]} dataSource={[...]} /> |
| 业务 Table | 接口请求、搜索表单、操作按钮 | <ProTable request={fetchList} /> |
核心功能清单:列配置(固定列、列宽拖拽)、排序(单列/多列、前端/后端)、筛选(表头筛选、搜索框)、分页(前端/后端、页码/加载更多)、行选择(单选/多选、跨页)、虚拟滚动(大数据量)、可编辑单元格、列拖拽排序。
关键设计决策:大数据量(>1000 行)用虚拟滚动而非分页,参考 长列表优化。状态管理用 Zustand 或 Context 取决于复杂度。TypeScript 泛型保证列配置与数据类型一致,参考 TypeScript 与 React。