React 组件设计模式
概述
React 的组件模型看似简单——一个接收 props、返回 JSX 的函数。但当项目规模扩大、组件需要被多处复用、在不同上下文中表现不同行为时,"如何设计组件接口"就成为了决定代码质量的关键问题。
组件设计模式本质上是在回答一个问题:如何在保持组件封装性的同时,给消费者足够的灵活性?
这个问题没有标准答案。不同的场景需要不同的模式——有时候需要高度受控的 API,有时候需要完全开放的组合能力。本话题将系统讲解 React 中经过工程验证的核心设计模式,以及它们各自的适用场景和 trade-off。
Controlled Components 与 Uncontrolled Components
问题背景
表单是 Web 应用中最常见的交互形式。在 React 中,表单元素的值有两种管理方式:
受控组件(Controlled Component):表单值由 React 状态管理,每次变化都通过 onChange 事件更新状态。
非受控组件(Uncontrolled Component):表单值由 DOM 自身管理,通过 ref 在需要时获取值。
理解这两种模式,是理解 React 数据流的基础。
受控组件
// 受控组件:值完全由 React 控制
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter text..."
/>
);
}
受控组件的核心特征:
- value prop 由状态决定:
value={value},不是"xxx"这样的固定值 - onChange 更新状态:用户输入时,事件处理器调用
setValue更新状态 - 状态即真相:任何时刻,React 状态中的值就是屏幕上显示的值
受控组件的优势在于完全可控。你可以:
- 在
onChange中对输入进行验证或转换 - 根据其他字段动态改变输入行为
- 轻松实现"校验不通过时禁用提交"这类逻辑
// 受控组件的高级用法:输入验证
function ValidatedInput() {
const [value, setValue] = useState('');
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
// 输入验证:只允许字母
if (!/^[a-zA-Z]*$/.test(newValue)) {
setError('Only letters allowed');
} else {
setError('');
}
setValue(newValue);
};
return (
<div>
<input value={value} onChange={handleChange} />
{error && <span className="error">{error}</span>}
</div>
);
}
非受控组件
// 非受控组件:值由 DOM 管理
function UncontrolledInput() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
// 在需要时通过 ref 获取值
console.log('Input value:', inputRef.current?.value);
};
return (
<div>
<input ref={inputRef} defaultValue="Hello" />
<button onClick={handleSubmit}>Submit</button>
</div>
);
}
非受控组件使用 defaultValue(对应 HTML 的 value)来设置初始值,之后就不再受 React 控制。
什么时候用非受控组件?
- 表单不需要实时校验
- 你只想在表单提交时获取值,不想追踪每次输入
- 与不支持受控模式的第三方库集成
选择原则
Dan Abramov 的 Thinking in React 一文指出了一个实用原则:
找到"拥有数据的组件",让它负责渲染和使用数据。如果多个组件需要访问同一份数据,把那份数据提升到它们最近的公共祖先。
对于表单场景:
| 场景 | 推荐模式 |
|---|---|
| 需要实时验证 | 受控 |
| 需要根据其他字段动态变化 | 受控 |
| 表单复杂,跨字段有依赖 | 受控 + 表单库 |
| 简单场景,只需提交时取值 | 非受控 |
| 集成第三方输入控件 | 非受控(通过 ref) |
Higher-Order Components(HOC)
模式定义
HOC 是一个接收组件、返回新组件的函数。它是 React 中最早的逻辑复用模式之一,在 Hooks 出现之前是复用状态逻辑的标准方案。
// HOC 的类型签名
function withAuth<P extends object>(Component: React.ComponentType<P>) {
return function AuthenticatedComponent(props: P) {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" />;
}
return <Component {...props} user={user} />;
};
}
// 使用
const ProtectedPage = withAuth(DashboardPage);
HOC 的名称约定:with 前缀 + 功能名(如 withAuth、withRouter、withTheme)。
经典应用场景
1. 权限控制
function withPermission<P>(
WrappedComponent: React.ComponentType<P>,
requiredRole: string
) {
return function PermissionGuard(props: P) {
const { user } = useAuth();
const hasPermission = user?.role === requiredRole;
if (!hasPermission) {
return <AccessDenied />;
}
return <WrappedComponent {...props} />;
};
}
// 使用
const AdminPanel = withPermission(AdminPanelContent, 'admin');
2. 数据获取
function withData<P>(
WrappedComponent: React.ComponentType<P>,
fetchFn: (props: P) => Promise<any>
) {
return function DataWrapper(props: P) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchFn(props)
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [props.id]);
if (loading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <WrappedComponent {...props} data={data} />;
};
}
HOC 的问题
HOC 虽然强大,但有几个显著问题:
1. 嵌套地狱
// 多个 HOC 嵌套,调试困难
const EnhancedComponent = withAuth(withTheme(withData(Component)));
2. Props 命名冲突
// 如果 HOC A 注入了 `user` prop,HOC B 也注入了 `user` prop
// 就会发生冲突,后面的覆盖前面的
3. 静态组合 vs 动态组合
HOC 在组件定义时组合,而不是在渲染时。这导致:
- 很难在 HOC 内部访问组件的内部状态
- 很难在 HOC 条件性地决定是否包装组件
4. 类型推导困难
// HOC 的类型推导需要大量 boilerplate
function withAuth<P extends { user?: User }>(
Component: React.ComponentType<P>
): React.ComponentType<Omit<P, 'user'>> {
// ...
}
React 官方文档现在推荐用 Hooks 替代 HOC 进行逻辑复用。但 HOC 在渲染劫持和条件渲染场景仍有价值,理解它有助于理解 React 生态的历史演进。
Render Props
模式定义
Render Props 是一种通过函数 prop 传递 JSX 渲染逻辑的模式:
// MouseTracker 使用 render prop 传递鼠标位置
<MouseTracker
render={(position) => (
<div>Mouse is at {position.x}, {position.y}</div>
)}
/>
// 或者用 children prop(更常见)
<MouseTracker>
{(position) => (
<div>Mouse is at {position.x}, {position.y}</div>
)}
</MouseTracker>
Render Props 的核心思想:把"怎么渲染"的决定权交给消费者,而不是组件作者。
经典实现
// MouseTracker 组件
function MouseTracker({ children }: { children: (pos: Point) => ReactNode }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return children(position);
}
Render Props vs HOC
| 维度 | Render Props | HOC |
|---|---|---|
| 组合方式 | 扁平(一次调用) | 嵌套(多层包装) |
| Props 冲突 | 无(显式传递) | 有(隐式注入) |
| 类型推导 | 自然 | 需要额外 boilerplate |
| 静态分析 | 困难 | 困难 |
| 执行顺序 | 渲染时 | 定义时 |
Render Props 的遗留价值
Hooks 出现后,Render Props 的大部分场景被 Hooks 替代。但它仍有不可替代的场景:
1. 条件渲染(Feature Flag)
<Feature flag="new-ui">
{(enabled) => enabled ? <NewUI /> : <LegacyUI />}
</Feature>
2. 跨组件树的状态传递
Render Props 本质上是一种状态分配机制。如果你的状态共享需求比较简单,Context + Hooks 通常是更好的方案。但当需要精确控制渲染时机和位置时,Render Props 仍有价值。
Compound Components(复合组件)
模式定义
Compound Components 是一种声明式组合模式。组件的各个"部分"作为子组件暴露,消费者通过组合这些子组件来控制结构和顺序,而状态在内部通过 Context 共享。
这种模式的灵感来自 Web Components 的 slot 概念和 React 的组合模型。
典型实现
// Tabs 组件的消费者视角
<Tabs defaultValue="tab1">
<Tabs.List>
<Tabs.Trigger value="tab1">Profile</Tabs.Trigger>
<Tabs.Trigger value="tab2">Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1">
<ProfileTab />
</Tabs.Content>
<Tabs.Content value="tab2">
<SettingsTab />
</Tabs.Content>
</Tabs>
内部实现
import { createContext, useContext, useState, ReactNode } from 'react';
// Context 存储共享状态
interface TabsContextValue {
value: string;
onValueChange: (value: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
// 顶层组件:管理状态
function Tabs({
defaultValue,
children,
}: {
defaultValue: string;
children: ReactNode;
}) {
const [value, setValue] = useState(defaultValue);
return (
<TabsContext.Provider value={{ value, onValueChange: setValue }}>
{children}
</TabsContext.Provider>
);
}
// List 组件
Tabs.List = function TabsList({ children }: { children: ReactNode }) {
return <div role="tablist" className="flex gap-1">{children}</div>;
};
// Trigger 组件
Tabs.Trigger = function TabsTrigger({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs.Trigger must be used within Tabs');
const isActive = ctx.value === value;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => ctx.onValueChange(value)}
>
{children}
</button>
);
};
// Content 组件
Tabs.Content = function TabsContent({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs.Content must be used within Tabs');
if (ctx.value !== value) return null;
return <div role="tabpanel">{children}</div>;
};
为什么 Compound Components 有效
1. 控制反转(Inversion of Control)
消费者决定渲染什么、以什么顺序渲染。组件只负责状态协调,不预设渲染结构。
2. 声明式 API
比 renderHeader、renderFooter 等配置 prop 更直观。你看到 JSX,就知道渲染结果。
3. 可组合性
可以在子组件之间插入自定义内容:
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="analytics">Analytics</Tabs.Trigger>
{/* 可以在任意位置插入其他组件 */}
<div className="ml-auto">
<ExportButton />
</div>
</Tabs.List>
<Tabs.Content value="overview">...</Tabs.Content>
<Tabs.Content value="analytics">...</Tabs.Content>
</Tabs>
4. 样式灵活性
子组件接受 className prop,允许消费者覆盖样式:
<Tabs.Trigger value="tab1" className="custom-trigger">
Custom Styled Trigger
</Tabs.Trigger>
代表实现
Radix UI、Reach UI、Ariakit 都采用 Compound Components 作为核心架构。shadcn/ui 同样基于这个模式。
设计原则:组合优于继承
为什么"组合"是 React 的核心思想
React 的官方文档在 Composition vs Inheritance 中明确建议:组合优于继承。
这有几个原因:
- 显式优于隐式:通过 props 传递的数据流是显式的,容易追踪和调试
- 灵活组合:任何组件只要 props 接口兼容,就可以被组合
- 打破层级限制:继承有严格的层级关系,组合没有
组合的三种主要形式
1. Children 组合
// 通过 children 传递 UI
function Card({ children }: { children: ReactNode }) {
return <div className="card">{children}</div>;
}
<Card>
<Card.Header>Title</Card.Header>
<Card.Body>Content</Card.Body>
</Card>
2. Slot 组合(Render Props)
// 通过函数 prop 传递渲染逻辑
<Modal
header={(onClose) => <button onClick={onClose}>X</button>}
>
Content here
</Modal>
3. Headless 组合(逻辑分离)
// 只提供逻辑,不提供 UI
function useCombobox({ items, onSelect }) {
// 状态管理和交互逻辑
return { getInputProps, getListProps, getItemProps };
}
// 消费者完全控制渲染
function MyCombobox() {
const { getInputProps, getListProps } = useCombobox({ items, onSelect });
return (
<div>
<input {...getInputProps()} />
<ul {...getListProps()} />
</div>
);
}
继承的合理使用场景
组合不是银弹。在某些场景下,继承仍然是合理的选择:
// 一个具体组件继承另一个具体组件
class Button extends React.Component {
// ...
}
class IconButton extends Button {
// IconButton 是 Button 的特化,仍然使用继承
}
判断标准:如果你发现自己在写 class A extends B,先问自己:
- A 是否是 B 的一种(is-a 关系)?
- 还是 A 需要使用 B 的能力(has-a / uses-a 关系)?
如果是后者,组合更合适。
Headless UI:无头组件
概念定义
Headless UI 是一种将逻辑层和渲染层完全分离的模式。组件提供:
- 状态管理
- 键盘交互
- ARIA 属性
- 焦点管理
但不提供任何 UI 样式。
Headless vs Compound Components
| 维度 | Compound Components | Headless UI |
|---|---|---|
| UI 控制 | 部分控制(子组件结构) | 完全控制(零 UI) |
| 学习曲线 | 低(JSX 组合直觉) | 中(需要理解 prop getters) |
| 样式绑定 | 通常有默认样式 | 零样式 |
| 适用场景 | Design System 组件 | 跨 Design System 基础设施 |
| 可访问性 | 内置 | 内置 |
实际应用:React Aria
Adobe 的 React Aria 是 Headless UI 的标杆实现:
function useComboBox({
items,
onSelectionChange,
}: {
items: string[];
onSelectionChange: (item: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
return {
getInputProps: () => ({
role: 'combobox',
'aria-expanded': isOpen,
onChange: (e) => {
setIsOpen(true);
// filter logic
},
}),
getListProps: () => ({
role: 'listbox',
}),
getItemProps: (index: number) => ({
role: 'option',
'aria-selected': index === activeIndex,
onClick: () => {
onSelectionChange(items[index]);
setIsOpen(false);
},
}),
};
}
代表库:
- React Aria(Adobe)— 专注于可访问性
- Downshift — Combobox 的完整实现
- TanStack Table — 表格逻辑
- Headless UI(Tailwind Labs)— 组件级实现
实际工程决策
模式选择指南
| 场景 | 推荐模式 |
|---|---|
| 简单 UI 复用 | Children 组合 |
| 需要在多处使用相同交互逻辑 | 自定义 Hook |
| 需要声明式控制组件内部结构 | Compound Components |
| 需要完全自定义 UI,只复用逻辑 | Headless UI |
| 一次性增强组件 | HOC(遗留场景) |
| 条件渲染 | Render Props(遗留场景) |
常见反模式
1. Boolean Explosion(布尔值爆炸)
// ❌ 反模式
<Modal
isOpen
isFullscreen
isClosable
hasOverlay
showHeader
showFooter
/>
// ✅ 正确做法:variant 或 Compound Components
<Modal variant="fullscreen">
<Modal.Header />
<Modal.Body />
<Modal.Footer />
</Modal>
2. God Component(上帝组件)
单个组件超过 500 行,混合了数据获取、状态管理、UI 渲染。拆分为 Container + Presentational,或用 Hooks 提取逻辑。
3. Prop Drilling(属性穿透)
Props 穿越 5+ 层组件。使用 Context、状态管理库,或重新设计组件边界。
延展阅读
- React 官方文档 — Thinking in React — 组件设计思维
- Patterns.dev — React Component Patterns — Addy Osmani 与 Lydia Hallie 的设计模式合集
- Kent C. Dodds — Advanced React Patterns — Compound Components 和控制反转的经典教程
- Radix UI — Architecture — Compound Components + Headless 的工业级实现
- React Aria — Adobe — Headless UI hooks 的标杆实现