3.5 组件设计模式 本节摘要:组件的设计模式是 React 社区沉淀的「复用与组织」套路。组合模式用 children 搭积木,高阶组件 HOC 包装逻辑,Render Props 用函数共享状态,Context 跨层共享。本节四种模式各给一个能跑的代码例子,再给出选择建议——组合是默认,Hooks 时代 HOC 和 Render Props 的适用面都在收窄。 核心问题 阅读完本节,你应当能够: 用组合模式 + children 搭建可复用容器组件 写一个高阶组件包装横切逻辑 用 Render Props 共享带状态的逻辑 对比四种模式的优劣与适用场景 判断什么时候该用自定义 Hook 替代 HOC 与 Render Props 一、问题与直觉:组件之间的逻辑怎么复用
本节摘要:组件的设计模式是 React 社区沉淀的「复用与组织」套路。组合模式用 children 搭积木,高阶组件 HOC 包装逻辑,Render Props 用函数共享状态,Context 跨层共享。本节四种模式各给一个能跑的代码例子,再给出选择建议——组合是默认,Hooks 时代 HOC 和 Render Props 的适用面都在收窄。
阅读完本节,你应当能够:
写组件写到一定阶段,会遇到两类重复:
第一类是结构重复:卡片、弹窗、列表容器,这些「壳」长得像,内容各不同。第二类是逻辑重复:好几个组件都要「先验证权限再渲染」「追踪鼠标位置」「请求数据并管理三态」。
结构重复用组合解决——把可变的内容留给 children。逻辑重复呢?React 的组件模型里,逻辑跟着组件走,想跨组件复用逻辑,就需要额外的机制。这就是高阶组件和 Render Props 存在的意义。
但要注意一个时代背景:Hooks 出现之前,逻辑复用只有 HOC 和 Render Props 两条路;Hooks 出现之后,自定义 Hook 成了更优解。所以本节讲四种模式,但会明确告诉你:新代码首选组合 + 自定义 Hook,HOC 与 Render Props 主要用于读旧代码和理解历史。
组合模式的核心是「嵌套 + children」:容器组件不关心里面装什么,只负责提供结构。
第 1.4 节的 Card 例子是雏形,这里给一个更完整的版本:
function Card(props) { return <div className="card">{props.children}</div>; } function CardTitle(props) { return <h2 className="card-title">{props.children}</h2>; } function CardBody(props) { return <div className="card-body">{props.children}</div>; } function UserProfileCard({ name, bio }) { return ( <Card> <CardTitle>{name}</CardTitle> <CardBody>{bio}</CardBody> </Card> ); }
组合的价值在于「松耦合」:Card 不知道内容是什么,内容不知道容器长什么样。两者通过 children 这个接口连接,任何一方修改都不影响另一方。
组合为什么是默认?因为它符合 React 的心智模型——组件就是函数,函数最自然的组合方式是嵌套调用。没有额外的封装层、没有魔法,读代码的人一眼能看懂结构。
children 是组合的默认插槽,但有些组件需要多个「命名插槽」——比如卡片有标题区、操作区、内容区,调用方想分别填充。做法是把多个 JSX 作为普通 props 传入:
function Panel({ header, actions, children }) { return ( <div className="panel"> <div className="panel-header">{header}</div> <div className="panel-body">{children}</div> <div className="panel-actions">{actions}</div> </div> ); } // 使用: function App() { return ( <Panel header={<h2>标题</h2>} actions={<button>保存</button>} > <p>内容区</p> </Panel> ); }
命名插槽让「同一个容器、不同部位放不同内容」变得明确。这是组合模式在复杂组件(布局组件、弹窗、表格)里的高频用法——比只有 children 一个通道的表达能力强得多。
组合模式还藏着一个性能彩蛋:children 是惰性求值的。 父组件重渲染时,传给子组件的 children 表达式只有在子组件真正渲染时才执行。这带来一个优化思路:把「不常变的子树」作为 children 传给 memo 的子组件,父组件重渲染时 children 引用不变,子组件的 memo 就能生效。这是「用组合优化渲染」的经典技巧,和第 3.1 节记忆化互为表里。
高阶组件是「接收组件、返回新组件」的函数。它把横切逻辑(鉴权、数据获取、打点)包装在组件外面,让被包装组件只关心自己的渲染。
function withAuthentication(WrappedComponent) { return class WithAuthentication extends React.Component { constructor(props) { super(props); this.state = { isAuthenticated: false }; } componentDidMount() { // 模拟异步鉴权 setTimeout(() => { this.setState({ isAuthenticated: true }); }, 1000); } render() { if (!this.state.isAuthenticated) { return <div>正在验证登录...</div>; } return <WrappedComponent {...this.props} />; } }; } function Profile() { return <h1>个人中心(受保护内容)</h1>; } const ProtectedProfile = withAuthentication(Profile);
withAuthentication 接收 Profile,返回一个带鉴权逻辑的新组件。Profile 完全不知道鉴权这回事——它只负责渲染内容,鉴权逻辑被 HOC 包在外部。
HOC 渲染被包装组件时,要把自己收到的 props 原样透传:
return <WrappedComponent {...this.props} />;
如果不透传,使用方传给 HOC 的所有 props(比如 onClick、data)都会丢失,被包装组件拿不到任何外部数据。这是 HOC 最常见的 bug 来源——包装了逻辑,丢了数据。任何 HOC 都要记得透传 props,这是写 HOC 的第一原则。
HOC 的问题在组合多个时暴露:多个 HOC 叠加会产生「包装器地狱」——一层套一层,调试时组件树里全是 HOC 生成的匿名组件名,难以定位。此外,HOC 有 props 命名冲突风险:两个 HOC 都注入同名的 props,后注入的会覆盖前者。
Hooks 时代 HOC 的适用面明显收窄,但有几类场景仍在用:需要配合类组件复用的存量逻辑、库作者提供「组件形态」的 API(比如 styled-components 的 styled.div)、声明式地给组件注入 props。维护老项目、读第三方库源码时,HOC 是必须认识的能力。
Render Props 是「把一个函数作为 prop,在组件内部调用它并传入状态」的模式。它的思路是:组件的渲染内容不写死,由调用方通过函数定制。
class MouseTracker extends React.Component { constructor(props) { super(props); this.state = { x: 0, y: 0 }; } handleMouseMove = (event) => { this.setState({ x: event.clientX, y: event.clientY }); }; render() { return ( <div onMouseMove={this.handleMouseMove}> {this.props.render(this.state)} </div> ); } } function App() { return ( <MouseTracker render={({ x, y }) => ( <h1>鼠标位置:({x}, {y})</h1> )} /> ); }
MouseTracker 管「追踪鼠标位置」这个逻辑,渲染什么由调用方通过 render prop 决定。逻辑复用、UI 自由——这是 Render Props 的核心卖点。它避免了 HOC 的包装器地狱,因为它是通过 JSX 嵌套组合的,结构透明。
Render Props 的缺点也很直接:回调嵌套。多个 Render Props 组合时,JSX 会嵌套得很深,可读性下降。
render 是约定俗成的 prop 名,但不是唯一的。React 生态里出现过几种变体:
children 即函数:不用名为 render 的 prop,直接让 children 是一个函数:
function MouseTracker({ children }) { // ...追踪逻辑 return children({ x, y }); } // 使用: <MouseTracker> {({ x, y }) => <p>位置 ({x}, {y})</p>} </MouseTracker>
children 可以是 React 节点:如果 children 不是函数,就原样渲染,组件同时支持「函数模式」和「普通模式」。这个「双模式」是很多库作者用过的技巧——函数进来自定义渲染,普通内容直接透传。
函数即 prop 的命名自由:React Router 第 4 版的 render prop、Context 的 Consumer 函数子节点,本质都是 Render Props 的不同名字。认出「一个函数被当作 prop 传进组件并接收状态」,你就认出了 Render Props 家族。
自定义 Hook 能表达同样的事,且更干净:
function useMousePosition() { const [position, setPosition] = useState({ x: 0, y: 0 }); const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY }); // 用 useEffect 绑定 mousemove return position; } function App() { const { x, y } = useMousePosition(); return <h1>鼠标位置:({x}, {y})</h1>; }
同样的逻辑,Hook 版本没有嵌套、没有 render 回调、逻辑可以再组合。这就是为什么官方明确推荐自定义 Hook 作为逻辑复用的首选。
第 2.5 节已经完整讲过 Context。在组件设计模式语境下,Context 是「跨层状态共享」的组件化方案——Provider 提供、Consumer 消费,中间层零参与。
const ThemeContext = React.createContext('light'); function ThemeProvider({ theme, children }) { return ( <ThemeContext.Provider value={theme}> {children} </ThemeContext.Provider> ); } function ThemedButton({ children }) { return ( <ThemeContext.Consumer> {theme => ( <button className={`btn-${theme}`}>{children}</button> )} </ThemeContext.Consumer> ); } function App() { return ( <ThemeProvider theme="dark"> <ThemedButton>点击</ThemedButton> </ThemeProvider> ); }
Context 和前面三种模式的关系是互补的:组合解决结构复用,HOC/Render Props 解决逻辑复用,Context 解决跨层数据共享。四者合起来,覆盖了组件设计的大部分复用需求。

| 模式 | 解决什么 | 成本 | 现状 |
|---|---|---|---|
| 组合 | 结构复用 | 低 | 默认首选 |
| 高阶组件 HOC | 逻辑复用(包装) | 中,有包装器地狱 | 收窄,存量在用 |
| Render Props | 逻辑复用(共享) | 中,嵌套深 | 收窄,被 Hook 取代 |
| Context | 跨层数据共享 | 中,订阅全体重渲染 | 常用 |
| 自定义 Hook | 逻辑复用 | 低 | 官方推荐首选 |
💡 关键直觉:把四种模式想成「复用工具包」——组合管结构、Hook 管逻辑、Context 管数据。HOC 和 Render Props 是历史功臣,新代码里遇到「要复用逻辑」,先想自定义 Hook,想不通再回头看它们。
⚠️ 常见坑:把 HOC 和 Render Props 当「标准答案」硬套。它们诞生于 Hooks 之前,被 Hooks 取代是有原因的——自定义 Hook 更简洁、无嵌套、易组合。新技术不用旧方案,除非在维护旧代码。
写组件时,按这个顺序问自己:
这个判断顺序把「先想模式再写代码」变成「先看问题再选模式」,能避免为了用模式而用模式的过度设计。模式是解决问题的工具,不是炫技的资本——这是组件设计里最重要的一条心法。
把本节模式综合到一个「可复用弹窗」上,看模式怎么配合:
// 容器组件:管弹窗结构 function Modal({ open, onClose, title, children, footer }) { if (!open) return null; return ( <div className="modal-mask" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()}> <div className="modal-header"> {title} <button onClick={onClose}>关闭</button> </div> <div className="modal-body">{children}</div> {footer && <div className="modal-footer">{footer}</div>} </div> </div> ); } // 使用:结构交给组合,内容由调用方填充 function ConfirmDialog({ open, onConfirm, onClose, message }) { return ( <Modal open={open} onClose={onClose} title="确认操作" footer={<button onClick={onConfirm}>确认</button>} > <p>{message}</p> </Modal> ); }
Modal 是纯容器——不知道内容、不知道 footer 里是什么;ConfirmDialog 用命名插槽填标题和底部按钮,用 children 填正文。结构复用在 Modal 层完成,业务差异在 ConfirmDialog 层表达。两个组件各自单一职责,改 Modal 不影响 ConfirmDialog,改 ConfirmDialog 不需要碰 Modal。这个例子体现了组合模式在真实组件库中的样子,也是组件设计「小积木拼大积木」思想的直接落地。
「自定义 Hook 一定能替代 Render Props 吗?」 大多数场景能,但不是百分之百。Render Props 有个 Hook 给不了的能力:它出现在 JSX 里,天然携带渲染上下文,可以在 JSX 的任何位置用不同的 render 函数调用同一个「逻辑提供者」。而 Hook 只能在函数组件顶层调用,位置受限。如果你写的是一个「库级 API」——使用者可能用函数组件也可能用类组件,Render Props 的兼容面更广。自己的业务代码里,这个差异基本用不上,Hook 优先即可。
「HOC 还在哪些真实项目里活着?」 比你想象的多。styled-components 的 styled(Button) 是 HOC;react-redux 老版本里的 connect 是 HOC;next.js 早期的 withRouter 是 HOC;大量第三方库为了兼容类组件而保留了 HOC 形态的 API。你读开源代码时,「接收组件返回组件」的函数几乎就是 HOC。所以本节专门讲透透传 props 和命名冲突,是为了让你在接这些库、改这些代码时不懵。
「组合模式和 HOC 的边界在哪?」 一句话:组合是「在 JSX 里嵌套」,HOC 是「在函数返回里包装」。组合发生在渲染阶段,结构可见、调试直观;HOC 发生在组件定义阶段,注入逻辑、结构隐藏在包装器里。能用组合表达的优先组合,因为它的心智成本最低。只有「横切逻辑」——多个互不相干的组件都需要同一段前置处理——才值得考虑 HOC,因为它能把这段逻辑从各组件里抽出来集中管理。这条边界画清楚了,就不会「为了 HOC 而 HOC」。
四种模式各有隐形成本,选型时除了「能不能解决」,还要看「代价值不值」。组合的代价是「容器组件变多」,每个布局容器都是一层抽象;HOC 的代价是「调试时组件树里多一层匿名包装」,多个 HOC 叠加时定位问题要靠「组件显示名」——生产上有一个小技巧:给 HOC 包装器设置 displayName,把「被包组件名 + HOC 名」拼起来,DevTools 里就能看清包装链;Render Props 的代价是「回调嵌套变深」,两层以上就要考虑拆组件;Context 的代价是「订阅面的全体重渲染」,值变化波及面最大。把这张「代价表」和前面的「功能表」放一起看,选择就不再是「哪个酷」,而是「哪个代价我能接受」。这也是为什么第 3.5 节反复强调:模式是工具,工具要趁手,不是要集齐。
下一节写测试——代码写出来了,怎么证明它是对的。单元、集成、E2E、快照四类测试,配 Jest 与 React Testing Library,把「我测过没问题」变成「可验证的断言」。测试不是可选项,是让组件可以放心重构的安全网。