批处理(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?
先看经典陷阱:
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”的意图(重复设同值)
此时正确写法是函数式更新:
setCount((c) => c + 1)
setCount((c) => c + 1)
这次就会 +2,因为每个更新都基于“队列里最新的 state”计算下一步。
批处理决定“渲染几次”,函数式更新决定“多次更新如何累积”。
2. 痛点 2:为什么 setState 之后立刻读 state 还是旧值?
setCount((c) => c + 1)
console.log(count) // 还是旧的
这不是 React “没更新”,而是:
- 你在同一次渲染里读到的
count是固定快照 setCount只是“提交更新请求”- 新的
count会在 下一次渲染 才出现
所以正确的思维不是“setState 立刻改变变量”,而是:
setState 申请下一次渲染的状态;当前渲染里的变量不会变。
3. 批处理到底合并了什么?——合并的是“渲染与提交”,不是“更新意图”
你可以把一次交互里发生的事情拆开:
- 你触发很多次
setState(产生很多“更新意图”) - React 把这些更新意图放入队列(update queue)
- React 选择一个时机把队列里的更新“统一计算”
- 只做一次 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)
setX((x) => x + 1)
setY((y) => y + 1)
如果 x/y 之间有强关系,考虑:
useReducer把它们合并成一个状态机更新(更可预测)
6.2 模式 B:根据旧 state 推导新 state(永远用函数式)
setList((prev) => prev.concat(item))
setMap((prev) => ({ ...prev, [k]: v }))
这不仅是为了避免旧值,也是为了在批处理/并发下语义稳定。
6.3 模式 C:需要在“更新提交后”做事(不要在 setState 后立刻读)
例如你想在 DOM 更新后聚焦 input:
- 这不是批处理的问题,是时序问题
- 正确做法是
useEffect或useLayoutEffect(视需求)
7. 批处理与并发:为什么批处理是并发渲染的“地基”
当 React 有了 Fiber 与调度能力后,它更希望:
- 把多次更新合并
- 选择合适的时间渲染
- 甚至根据优先级调整顺序
批处理让更新从“立即执行”变成“可调度的队列”,这正是并发能力的基础之一。
换句话说:
批处理让 React 有机会当“项目经理”,而不是被你每次 setState 牵着跑。
8. 什么时候你反而需要“拆开批处理”?(少数高阶场景)
大多数时候,你应该享受批处理。 但确实有少数场景,你可能需要:
- 强制让某次更新立刻提交(例如为了测量 DOM、同步某个外部系统)
- 或者把两类更新拆成两个独立提交(例如先展示 loading,再渲染重内容)
这类需求本质上是在问:
我需要一个“确定的提交边界”。
实现方式会随着 React 版本与渲染模式有所不同;工程上更常见的替代方案是:
- 把“先显示的状态”放到更高优先级的路径(例如更靠近交互的局部 state)
- 或拆分 UI:先显示轻量骨架,再渐进渲染重区域
- 或通过 effect 来保证“提交后执行”的时序
原则:先从 UI 结构与状态粒度解决,最后才考虑强制刷新。
9. 一个实战建议:把“渲染次数”当指标,把“提交成本”当目标
优化时别只盯 render 次数,因为:
- render 可能很便宜
- commit 与浏览器布局绘制更贵
批处理主要减少“渲染 + 提交的次数”,而你在工程上真正要做的是:
- 缩小 commit 范围(拆组件、局部 state、memo)
- 降低单次提交成本(虚拟列表、减少 DOM 复杂度)
- 让重工作可被调度(time slicing、延后非关键更新)
10. 本文小结:你应当形成的批处理心智模型
- 批处理合并的是 渲染与提交次数,不是丢更新
- 同一时间窗口多次更新,函数式更新保证可叠加
- “setState 后读到旧值”是快照模型,和批处理相辅相成
- React 18 的自动批处理覆盖更广,渲染更少但时序更需要用 effect 推理
- 绝大多数时候别打破批处理:先优化状态粒度与 UI 结构
留言讨论
Discussion
欢迎交流与反馈