跳到主要内容

前端 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 渲染无/极少AvatarBadgeTag
容器组件数据获取与逻辑UserListContainer
通用组件与业务无关的基础组件可控ButtonModalSelect
业务组件封装特定业务逻辑PaymentFormUserCard

受控 vs 非受控

受控 vs 非受控对比
// 受控组件 — 外部完全控制状态
<Input value={name} onChange={setName} />

// 非受控组件 — 内部管理状态,外部读取
<Input defaultValue="初始值" ref={inputRef} />

// 最佳实践:同时支持两种模式
<Select value={value} defaultValue="option1" onChange={onChange} />

原子组件 vs 复合组件

三、API 设计

API 设计是组件设计中最重要的环节。遵循以下原则:

Props 设计原则

设计一个 Select 组件的 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 分层策略
  1. 核心层:最少必传 props(如 options),决定组件能否工作
  2. 控制层:受控/非受控 props(value/defaultValue/onChange
  3. 外观层:样式和尺寸(sizeclassNameclassNames
  4. 扩展层:自定义渲染和行为(renderOptionfilterOption

Ref 暴露策略

forwardRef + useImperativeHandle
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。

Tabs 组合组件
// ---- 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 暴露状态和行为,让用户完全掌控渲染。

useSelect — Headless 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>;
}
维度HOCRender PropsCustom Hook
可读性低(嵌套包裹)中(回调嵌套)高(线性代码)
TypeScript泛型复杂中等简单
调试体验差(组件名丢失)中等好(直接在组件中)
组合性多层嵌套回调地狱简单组合
Props 冲突容易冲突不冲突不冲突
现状遗留代码少量使用主流推荐

更多 Hooks 知识详见 React Hooks 原理

七、受控与非受控统一

优秀的组件应同时支持受控和非受控模式:

useControllableState Hook
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 variantsTailwind 项目类型安全依赖 Tailwind
classNames 多 slot 示例
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)

可访问的 Dialog 组件核心
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-expandedaria-selected
键盘导航所有操作可通过键盘完成Tab/Enter/Escape/Arrow
焦点管理打开/关闭时正确移动焦点Dialog 打开聚焦,关闭恢复
语义化 HTML优先使用原生元素<button> 而非 <div onClick>

十、组件测试策略

React Testing Library 测试示例
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-axeARIA、键盘导航5%
视觉回归Storybook + Chromatic样式一致性5%

更多测试策略详见 前端测试策略


常见面试问题

Q1: 如何设计一个通用的 Modal/Dialog 组件?需要考虑哪些方面?

答案

一个完善的 Modal 组件需要考虑以下方面:

  1. 渲染位置createPortal 挂载到 document.body,避免被父元素 overflow: hidden 裁剪
  2. 受控/非受控 — 同时支持 open(受控)和 defaultOpen(非受控),通过 useControllableState 统一
  3. 焦点管理 — 打开时自动聚焦到 Modal 内第一个可交互元素;关闭时恢复之前的焦点
  4. 焦点陷阱 — Tab 键只在 Modal 内循环,不会跳到背后的页面元素
  5. ESC 关闭 — 监听 keydown 事件,Escape 键关闭
  6. 遮罩层点击关闭 — 点击 overlay 关闭,点击内容区 stopPropagation
  7. 动画 — 入场/退场 CSS 动画(opacity + transform),需在动画结束后再卸载 DOM
  8. A11Yrole="dialog"aria-modal="true"aria-labelledby 关联标题
  9. 滚动锁定 — 打开时 body 设为 overflow: hidden,关闭时恢复
  10. 嵌套 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 暴露状态(如 selectedValueonChange),子组件通过 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 应该如何设计?有哪些原则?

答案

  1. 最少必传 — 必传 props 越少越好,降低使用门槛
  2. 合理默认值size 默认 'md'disabled 默认 false
  3. 类型安全 — 用 TypeScript 泛型约束,联合类型限制取值范围
  4. 分层设计 — 基础 props → 控制 props → 外观 props → 扩展 props
  5. 命名一致 — 事件用 onXxx,布尔用 isXxx 或形容词(disabledloading
  6. 扩展点className/classNames 样式扩展、renderXxx 自定义渲染
  7. 透传支持 — 使用 ...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 分别是什么?如何选择?

答案

维度HOCRender PropsCustom Hook
原理函数接收组件返回新组件函数作为 prop/children函数内调用 Hooks
示例withAuth(Page)<Auth>{(user) => ...}</Auth>const { user } = useAuth()
优点装饰器语法明确数据来源最简洁、可组合
缺点props 冲突、难调试嵌套地狱只能在函数组件用
推荐度遗留场景特殊场景首选

结论:新项目统一用 Custom Hook。HOC 仅在类组件或装饰器场景使用。Render Props 在需要控制子组件渲染范围时使用(如 Slot 模式)。

更多通信方案参考 组件通信方案

Q7: 如何给组件添加可访问性支持?

答案

  1. 语义化 HTML — 用 <button> 而非 <div onClick>,用 <nav> 而非 <div className="nav">
  2. ARIA 角色和属性
    • role="dialog" / role="menu" / role="tablist"
    • aria-expanded 展开状态、aria-selected 选中状态
    • aria-labelledby 关联标题、aria-describedby 关联描述
  3. 键盘导航 — Tab 聚焦、Enter 确认、Escape 关闭、Arrow 切换
  4. 焦点管理 — 打开弹窗聚焦、关闭恢复、焦点陷阱
  5. 颜色对比度 — 文字与背景对比度不低于 4.5:1(WCAG AA)
  6. 屏幕阅读器测试 — 用 VoiceOver/NVDA 验证朗读效果

Q8: 如何设计组件的样式方案使其易于定制?

答案

推荐多层次组合方案

  1. CSS Variables — 定义组件 token(--btn-bg--btn-radius),用户通过变量覆盖主题
  2. className 透传 — 简单场景,整个组件覆盖
  3. classNames 多 slot — 复杂组件,精细控制各部件样式
  4. 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();

测试优先级:

  1. 核心功能 — 受控/非受控模式切换、状态变更、回调触发
  2. 用户交互 — 点击、键盘操作、表单输入
  3. 边界情况 — 空数据、加载中、错误状态、禁用状态
  4. 可访问性 — ARIA 属性正确性、键盘可操作性

更多策略参考 前端测试策略

Q10: 设计一个通用 Table 组件需要考虑哪些功能?如何分层设计?

答案

功能分层

各层职责

层级职责示例
Headless数据处理、排序/筛选/分页逻辑useTableuseSortByusePagination
基础 Table表格渲染、列配置、虚拟滚动<Table columns={[...]} dataSource={[...]} />
业务 Table接口请求、搜索表单、操作按钮<ProTable request={fetchList} />

核心功能清单:列配置(固定列、列宽拖拽)、排序(单列/多列、前端/后端)、筛选(表头筛选、搜索框)、分页(前端/后端、页码/加载更多)、行选择(单选/多选、跨页)、虚拟滚动(大数据量)、可编辑单元格、列拖拽排序。

关键设计决策:大数据量(>1000 行)用虚拟滚动而非分页,参考 长列表优化。状态管理用 Zustand 或 Context 取决于复杂度。TypeScript 泛型保证列配置与数据类型一致,参考 TypeScript 与 React


相关链接