mCell
11 / 14

批处理(Batching):React 内部的性能自动挡

从“我连续 setState 了好几次,为什么只渲染一次/为什么读到的还是旧值”这些高频困惑出发,讲清 React 批处理的本质:把多次更新合并成一次渲染;对比 React 18 前后自动批处理覆盖范围;解释更新队列、渲染快照与函数式更新的关系,并给出可控地“合并/拆分更新”的实践建议

你一定写过类似代码:

  • 在一次点击里连续 setState 多次
  • 你以为会渲染很多次
  • 结果只渲染了一次

你也一定遇到过另一类困惑:

  • setState 后立刻 console.log(state),还是旧值
  • 明明更新了,为什么 UI 不立刻变化?

很多“React 很玄学”的体验,背后其实都指向同一个机制:批处理(Batching)
它是 React 性能体系里非常关键的一环,也是你建立正确心智模型的分水岭。

这一篇我们会把它讲到能落地推理的程度:你能解释“为什么只渲染一次”、能预测“哪些场景会批”、以及在极少数情况下怎么“打破批”。


0. 一句话定义:批处理是什么?

批处理(Batching)就是:

把同一个“时间窗口”内触发的多次状态更新合并起来,只做一次渲染与提交。

它带来的直接收益是:

  • 少算几次 render(计算)
  • 少做几次 diff
  • 少提交几次 DOM(可能最贵)

批处理不是“让 setState 变慢”,而是让 React 更聪明地安排工作:尽量一次干完


1. 痛点 1:为什么我 setState 两次,只加了 1?

先看经典陷阱:

jsx
function Counter() { const [count, setCount] = React.useState(0) const onClick = () => { setCount(count + 1) setCount(count + 1) } return <button onClick={onClick}>{count}</button> }

很多人的预期是:点击一次加 2。 结果:只加 1。

1.1 根因不是“批处理”,而是“渲染快照 + 更新队列”

关键点:

  • 这次点击触发时,count当前这次渲染的快照
  • 两次 setCount(count + 1) 都把 同一个结果(count+1) 放进更新队列
  • React 最终只会得到“把 count 设为 1”的意图(重复设同值)

此时正确写法是函数式更新:

jsx
setCount((c) => c + 1) setCount((c) => c + 1)

这次就会 +2,因为每个更新都基于“队列里最新的 state”计算下一步。

批处理决定“渲染几次”,函数式更新决定“多次更新如何累积”。


2. 痛点 2:为什么 setState 之后立刻读 state 还是旧值?

jsx
setCount((c) => c + 1) console.log(count) // 还是旧的

这不是 React “没更新”,而是:

  • 你在同一次渲染里读到的 count 是固定快照
  • setCount 只是“提交更新请求”
  • 新的 count 会在 下一次渲染 才出现

所以正确的思维不是“setState 立刻改变变量”,而是:

setState 申请下一次渲染的状态;当前渲染里的变量不会变。


3. 批处理到底合并了什么?——合并的是“渲染与提交”,不是“更新意图”

你可以把一次交互里发生的事情拆开:

  1. 你触发很多次 setState(产生很多“更新意图”)
  2. React 把这些更新意图放入队列(update queue)
  3. React 选择一个时机把队列里的更新“统一计算”
  4. 只做一次 render + commit

因此:

  • 你的更新意图不会丢(除非你自己覆盖了它,比如两次 set 同一个值)
  • React 只是把它们 集中处理

4. React 18 前后:批处理覆盖范围的变化(最容易被忽略)

在 React 18 之前,React 的批处理主要发生在:

  • React 管控的事件回调里(例如 onClick/onChange)

但在一些异步场景(如 setTimeout、Promise)中,批处理可能不一致,导致:

  • 你在同一个异步回调里 setState 多次
  • 可能触发多次渲染

React 18 引入了更广泛的 自动批处理(Automatic Batching) 概念(你可以把它理解为:更多场景会自动合并渲染)。

你不需要死记“哪些场景一定批”,但需要记住趋势:

React 18 以后,批处理更“积极”,在更多异步边界内也会合并更新。

对开发者意味着:

  • 渲染次数更少(通常更快)
  • 但你“立刻读取到更新后 UI”的直觉更不可靠(本来就不该依赖)

5. 更新队列是什么?理解它你就理解了批处理的一半

你可以把 state 看成一条流水线:

  • 每次 setState 往队列里塞一个“更新”
  • React 在渲染阶段,把队列里的更新按顺序应用到旧 state 上,得到新 state

两类更新写法:

5.1 直接值更新:setState(next)

它不关心队列里当前 state 是啥,直接覆盖成 next。

5.2 函数式更新:setState(prev => next)

它会在应用到队列时,拿到“当时最新的 prev”,逐个累积。

这就是为什么在同一时间窗口多次更新时:

  • 函数式更新更稳、更可叠加
  • 值更新更像“最后一次覆盖前面的意图”

6. 批处理对你写代码的实际影响:三个常见模式

6.1 模式 A:一次交互更新多个 state(推荐函数式或合并成 reducer)

jsx
setX((x) => x + 1) setY((y) => y + 1)

如果 x/y 之间有强关系,考虑:

  • useReducer 把它们合并成一个状态机更新(更可预测)

6.2 模式 B:根据旧 state 推导新 state(永远用函数式)

jsx
setList((prev) => prev.concat(item)) setMap((prev) => ({ ...prev, [k]: v }))

这不仅是为了避免旧值,也是为了在批处理/并发下语义稳定。

6.3 模式 C:需要在“更新提交后”做事(不要在 setState 后立刻读)

例如你想在 DOM 更新后聚焦 input:

  • 这不是批处理的问题,是时序问题
  • 正确做法是 useEffectuseLayoutEffect(视需求)

7. 批处理与并发:为什么批处理是并发渲染的“地基”

当 React 有了 Fiber 与调度能力后,它更希望:

  • 把多次更新合并
  • 选择合适的时间渲染
  • 甚至根据优先级调整顺序

批处理让更新从“立即执行”变成“可调度的队列”,这正是并发能力的基础之一。

换句话说:

批处理让 React 有机会当“项目经理”,而不是被你每次 setState 牵着跑。


8. 什么时候你反而需要“拆开批处理”?(少数高阶场景)

大多数时候,你应该享受批处理。 但确实有少数场景,你可能需要:

  • 强制让某次更新立刻提交(例如为了测量 DOM、同步某个外部系统)
  • 或者把两类更新拆成两个独立提交(例如先展示 loading,再渲染重内容)

这类需求本质上是在问:

我需要一个“确定的提交边界”。

实现方式会随着 React 版本与渲染模式有所不同;工程上更常见的替代方案是:

  • 把“先显示的状态”放到更高优先级的路径(例如更靠近交互的局部 state)
  • 或拆分 UI:先显示轻量骨架,再渐进渲染重区域
  • 或通过 effect 来保证“提交后执行”的时序

原则:先从 UI 结构与状态粒度解决,最后才考虑强制刷新。


9. 一个实战建议:把“渲染次数”当指标,把“提交成本”当目标

优化时别只盯 render 次数,因为:

  • render 可能很便宜
  • commit 与浏览器布局绘制更贵

批处理主要减少“渲染 + 提交的次数”,而你在工程上真正要做的是:

  • 缩小 commit 范围(拆组件、局部 state、memo)
  • 降低单次提交成本(虚拟列表、减少 DOM 复杂度)
  • 让重工作可被调度(time slicing、延后非关键更新)

10. 本文小结:你应当形成的批处理心智模型

  1. 批处理合并的是 渲染与提交次数,不是丢更新
  2. 同一时间窗口多次更新,函数式更新保证可叠加
  3. “setState 后读到旧值”是快照模型,和批处理相辅相成
  4. React 18 的自动批处理覆盖更广,渲染更少但时序更需要用 effect 推理
  5. 绝大多数时候别打破批处理:先优化状态粒度与 UI 结构

留言讨论

Discussion

欢迎交流与反馈