React 展示组件与容器组件分离模式:用 Presentational / Container 拆分职责(react-bits 实战解读)

原创2026-09-20 13:39:051,352 阅读
文章标签:前端教程

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):知道数据的形状与来源

容器组件的职责,文档概括为三句话:

  1. 容器知道数据、数据的形状以及数据从哪来(know about data, it's shape and where it comes from);
  2. 容器知道事情如何运转,即所谓的业务逻辑(business logic);
  3. 容器的 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。

落地建议与适用边界

基于文档与仓库源码,给出几条可操作的落地准则:

  1. 目录约定:展示组件与容器同目录成对存放(如 Clock/index.js + Clock/Clock.jsx),容器默认导出,外部只 import 容器;
  2. 展示组件守则:无内部 state、无副作用、不依赖具体数据源,只依赖 props;格式补零这类"呈现相关"的纯函数可以留在展示层;
  3. 容器守则:集中处理数据获取、生命周期、业务逻辑,render 中尽量只有"渲染展示组件"这一行实质代码;
  4. 优先用 HOC 生成容器:把"取数 + 格式化 + 注入"封装进可复用工厂,换取展示层的可替换性(数字钟 ↔ 模拟钟);
  5. 与状态管理方案配合:容器可以订阅 store(参考单向数据流与 Flux 模式),展示组件保持哑视图,复杂度收敛于单一数据层。

适用边界:该模式的核心收益是"关注点分离 + 展示可复用"。对于极简单的、数据流只在本组件内闭合的一次性 UI,强行拆分反而增加文件数与间接层——模式的价值随组件复杂度与复用需求增长,不必为两三行代码的组件机械套用。

小结

容器/展示分离是 React 组件设计中最基础也最具杠杆作用的模式之一:容器回答"数据是什么、从哪来、怎么变",展示组件回答"数据长什么样、怎么摆"。react-bits 在 patterns/8.presentational-vs-container.md 中用一个时钟示例把两者从耦合到解耦完整演示了一遍,并借高阶组件把容器升级为"可注入任意渲染器的工厂"。沿着这一思路继续阅读仓库中的 单向数据流、依赖注入 与 UX 变体 各篇,你将看到这一模式如何在更大规模的应用架构中持续发挥作用。

登录后查看全文
react-bits