React 展示组件与容器组件分离模式:用 Presentational / Container 拆分职责(react-bits 实战解读)
React 展示组件与容器组件分离模式:用 Presentational / Container 拆分职责(react-bits 实战解读)
导读
本篇基于 react-bits 仓库的 patterns/8.presentational-vs-container.md 展开,系统讲解 React 中经典的展示组件(Presentational)与容器组件(Container)分离模式:先剖析"数据与逻辑混杂"的组件病态,再演示如何把一个组件拆成"管数据"与"管外观"两部分,最后用高阶组件(HOC)把容器改造成可注入任意渲染器的灵活工厂。读完你不仅能掌握这种组件职责切分的落地写法,还能理解它与本仓库中单向数据流、依赖注入、UX 变体等模式的联动关系。
问题:数据与逻辑耦合在一起的组件
当一个组件同时负责"数据从哪来、如何变化"与"数据长什么样"时,它就患上了职责混杂症。文档开篇给出了一个典型的 Clock 时钟组件:
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {time: this.props.time};
this._update = this._updateTime.bind(this);
}
render() {
var time = this._formatTime(this.state.time);
return (
<h1>{ time.hours } : { time.minutes } : { time.seconds }</h1>
);
}
componentDidMount() {
this._interval = setInterval(this._update, 1000);
}
componentWillUnmount() {
clearInterval(this._interval);
}
_formatTime(time) {
var [ hours, minutes, seconds ] = [
time.getHours(),
time.getMinutes(),
time.getSeconds()
].map(num => num < 10 ? '0' + num : num);
return {hours, minutes, seconds};
}
_updateTime() {
this.setState({time: new Date(this.state.time.getTime() + 1000)});
}
}
ReactDOM.render(<Clock time={ new Date() }/>, ...);
这段代码把三类职责揉在了一起:
- 数据获取与生命周期:
constructor初始化 state、componentDidMount启动setInterval、componentWillUnmount清理定时器; - 业务逻辑:
_updateTime每秒推进时间、_formatTime把Date拆成时分秒并补零; - UI 渲染:render 中把格式化后的时分秒拼成
<h1>标题。
一旦业务要求"从数字时钟换成模拟表盘",或"把时钟数据源从本地定时器换成服务器推送",你都必须在这个组件内部大动干戈,而且所有关心时间的页面都会跟着受影响。这正是文档提出的 Problem:Data and logic together。
解决方案:把一个组件拆成两个
文档给出的 Solution 非常干脆:把组件拆成两部分——container(容器) 负责数据,presentational(展示) 负责外观。
拆分后的目录结构可以是:
Clock/
├── index.js # 默认导出容器组件
└── Clock.jsx # 展示组件
容器组件(Container):知道数据的形状与来源
容器组件的职责,文档概括为三句话:
- 容器知道数据、数据的形状以及数据从哪来(know about data, it's shape and where it comes from);
- 容器知道事情如何运转,即所谓的业务逻辑(business logic);
- 容器的 render 里只渲染展示组件(Their render method contains only the presentational component)。
容器接收原始信息,先加工成便于展示组件直接使用的格式,再注入过去。文档给出的实现:
// Clock/index.js
import Clock from './Clock.jsx'; // <-- that's the presentational component
export default class ClockContainer extends React.Component {
constructor(props) {
super(props);
this.state = {time: props.time};
this._update = this._updateTime.bind(this);
}
render() {
return <Clock { ...this._extract(this.state.time) }/>;
}
componentDidMount() {
this._interval = setInterval(this._update, 1000);
}
componentWillUnmount() {
clearInterval(this._interval);
}
_extract(time) {
return {
hours: time.getHours(),
minutes: time.getMinutes(),
seconds: time.getSeconds()
};
}
_updateTime() {
this.setState({time: new Date(this.state.time.getTime() + 1000)});
}
};
注意对照拆分前的变化:
- 定时器的启停、
Date的推进、以及把Date拆成{hours, minutes, seconds}的_extract全部留在容器里; - render 只做一件事:
return <Clock {...this._extract(this.state.time)}/>,把加工好的数据以 props 形式喷洒给展示组件; - 容器不再关心展示成
<h1>还是表盘,它只负责"喂数据"。
文档还特别强调:"Very often we use higher-order components to create containers"——实际项目中容器往往不是手写类,而是通过高阶组件生成的,这一点下文会展开。
展示组件(Presentational):只关心东西长什么样
展示组件只关心 how the things look(外观),文档给出三条特征:
- 包含让页面变好看所需的额外标记(additional markup);
- 不被任何东西绑定、没有任何依赖(not bound to anything and have no dependencies);
- 常常实现为无状态函数式组件(stateless functional components),没有内部 state。
它的实现极其朴素:
// Clock/Clock.jsx
export default function Clock(props) {
var [ hours, minutes, seconds ] = [
props.hours,
props.minutes,
props.seconds
].map(num => num < 10 ? '0' + num : num);
return <h1>{ hours } : { minutes } : { seconds }</h1>;
};
展示组件只做两件事:接收 props、渲染。连"补零"这类纯展示性格式处理都放在了展示层——它仍然是"如何呈现"的一部分。这样一个组件:
- 可以独立于任何数据源测试(喂不同 props 断言不同输出);
- 可以在多个容器之间复用;
- 不持有状态,天然符合 perf-tips/02.pure-component.md 中"用纯组件换取性能"的思路;
- 与 styling/01.stateless-ui-components.md 强调的"无状态 UI 组件更易于样式管理与复用"互为印证。
用高阶组件让容器更灵活:从数字钟到模拟钟
文档点出容器的最大红利:容器封装了逻辑,可以把数据注入不同的渲染器(containers encapsulate logic and may inject data into different renderers)。
但手写的 ClockContainer 还只是硬编码了 Clock 这一个渲染器。文档指出:"Very often a file that exports a container is not sending out a class directly but a function"——容器文件导出的往往不是一个类,而是一个函数。
与其这样写:
import Clock from './Clock.jsx';
export default class ClockContainer extends React.Component {
render() {
return <Clock />;
}
}
不如导出一个接收展示组件并返回容器类的函数:
export default function (Component) {
return class Container extends React.Component {
render() {
return <Component />;
}
}
}
用这种技巧,容器在渲染结果上变得极其灵活——如果想从数字时钟切换到模拟时钟,只需要把不同的组件传入这个工厂函数即可,容器内部的定时、取数、格式化逻辑一字不改。
这个"函数返回类"的形态正是高阶组件(HOC)的标准写法,与仓库中其他文档一脉相承:
- patterns/3.dependency-injection.md 中
inject(Component)返回Injector类,通过{...this.state} {...this.props}向目标组件注入依赖——和这里的容器工厂结构完全相同,只是注入物换成了业务数据; - ux-variations/04.HOC-props-proxy.md 展示的 props 代理 HOC 用
<WrappedComponent {...this.props} {...newProps} />增改 props,也是同一套"外层接管、内层渲染"的模式; - ux-variations/03.HOC-feature-toggles.md 的
toggleOn(featureName, ComposedComponent)甚至把渲染与否都交给了外层函数。
交叉印证:容器/展示分离与仓库其他模式的协同
容器与展示的拆分并非孤立的技巧,它在 react-bits 的整体设计图谱里占据枢纽位置:
与单向数据流的配合。patterns/7.one-way-data-flow.md 指出,让组件订阅 Store、由 Store 单向驱动更新后,"我们的组件会变成 Store 数据的哑表示(dummy representation)","把 React 组件当作视图(renderers)来思考"。这正是容器/展示分离的终极形态:容器订阅 store 取数并注入,展示组件退化为纯视图,复杂度被收敛到唯一的地方(the complexity in only one place)。文档也提醒,示例里用 forceUpdate 只是为了让例子简单,真实项目"通常用高阶组件来触发重渲染"。
与依赖注入的关系。当数据需要在深层组件树中传递时,容器可以通过 HOC 或 context 完成注入。patterns/3.dependency-injection.md 的 inject HOC 就是"容器生成器"的典型实例,展示组件因此可以完全不关心数据来源。
与性能优化的呼应。展示组件无状态、纯 props 驱动,天然契合 perf-tips/01.shouldComponentUpdate-check.md 与 perf-tips/02.pure-component.md 的浅比较优化前提;反过来,容器持有 state 与逻辑,是 shouldComponentUpdate 收益最大的改造对象。
与 UX 变体的协作。切换"数字钟/模拟钟"本质就是一种展示层变体(variation)。仓库 ux-variations/01.composing-variations.md 与 ux-variations/06.display-order-variations.md 讨论的组合与展示顺序变化,都可以建立在"容器只管数据、展示层可任意替换"的基础上。
反模式对照。容器把 props.time 存入 this.state 的做法,需要与 anti-patterns/01.props-in-initial-state.md 中的"props 进初始 state"反模式区分开:这里 time 是时钟的初始值,之后由定时器持续驱动 state 演化,属于"以 props 作 seed、由组件自治"的合法场景;而把 props 作为唯一真相源时则不应复制到 state。
落地建议与适用边界
基于文档与仓库源码,给出几条可操作的落地准则:
- 目录约定:展示组件与容器同目录成对存放(如
Clock/index.js+Clock/Clock.jsx),容器默认导出,外部只 import 容器; - 展示组件守则:无内部 state、无副作用、不依赖具体数据源,只依赖 props;格式补零这类"呈现相关"的纯函数可以留在展示层;
- 容器守则:集中处理数据获取、生命周期、业务逻辑,render 中尽量只有"渲染展示组件"这一行实质代码;
- 优先用 HOC 生成容器:把"取数 + 格式化 + 注入"封装进可复用工厂,换取展示层的可替换性(数字钟 ↔ 模拟钟);
- 与状态管理方案配合:容器可以订阅 store(参考单向数据流与 Flux 模式),展示组件保持哑视图,复杂度收敛于单一数据层。
适用边界:该模式的核心收益是"关注点分离 + 展示可复用"。对于极简单的、数据流只在本组件内闭合的一次性 UI,强行拆分反而增加文件数与间接层——模式的价值随组件复杂度与复用需求增长,不必为两三行代码的组件机械套用。
小结
容器/展示分离是 React 组件设计中最基础也最具杠杆作用的模式之一:容器回答"数据是什么、从哪来、怎么变",展示组件回答"数据长什么样、怎么摆"。react-bits 在 patterns/8.presentational-vs-container.md 中用一个时钟示例把两者从耦合到解耦完整演示了一遍,并借高阶组件把容器升级为"可注入任意渲染器的工厂"。沿着这一思路继续阅读仓库中的 单向数据流、依赖注入 与 UX 变体 各篇,你将看到这一模式如何在更大规模的应用架构中持续发挥作用。