React 组合模式
为什么组合是 React 的核心设计哲学
React 的官方文档明确指出:"在 React 中,组合优于继承"。这不仅仅是一个设计建议,而是一套经过多年实践检验的组件复用模式体系。
理解组合模式,意味着理解 React 组件模型的核心:组件是 UI 的封装单元,而组合是跨越封装边界复用逻辑的方式。当我们需要让一个组件与另一个组件"合作"时,组合提供了比继承更灵活、更轻量的方式。
面试定位:组合模式是 React 面试中考察"组件设计能力"的核心话题。面试官通过候选人对 HOC、render props、custom hooks 三种模式的理解深度和取舍判断,来评估其构建可复用组件架构的能力。
一、"组合优于继承"在 React 中的表达
1.1 继承的困境
在传统的面向对象编程中,继承是一种常见的代码复用方式。但在 UI 组件设计中,继承会带来几个问题:
// 假设我们有一个 Button 基类
class Button extends React.Component {
// 基本按钮逻辑
}
// 想要一个带图标的 Button
class IconButton extends Button {
// 图标按钮逻辑
}
// 想要一个带 loading 状态的 IconButton
class LoadingIconButton extends IconButton {
// Loading 逻辑
}
// 想要一个既带 loading 又有不同变体的...
// 继承链会爆炸性地增长
继承的问题:
- 紧耦合:子类与父类的实现细节绑定
- 不灵活:难以动态组合行为
- 层次过深:继承链越长,维护越困难
- 隐式依赖:父类的实现会隐式地影响子类的行为
1.2 组合的优势
组合的核心思想是:将组件视为能力的组合,而不是继承链的节点。
// 组合方式:功能是独立可拔插的
<Button
leftIcon={<Icon />}
rightIcon={<Spinner />}
isLoading={isLoading}
variant="primary"
>
点击
</Button>
// 或者更灵活的 slot 模式
<Button>
<ButtonIcon position="left"><Icon /></ButtonIcon>
<ButtonContent>点击</ButtonContent>
{isLoading && <ButtonSpinner />}
</Button>
组合的优势:
- 松耦合:功能是独立的,可以按需组合
- 灵活:可以动态决定组件的组成
- 扁平化:没有继承层次,所有功能在同一层级
- 显式依赖:所有依赖都是通过 props 传入,一目了然
二、Containment(包含)和 Slot Pattern
2.1 children prop:最基本的组合方式
React 组件的 children prop 允许组件在其标签之间包含任意内容:
function Card({ children }) {
return <div className="card">{children}</div>;
}
function App() {
return (
<Card>
<h2>标题</h2>
<p>内容</p>
</Card>
);
}
这个简单的模式已经体现了组合的核心思想:Card 组件不知道也不关心它的 children 是什么。
2.2 Slot Pattern:显式的内容位置
当 children 需要被放在特定位置时,slot pattern 提供了更明确的组合方式:
function Dialog({ header, children, footer }) {
return (
<div className="dialog">
<div className="dialog-header">{header}</div>
<div className="dialog-body">{children}</div>
<div className="dialog-footer">{footer}</div>
</div>
);
}
function App() {
return (
<Dialog
header={<h2>确认删除</h2>}
footer={
<>
<button>取消</button>
<button>删除</button>
</>
}
>
<p>确定要删除这个项目吗?此操作无法撤销。</p>
</Dialog>
);
}
Slot pattern 的优势:
- 显式结构:组件的结构由调用者决定,而不是被限制
- 类型安全:TypeScript 可以对每个 slot 的内容进行类型检查
- 灵活性:调用者可以传递任何 React 可渲染的内容
2.3 实现可复用的 Slot 容器
function Dialog({ header, children, footer }) {
return (
<div className="dialog">
{header && <div className="dialog-header">{header}</div>}
<div className="dialog-body">{children}</div>
{footer && <div className="dialog-footer">{footer}</div>}
</div>
);
}
// 复用:不同页面使用相同的 Dialog 结构,但内容不同
function DeleteConfirmDialog({ item, onConfirm, onCancel }) {
return (
<Dialog
header={<DialogTitle>确认删除</DialogTitle>}
footer={
<DialogActions>
<Button onClick={onCancel}>取消</Button>
<Button variant="danger" onClick={onConfirm}>删除</Button>
</DialogActions>
}
>
<p>确定要删除 {item.name} 吗?</p>
</Dialog>
);
}
2.4 多 slot 场景:使用对象代替多个 props
当组件有多个 slot 时,使用对象可以保持 API 的整洁:
function Layout({ slots }) {
const { header, sidebar, main, footer } = slots;
return (
<div className="layout">
{header && <header>{header}</header>}
<div className="layout-body">
{sidebar && <aside>{sidebar}</aside>}
<main>{main}</main>
</div>
{footer && <footer>{footer}</footer>}
</div>
);
}
// 使用
<Layout
slots={{
header: <AppHeader />,
sidebar: <Sidebar />,
main: <MainContent />,
footer: <Footer />,
}}
/>
三、特例化(Specialization Pattern)
3.1 什么是特例化
特例化是组合模式的一个重要应用场景:通过 props 控制组件的"种类",而不是通过继承创建新的组件类型。
// 反模式:通过继承创建"特殊按钮"
class PrimaryButton extends Button { /* ... */ }
class SecondaryButton extends Button { /* ... */ }
class DangerButton extends Button { /* ... */ }
// 正确方式:通过 props 实现特例化
function Button({ variant = 'primary', children, ...props }) {
return (
<button className={`btn btn-${variant}`} {...props}>
{children}
</button>
);
}
// 使用
<Button variant="primary">主要按钮</Button>
<Button variant="secondary">次要按钮</Button>
<Button variant="danger">危险操作</Button>
3.2 组合 + 特例化的实际例子
一个典型的移动端页面结构:
// 基础页面组件:定义整体结构
function Page({ header, children, footer }) {
return (
<div className="page">
<PageHeader>{header}</PageHeader>
<PageContent>{children}</PageContent>
<PageFooter>{footer}</PageFooter>
</div>
);
}
// 特例化页面:预设常用的页面类型
function SettingsPage({ title, children }) {
return (
<Page
header={<BackHeader title={title} />}
footer={<BottomSafeArea />}
>
{children}
</Page>
);
}
// 使用
function AppSettings() {
return (
<SettingsPage title="应用设置">
<SettingItem icon={<BellIcon />} label="通知设置" />
<SettingItem icon={<LockIcon />} label="隐私设置" />
<SettingItem icon={<ThemeIcon />} label="主题设置" />
</SettingsPage>
);
}
3.3 特例化的边界
特例化模式有它的适用边界:当"种类"数量有限且行为差异不大时,props 方式更优;当"种类"之间行为差异很大时,可能需要真正的组件。
// 适用特例化的场景:按钮变体
<Button variant="primary" />
<Button variant="secondary" />
<Button variant="danger" />
// 不适用特例化的场景:完全不同行为的组件
// 表单 vs 只读展示 → 应该用两个不同组件
// Dialog vs Drawer → 应该用两个不同组件(行为本身就不同)
四、render props:灵活的行为复用
4.1 render props 的概念
render props 是一种将渲染逻辑作为 props 传递给组件的技术:
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return render(position);
}
// 使用
<MouseTracker
render={({ x, y }) => (
<p>鼠标位置: {x}, {y}</p>
)}
/>
4.2 render props 的实际使用场景
场景一:抽象状态逻辑
function DataFetcher({ url, render }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return render({ data, loading, error });
}
// 使用
<DataFetcher
url="/api/user"
render={({ data, loading, error }) => {
if (loading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <UserProfile user={data} />;
}}
/>
场景二:列表渲染逻辑
function VirtualList({ items, renderItem, estimatedItemSize = 50 }) {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
// 滚动计算省略...
return (
<div className="virtual-list">
{items.slice(visibleRange.start, visibleRange.end).map(renderItem)}
</div>
);
}
// 使用
<VirtualList
items={largeDataSet}
renderItem={(item, index) => (
<ListItem key={item.id} data={item} index={index} />
)}
/>
4.3 render props 的变形:children as function
React 官方支持的一种模式是将 children 作为函数:
function MouseTracker({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
// ...
return children(position);
}
// 使用(更符合 React 的习惯)
<MouseTracker>
{({ x, y }) => <p>鼠标位置: {x}, {y}</p>}
</MouseTracker>
4.4 render props 的优点和局限
优点:
- 状态逻辑与 UI 完全分离
- 调用者完全控制渲染结果
- 可以传递任意 props 给 render 函数
局限:
- 容易产生"嵌套地狱"(多层 render props 嵌套)
- TypeScript 支持不够友好(需要复杂的泛型)
- 对于简单逻辑显得过于复杂
五、HOC vs render props vs custom hooks:取舍
5.1 三种模式的对比
| 维度 | HOC | render props | custom hooks |
|---|---|---|---|
| 数据来源 | 通过 props 注入 | 通过 render prop 回调 | 在 hook 内部自己管理 |
| 组合方式 | 包装(wrapping) | 渲染回调(render callback) | 组合(composition) |
| 命名冲突 | 可能有(需要 mergeProps) | 无 | 无 |
| TypeScript 支持 | 一般 | 复杂 | 良好 |
| 学习曲线 | 较陡 | 中等 | 平缓 |
| 适用场景 | 横切关注点 | 状态抽象 | 逻辑复用 |
5.2 HOC(高阶组件)
HOC 是一个函数,接收一个组件并返回一个新组件:
function withLoading(Component) {
return function WithLoadingComponent({ isLoading, ...props }) {
if (isLoading) return <Skeleton />;
return <Component {...props} />;
};
}
// 使用
const UserListWithLoading = withLoading(UserList);
<UserListWithLoading isLoading={isLoading} users={users} />
HOC 的问题:render hollow problem
HOC 会引入一个额外的组件层级,这可能导致:
// 问题:debug 时组件树多了一层
<withLogger(Connect(UsersPage))>
<Connect(UsersPage)>
<UsersPage>
...
</UsersPage>
</Connect(UsersPage)>
</withLogger(Connect(UsersPage))>
// 问题:React DevTools 中组件名称混乱
5.3 render props 的取舍
render props 的核心优势是调用者完全控制渲染,但代价是逻辑和数据都在 render 函数内部,调用者必须知道如何处理这些数据。
// render props:调用者必须处理所有状态
<DataFetcher
url="/api/users"
render={({ data, loading, error }) => {
if (loading) return <Loading />;
if (error) return <Error error={error} />;
return <UserList users={data} />;
}}
/>
// custom hooks:调用者只需要关注数据
const { data, loading, error } = useDataFetcher('/api/users');
// 调用者决定如何渲染
5.4 custom hooks 的现代选择
随着 React Hooks 的普及,custom hooks 已经成为逻辑复用的首选方式:
// useDataFetcher hook
function useDataFetcher(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
// 使用:简洁、直观
function UsersPage() {
const { data, loading, error } = useDataFetcher('/api/users');
if (loading) return <Skeleton />;
if (error) return <Error error={error} />;
return <UserList users={data} />;
}
custom hooks 的优势:
- 符合 React Hooks 的心智模型
- 没有额外的组件层级
- TypeScript 支持良好
- 可以在多个 hooks 之间组合
5.5 何时用哪种模式
| 场景 | 推荐模式 |
|---|---|
| 跨应用复用 UI 样式/布局 | HOC(如 styled-components) |
| 封装状态逻辑但不控制渲染 | custom hooks |
| 调用者需要完全控制渲染 | render props |
| 简单的逻辑复用 | custom hooks |
| 横切关注点(日志、性能监控) | HOC |
六、高阶组件的 render hollow problem
6.1 什么是 render hollow problem
render hollow problem 指的是 HOC 在组件树中引入了一层"空壳"组件,这层组件除了传递 props 之外没有任何实际 UI:
// 组件树看起来是这样的
<App>
<Router>
<withAuth(ProtectedPage)>
<withTheme(withLogging(ProtectedPage))>
<ProtectedPage />
</withTheme(withLogging(ProtectedPage))>
</withAuth(ProtectedPage)>
</Router>
</App>
6.2 render hollow 带来的问题
问题一:React DevTools 中的可读性
多层 HOC 包装后,DevTools 中的组件名称会变得混乱,难以定位问题:
▼ UsersPage
▼ withAuth
▼ withTheme
▼ withLogging
▼ Connect
▼ UsersPage (actual)
问题二:Refs 无法穿透
const EnhancedComponent = withAuth(MyComponent);
// ref 指向的是 withAuth 返回的组件,而不是 MyComponent
const ref = useRef();
// ref.current 是 withAuth 的实例,不是 MyComponent 的实例
问题三:Context 无法直接穿透
如果 HOC 内部也使用了 Context,需要确保正确的 Context 层次。
6.3 解决方案
方案一:使用 forwardRef + cloneElement 组合
function withAuth(Component) {
return forwardRef(function WithAuthComponent(props, ref) {
const { user } = useAuth();
if (!user) return <LoginPrompt />;
return <Component ref={ref} {...props} />;
});
}
方案二:优先使用 custom hooks
对于大多数场景,custom hooks 可以替代 HOC,同时避免 render hollow 问题:
// 替代 withAuth HOC
function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}
七、面试高频问题
Q: React 中组合优于继承的原因是什么?
回答要点:继承的的问题是子类与父类实现细节紧耦合、行为难以动态组合、层次过深维护困难。React 的组件模型本质上适合组合而非继承——组件通过 props 接收数据和回调函数,通过 children/Slot Pattern 接收 UI 结构,这种方式让组件的边界清晰、依赖显式、组合灵活。继承在 UI 组件库中几乎找不到合理的应用场景。
Q: HOC、render props、custom hooks 三种模式的区别和使用场景?
回答要点:HOC 适合横切关注点(日志、样式、性能监控)和跨应用复用 UI;render props 适合需要调用者完全控制渲染结果的场景;custom hooks 是目前最推荐的逻辑复用方式,因为没有额外组件层级、TypeScript 支持好、符合 Hooks 心智模型。对于简单的逻辑复用,无脑选择 custom hooks;对于需要包装组件本身的场景(如添加 loading 状态、错误边界),HOC 仍然有价值。
Q: 什么是 render hollow problem?如何解决?
回答要点:HOC 在组件树中引入额外的"空壳"层级,除了传递 props 外没有实际 UI,这会导致 React DevTools 可读性差、Refs 无法穿透、Context 层次混乱等问题。解决方案包括:使用 forwardRef 确保 ref 能正确穿透;优先使用 custom hooks 替代 HOC;对于必须使用 HOC 的场景,确保 HOC 使用 displayName 便于调试。
延展阅读
- React 官方文档:组合 vs 继承 — 官方对组合模式的说明
- Kent C. Dodds: Advanced React Patterns — 深入讲解各种组合模式
- Ryan Florence: Mixins Considered Harmful — 为什么 HOC 比 Mixins 更好
- Overreacted: Writing Use Hooks with Confidence — 如何编写可靠的 custom hooks
- React Docs: React Hooks API Reference — Hooks 官方文档