创见博客
React 更新批处理机制:为什么多次 setState 只渲染一次?
七崽爱吃小饼干2026/10/21阅读 0专栏 React

先看一个组件:

jsx
import { useState } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  console.log('render');

  const handleClick = () => {
    setCount((c) => c + 1);
    setFlag((f) => !f);
  };

  return (
    <button onClick={handleClick}>
      {count} / {String(flag)}
    </button>
  );
}

点击按钮时,setCount 和 setFlag 各调用了一次,但控制台通常只会打印一次 render。

这就是 React 的更新批处理:同一个执行上下文里的多次状态更新,会被合并成一次渲染。

不过,这个行为在 React 17 和 React 18 里的范围并不一样。理解这个差异,才能真正理解批处理。

一、什么是批处理

批处理(batching)指的是:

React 不会在每次 setState 后立刻重新渲染,而是先把更新收集起来,等当前执行上下文结束后,再统一计算新状态并渲染一次。

例如:

js
setCount((c) => c + 1);
setFlag((f) => !f);

如果每次都立即渲染,组件会经历两次更新:

text
count + 1,渲染一次
flag 取反,再渲染一次

批处理之后,React 会把两个更新一起处理:

text
count + 1,flag 取反,渲染一次

这减少了一次协调和提交,也避免了用户看到中间状态。

二、React 17 和 React 18 的差异

React 17:主要批处理 React 事件

在 React 17 及更早版本里,React 事件处理函数中的多次更新默认会被批处理:

jsx
function handleClick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // 合并成一次渲染
}

但放在 setTimeout、Promise 或原生事件监听器里,就不一定了:

js
setTimeout(() => {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // React 17:通常会渲染两次
  // React 18:合并成一次
}, 0);

React 17 里,这类更新不在 React 的事件调度上下文内,因此会各自触发一次渲染。需要手动合并时,可以使用 unstable_batchedUpdates:

js
import { unstable_batchedUpdates } from 'react-dom';

setTimeout(() => {
  unstable_batchedUpdates(() => {
    setCount((c) => c + 1);
    setFlag((f) => !f);
  });
}, 0);

React 18:自动批处理范围扩大

React 18 使用 createRoot 后,自动批处理覆盖了更多场景:

  • React 事件处理函数
  • setTimeout、setInterval
  • Promise 的 then 和 async/await
  • 原生事件监听器
  • 其他在同一轮调度中的更新
jsx
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')).render(<App />);

只要使用 createRoot,下面这段代码在 React 18 中通常只会渲染一次:

js
setTimeout(() => {
  setCount((c) => c + 1);
  setFlag((f) => !f);
}, 0);

对比一下:

更新场景React 17React 18 + createRoot
React 事件处理函数批处理批处理
setTimeout默认不批处理批处理
Promise / async默认不批处理批处理
原生事件监听器默认不批处理批处理
flushSync 内部强制同步刷新强制同步刷新

注意:如果 React 18 仍然使用旧的 ReactDOM.render,行为会更接近 React 17,不会获得完整的自动批处理能力。

三、为什么要批处理

批处理不是为了少写代码,而是为了减少不必要的工作。

1. 减少重复渲染

每次状态更新都可能触发:

text
更新状态 -> 重新执行组件函数 -> 计算虚拟 DOM -> Diff -> 提交到真实 DOM

如果一次交互改了三个状态,不批处理就要走三遍流程。批处理把它们合并成一次,减少协调和提交开销。

2. 避免中间状态闪烁

假设一次操作同时更新 loading 和数据:

js
setLoading(false);
setData(data);

如果分两次渲染,用户可能先看到“加载结束但数据还没到”的中间状态。批处理让两次更新一起生效,界面更稳定。

3. 保持同一次交互的状态一致

一次点击往往对应一组相关的状态变化。把它们放在同一次渲染里,组件看到的是一个完整的新状态,而不是更新到一半的状态。

四、批处理是怎么实现的

可以把 React 的更新过程简化成三步:

text
setState -> 把更新放入队列 -> 调度一次渲染

1. 更新进入队列

每次调用 setState,React 不会立刻修改当前状态,而是创建一个 update 对象,挂到对应 Fiber 节点的更新队列上。

text
Fiber
└── updateQueue
    ├── update 1: count + 1
    └── update 2: flag 取反

真正计算新状态,是在后续 render 阶段处理这个队列时完成的。

2. 调度渲染

状态更新会带上优先级,也就是 React 内部的 lane。React 根据优先级决定什么时候处理这批更新。

在 React 18 的并发根节点下,更新会交给 React 的调度器处理。当前同步代码执行完后,React 再统一处理队列中的更新。

所以下面两行代码之间,组件并不会立刻重新渲染:

js
setCount((c) => c + 1);
setFlag((f) => !f);

它们只是先后把两个 update 放进了队列。

3. 统一处理更新

调度开始后,React 会遍历更新队列,按顺序计算新状态,然后执行一次 render。

在 React 17 的旧同步模式里,事件系统会用类似 batchedUpdates 的机制包裹事件处理函数,在事件结束时统一刷新。React 18 的自动批处理则把这种能力扩展到了更多调度场景。

4. 不同优先级可能分开渲染

批处理并不意味着所有更新永远只渲染一次。

React 会按照 lane 优先级处理更新。紧急更新和过渡更新可能被拆成不同批次。例如:

jsx
startTransition(() => {
  setKeyword(value);
});

startTransition 标记的是低优先级更新,它可能被更高优先级的更新打断或延后。这和“同一个批次里的多次 setState”不是同一件事。

五、flushSync:主动放弃批处理

有些场景需要状态更新后立刻读取 DOM,例如测量元素尺寸。这时可以使用 flushSync 强制同步刷新:

jsx
import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCount((c) => c + 1);
  });

  // 这里 DOM 已经更新

  flushSync(() => {
    setFlag((f) => !f);
  });
}

每调用一次 flushSync,React 就会同步完成一次更新。因此上面的代码会渲染两次,批处理被主动放弃。

flushSync 不适合日常使用。它会让浏览器更早执行渲染和布局,容易造成性能问题。只有在确实需要同步读取最新 DOM 时才考虑。

六、批处理不等于状态合并

这是另一个常见误区。

批处理解决的是“什么时候渲染”,不解决“多次更新如何计算”。

看这段代码:

js
setCount(count + 1);
setCount(count + 1);

两次都读取了同一个旧的 count,所以最终只增加 1,而不是 2。批处理只是把这两次更新合并处理,并不会自动累加。

正确写法是使用函数式更新:

js
setCount((c) => c + 1);
setCount((c) => c + 1);

每次更新都基于前一次的最新状态计算,最终增加 2。

所以:

  • 批处理:多次更新合并成一次渲染。
  • 函数式更新:保证每次更新基于最新状态。

两者解决的是不同问题,经常需要一起使用。

七、异步场景下要注意什么

React 18 的自动批处理覆盖了 Promise 和 async/await,但不意味着跨任意异步边界的更新都会合并。

js
async function handleClick() {
  setLoading(true);

  const data = await fetchData();

  setData(data);
  setLoading(false);
}

setLoading(true) 发生在 await 之前,数据返回后的两次更新发生在 await 之后。它们可能处于不同的调度时机,不能简单假设整个异步流程只会渲染一次。

如果需要避免中间状态,可以考虑:

  • 把相关状态合并成一个对象。
  • 使用 startTransition 标记非紧急更新。
  • 在数据层做好缓存和请求状态管理,而不是依赖批处理来消除闪烁。

八、几个常见误解

误解一:setState 是异步函数

setState 不是 Promise,也不是传统意义上的异步 API。它只是把更新排入队列,React 决定何时处理。

误解二:批处理一定会把所有更新合成一次

只有在同一轮调度中、且优先级处理允许的情况下,更新才会合并。不同优先级、flushSync、跨异步边界的更新都可能产生多次渲染。

误解三:StrictMode 的两次渲染是批处理失效

开发环境下,StrictMode 会故意重复调用部分生命周期和渲染逻辑,用于发现副作用问题。这不是批处理失效,也不是生产环境的行为。

误解四:React 18 在所有地方都自动批处理

自动批处理依赖 createRoot。如果还在使用旧的 ReactDOM.render,行为不会完全一致。

最后

React 的批处理机制可以总结为三句话:

  1. setState 不立即渲染,而是把更新放进队列。
  2. 同一轮调度中的多次更新会被合并,减少重复的协调和提交。
  3. React 18 通过 createRoot 把自动批处理扩展到了定时器、Promise 和原生事件等场景。

理解批处理之后,很多现象就顺了:为什么一次点击里多次 setState 只打印一次 render,为什么 setTimeout 里 React 17 和 React 18 的表现不同,为什么连续两次 setCount(count + 1) 只加了一次,以及为什么 flushSync 能让 DOM 立刻更新。

批处理优化的是渲染时机,函数式更新保证的是状态计算。把这两件事分开看,React 的状态更新就不容易绕晕了。

评论
0/100