先看一个组件:
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后立刻重新渲染,而是先把更新收集起来,等当前执行上下文结束后,再统一计算新状态并渲染一次。
例如:
setCount((c) => c + 1);
setFlag((f) => !f);
如果每次都立即渲染,组件会经历两次更新:
count + 1,渲染一次
flag 取反,再渲染一次
批处理之后,React 会把两个更新一起处理:
count + 1,flag 取反,渲染一次
这减少了一次协调和提交,也避免了用户看到中间状态。
二、React 17 和 React 18 的差异
React 17:主要批处理 React 事件
在 React 17 及更早版本里,React 事件处理函数中的多次更新默认会被批处理:
function handleClick() {
setCount((c) => c + 1);
setFlag((f) => !f);
// 合并成一次渲染
}
但放在 setTimeout、Promise 或原生事件监听器里,就不一定了:
setTimeout(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
// React 17:通常会渲染两次
// React 18:合并成一次
}, 0);
React 17 里,这类更新不在 React 的事件调度上下文内,因此会各自触发一次渲染。需要手动合并时,可以使用 unstable_batchedUpdates:
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 - 原生事件监听器
- 其他在同一轮调度中的更新
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(<App />);
只要使用 createRoot,下面这段代码在 React 18 中通常只会渲染一次:
setTimeout(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
}, 0);
对比一下:
| 更新场景 | React 17 | React 18 + createRoot |
|---|---|---|
| React 事件处理函数 | 批处理 | 批处理 |
setTimeout | 默认不批处理 | 批处理 |
Promise / async | 默认不批处理 | 批处理 |
| 原生事件监听器 | 默认不批处理 | 批处理 |
flushSync 内部 | 强制同步刷新 | 强制同步刷新 |
注意:如果 React 18 仍然使用旧的 ReactDOM.render,行为会更接近 React 17,不会获得完整的自动批处理能力。
三、为什么要批处理
批处理不是为了少写代码,而是为了减少不必要的工作。
1. 减少重复渲染
每次状态更新都可能触发:
更新状态 -> 重新执行组件函数 -> 计算虚拟 DOM -> Diff -> 提交到真实 DOM
如果一次交互改了三个状态,不批处理就要走三遍流程。批处理把它们合并成一次,减少协调和提交开销。
2. 避免中间状态闪烁
假设一次操作同时更新 loading 和数据:
setLoading(false);
setData(data);
如果分两次渲染,用户可能先看到“加载结束但数据还没到”的中间状态。批处理让两次更新一起生效,界面更稳定。
3. 保持同一次交互的状态一致
一次点击往往对应一组相关的状态变化。把它们放在同一次渲染里,组件看到的是一个完整的新状态,而不是更新到一半的状态。
四、批处理是怎么实现的
可以把 React 的更新过程简化成三步:
setState -> 把更新放入队列 -> 调度一次渲染
1. 更新进入队列
每次调用 setState,React 不会立刻修改当前状态,而是创建一个 update 对象,挂到对应 Fiber 节点的更新队列上。
Fiber
└── updateQueue
├── update 1: count + 1
└── update 2: flag 取反
真正计算新状态,是在后续 render 阶段处理这个队列时完成的。
2. 调度渲染
状态更新会带上优先级,也就是 React 内部的 lane。React 根据优先级决定什么时候处理这批更新。
在 React 18 的并发根节点下,更新会交给 React 的调度器处理。当前同步代码执行完后,React 再统一处理队列中的更新。
所以下面两行代码之间,组件并不会立刻重新渲染:
setCount((c) => c + 1);
setFlag((f) => !f);
它们只是先后把两个 update 放进了队列。
3. 统一处理更新
调度开始后,React 会遍历更新队列,按顺序计算新状态,然后执行一次 render。
在 React 17 的旧同步模式里,事件系统会用类似 batchedUpdates 的机制包裹事件处理函数,在事件结束时统一刷新。React 18 的自动批处理则把这种能力扩展到了更多调度场景。
4. 不同优先级可能分开渲染
批处理并不意味着所有更新永远只渲染一次。
React 会按照 lane 优先级处理更新。紧急更新和过渡更新可能被拆成不同批次。例如:
startTransition(() => {
setKeyword(value);
});
startTransition 标记的是低优先级更新,它可能被更高优先级的更新打断或延后。这和“同一个批次里的多次 setState”不是同一件事。
五、flushSync:主动放弃批处理
有些场景需要状态更新后立刻读取 DOM,例如测量元素尺寸。这时可以使用 flushSync 强制同步刷新:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount((c) => c + 1);
});
// 这里 DOM 已经更新
flushSync(() => {
setFlag((f) => !f);
});
}
每调用一次 flushSync,React 就会同步完成一次更新。因此上面的代码会渲染两次,批处理被主动放弃。
flushSync 不适合日常使用。它会让浏览器更早执行渲染和布局,容易造成性能问题。只有在确实需要同步读取最新 DOM 时才考虑。
六、批处理不等于状态合并
这是另一个常见误区。
批处理解决的是“什么时候渲染”,不解决“多次更新如何计算”。
看这段代码:
setCount(count + 1);
setCount(count + 1);
两次都读取了同一个旧的 count,所以最终只增加 1,而不是 2。批处理只是把这两次更新合并处理,并不会自动累加。
正确写法是使用函数式更新:
setCount((c) => c + 1);
setCount((c) => c + 1);
每次更新都基于前一次的最新状态计算,最终增加 2。
所以:
- 批处理:多次更新合并成一次渲染。
- 函数式更新:保证每次更新基于最新状态。
两者解决的是不同问题,经常需要一起使用。
七、异步场景下要注意什么
React 18 的自动批处理覆盖了 Promise 和 async/await,但不意味着跨任意异步边界的更新都会合并。
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 的批处理机制可以总结为三句话:
setState不立即渲染,而是把更新放进队列。- 同一轮调度中的多次更新会被合并,减少重复的协调和提交。
- React 18 通过
createRoot把自动批处理扩展到了定时器、Promise 和原生事件等场景。
理解批处理之后,很多现象就顺了:为什么一次点击里多次 setState 只打印一次 render,为什么 setTimeout 里 React 17 和 React 18 的表现不同,为什么连续两次 setCount(count + 1) 只加了一次,以及为什么 flushSync 能让 DOM 立刻更新。
批处理优化的是渲染时机,函数式更新保证的是状态计算。把这两件事分开看,React 的状态更新就不容易绕晕了。