3.5 组件设计模式


文档摘要

3.5 组件设计模式 本节摘要:组件的设计模式是 React 社区沉淀的「复用与组织」套路。组合模式用 children 搭积木,高阶组件 HOC 包装逻辑,Render Props 用函数共享状态,Context 跨层共享。本节四种模式各给一个能跑的代码例子,再给出选择建议——组合是默认,Hooks 时代 HOC 和 Render Props 的适用面都在收窄。 核心问题 阅读完本节,你应当能够: 用组合模式 + children 搭建可复用容器组件 写一个高阶组件包装横切逻辑 用 Render Props 共享带状态的逻辑 对比四种模式的优劣与适用场景 判断什么时候该用自定义 Hook 替代 HOC 与 Render Props 一、问题与直觉:组件之间的逻辑怎么复用

3.5 组件设计模式

本节摘要:组件的设计模式是 React 社区沉淀的「复用与组织」套路。组合模式用 children 搭积木,高阶组件 HOC 包装逻辑,Render Props 用函数共享状态,Context 跨层共享。本节四种模式各给一个能跑的代码例子,再给出选择建议——组合是默认,Hooks 时代 HOC 和 Render Props 的适用面都在收窄。

核心问题

阅读完本节,你应当能够:

  1. 用组合模式 + children 搭建可复用容器组件
  2. 写一个高阶组件包装横切逻辑
  3. 用 Render Props 共享带状态的逻辑
  4. 对比四种模式的优劣与适用场景
  5. 判断什么时候该用自定义 Hook 替代 HOC 与 Render Props

一、问题与直觉:组件之间的逻辑怎么复用

写组件写到一定阶段,会遇到两类重复:

第一类是结构重复:卡片、弹窗、列表容器,这些「壳」长得像,内容各不同。第二类是逻辑重复:好几个组件都要「先验证权限再渲染」「追踪鼠标位置」「请求数据并管理三态」。

结构重复用组合解决——把可变的内容留给 children。逻辑重复呢?React 的组件模型里,逻辑跟着组件走,想跨组件复用逻辑,就需要额外的机制。这就是高阶组件和 Render Props 存在的意义。

但要注意一个时代背景:Hooks 出现之前,逻辑复用只有 HOC 和 Render Props 两条路;Hooks 出现之后,自定义 Hook 成了更优解。所以本节讲四种模式,但会明确告诉你:新代码首选组合 + 自定义 Hook,HOC 与 Render Props 主要用于读旧代码和理解历史。

二、组合模式:React 的默认设计

组合模式的核心是「嵌套 + 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 表达式只有在子组件真正渲染时才执行。这带来一个优化思路:把「不常变的子树」作为 children 传给 memo 的子组件,父组件重渲染时 children 引用不变,子组件的 memo 就能生效。这是「用组合优化渲染」的经典技巧,和第 3.1 节记忆化互为表里。

三、高阶组件 HOC:给组件加包装

高阶组件是「接收组件、返回新组件」的函数。它把横切逻辑(鉴权、数据获取、打点)包装在组件外面,让被包装组件只关心自己的渲染。

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 生成的匿名组件名,难以定位。此外,HOC 有 props 命名冲突风险:两个 HOC 都注入同名的 props,后注入的会覆盖前者。

何时还需要 HOC

Hooks 时代 HOC 的适用面明显收窄,但有几类场景仍在用:需要配合类组件复用的存量逻辑库作者提供「组件形态」的 API(比如 styled-components 的 styled.div)、声明式地给组件注入 props。维护老项目、读第三方库源码时,HOC 是必须认识的能力。

四、Render Props:函数共享状态

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 Props 的几种变体

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 家族。

Hooks 如何取代它

自定义 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 作为逻辑复用的首选。

五、Context 模式:跨层共享

第 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 更简洁、无嵌套、易组合。新技术不用旧方案,除非在维护旧代码。

七、工程实践:模式选择的判断顺序

写组件时,按这个顺序问自己:

  1. 是结构重复吗? 是 → 组合模式(children)
  2. 是逻辑重复吗? 是 → 自定义 Hook;遇到类组件再考虑 HOC/Render Props
  3. 是跨层数据吗? 是 → Context + 自定义消费 Hook
  4. 都不是? → 可能根本不需要模式,直接写

这个判断顺序把「先想模式再写代码」变成「先看问题再选模式」,能避免为了用模式而用模式的过度设计。模式是解决问题的工具,不是炫技的资本——这是组件设计里最重要的一条心法。

一个组合模式贯穿的实战例子

把本节模式综合到一个「可复用弹窗」上,看模式怎么配合:

// 容器组件:管弹窗结构 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 节反复强调:模式是工具,工具要趁手,不是要集齐。

  • 组合模式:children 搭积木,结构复用,React 默认首选
  • HOC:接收组件返回新组件,包装横切逻辑,有包装器地狱与 props 冲突
  • Render Props:函数 prop 共享状态,逻辑复用 UI 自由,嵌套深
  • Context:Provider/Consumer 跨层共享数据
  • 自定义 Hook:逻辑复用的官方推荐方案,取代了大部分 HOC/Render Props
  • 判断顺序:结构→组合,逻辑→Hook,跨层→Context
  • 避免过度设计:模式是工具,先看问题再选模式
  • 插槽变体:命名插槽(header/actions 作为 props)适合布局组件
  • HOC 透传:任何 HOC 都要 {...this.props} 透传,否则 props 丢失

下一节写测试——代码写出来了,怎么证明它是对的。单元、集成、E2E、快照四类测试,配 Jest 与 React Testing Library,把「我测过没问题」变成「可验证的断言」。测试不是可选项,是让组件可以放心重构的安全网。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U