React 批量更新(Batching)

多次 state 更新为什么通常只触发一次重新渲染?

先记住一句话:批量更新是 React 将同一批次中的多个更新集中处理,尽量减少 Render 和 Commit 次数的机制;它不会把多个 state 合并成一个值,也不会立即修改当前渲染中的变量。

1. 什么是批量更新?

function handleClick() {
  setCount((value) => value + 1);
  setFlag((value) => !value);
}

React 通常会把这两个更新放在同一批次中处理,最后再进行一次组件树更新。这样可以避免出现短暂的中间 UI,例如先更新了 count、下一次才更新 flag

批量更新主要减少的是:

  • 组件函数执行次数;
  • Render 次数;
  • Commit 到 DOM 的次数。

它不会改变更新队列的顺序。多个更新仍然会按照 React 规定的顺序计算。

2. 批量更新和状态快照

批量更新不代表 setter 会立即修改当前变量:

function handleClick() {
  console.log(count); // 当前渲染的快照
  setCount((value) => value + 1);
  console.log(count); // 仍然是当前快照
}

如果连续更新依赖旧状态,使用函数式更新:

setCount((value) => value + 1);
setCount((value) => value + 1); // 最终增加 2

直接传值时,两次调用可能都基于同一个旧快照:

setCount(count + 1);
setCount(count + 1); // 例如从 0 最终只得到 1

这和批量更新是两个不同概念:前者是更新如何计算,后者是 React 何时集中处理这些更新。

3. React 17 及以前

在传统 React 17 渲染方式下,React 主要会在自己的事件处理流程中自动批量更新:

function handleClick() {
  setCount((value) => value + 1);
  setFlag((value) => !value);
}

但在 setTimeout、Promise 回调和原生事件监听器等 React 控制范围之外的场景,更新通常不会自动批量:

setTimeout(() => {
  setCount((value) => value + 1);
  setFlag((value) => !value);
}, 1000);

这描述的是 React 17 的典型行为。具体结果还会受到渲染入口、版本和手动批处理 API 的影响。

4. React 18 的自动批量更新

使用 createRoot 创建的 React 18 应用,会把自动批处理扩展到更多场景:

  • React 事件;
  • Promise 回调;
  • setTimeout / setInterval
  • 原生事件监听器;
  • 其他由这些任务触发的 state 更新。
setTimeout(() => {
  setCount((value) => value + 1);
  setFlag((value) => !value);
  // React 通常会将它们作为同一批次处理
}, 1000);

需要注意:如果应用仍使用旧的 ReactDOM.render 入口,React 18 可以保留旧的更新行为以便迁移。是否启用完整的 React 18 自动批处理,首先要确认应用是否使用 createRoot

5. 批量更新不等于“合并 state”

批量更新和类组件的对象 state 合并不是一回事:

const [user, setUser] = useState({ name: "Ada", age: 20 });

// ❌ 不会自动保留 name
setUser({ age: 21 });

// ✅ 需要显式合并对象
setUser((currentUser) => ({
  ...currentUser,
  age: 21,
}));

函数组件中的 useState setter 会用新值替换旧状态,不会自动浅合并对象。

6. 为什么批量更新有用?

减少重复渲染

如果一个交互需要更新多个相关状态,集中处理可以减少组件树重复计算。

避免中间状态

批量处理让用户通常直接看到完整的一组状态变化,而不是先看到某个字段已经更新、其他字段还没更新的中间画面。

保持更新顺序

批量不是简单丢弃重复调用。React 仍会处理更新队列:

setCount((value) => value + 1);
setCount((value) => value * 2);

1 开始时,结果是 4,因为更新按队列顺序计算:

1 -> +1 -> 2 -> *2 -> 4

7. 什么时候使用flushSync

绝大多数业务代码不需要退出批处理。只有在必须在同一段代码中立刻读取更新后的 DOM 时,才考虑 flushSync

import { flushSync } from "react-dom";

function handleAdd() {
  flushSync(() => {
    setItems((items) => [...items, createItem()]);
  });

  // 这里可以读取已经提交的 DOM
  listRef.current?.lastElementChild?.scrollIntoView();
}

flushSync 会强制 React 同步完成更新,并可能影响性能或触发额外的 effect。不要把它当成“让 state 立即更新”的常规 API,也不要在普通状态流转中滥用。

8. 常见误区

批量更新一定只渲染一次

不应把“一次批次”当成绝对的一次组件执行。更新可能被拆分、优先级不同,或者因为其他同步工作产生额外渲染。批处理的目标是减少不必要的工作,而不是承诺固定次数。

setState 调用后立即读取变量能拿到新值

拿到的仍然是当前渲染的状态快照。需要使用最新状态时,通过下一次渲染、effect 或函数式 updater 处理。

批处理会深度合并对象

不会。useState 的新对象会替换旧对象,需要手动展开合并。

所有性能问题都能靠批处理解决

不能。昂贵的计算、大列表、过大的组件更新范围和首屏包体仍需要通过状态下沉、列表虚拟化、代码分割和性能分析解决。

9. 与相关机制的关系

机制解决的问题
更新队列按顺序计算多个 state 更新
函数式更新基于队列中的最新状态计算新值
批量更新集中处理一批更新,减少重复 Render / Commit
startTransition标记非紧急更新,允许 React 优先处理交互
flushSync特殊场景下强制同步提交

总结

批量更新可以概括为:

  1. React 将同一批次中的多个更新集中处理;
  2. React 18 配合 createRoot 后,更多异步场景默认支持自动批量;
  3. 批量更新不改变状态快照,也不等于对象合并;
  4. 依赖旧状态时使用函数式更新;
  5. 只有需要立即读取 DOM 时,才谨慎使用 flushSync

面试时可以这样回答:Batching 是 React 减少重复 Render 和 Commit 的机制。React 17 主要在 React 事件中批量,React 18 使用 createRoot 后扩展到 Promise、定时器和原生事件等场景。批量更新不会让 setter 同步修改当前 state,也不会自动合并对象;需要基于旧值连续更新时应使用函数式写法。