批量更新是 React 最重要的性能优化机制之一。它的核心思想是:将多次状态更新合并为一次重渲染,避免不必要的中间渲染过程。
为什么需要批量更新
假设你在一个事件处理函数中连续调用了三次 setState:
function handleClick() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
}
如果没有批量更新机制,每次调用 setState 都会立即触发一次组件重渲染。以上代码会导致三次重渲染,但实际上用户只需要看到最终结果。批量更新会将这三步状态变更攒在一起,只触发一次重渲染,极大地减少了 DOM 操作次数和计算消耗。
React 17 及之前版本的批量更新
在 React 17 及更早版本中,批量更新只在 React 合成事件(如 onClick、onChange)和生命周期方法中生效。在这些场景之外,比如 setTimeout、Promise、原生事件监听器中,多个 setState 调用不会自动合并,每次调用都会立即触发一次渲染。
示例:React 17 中不批量的场景
function handleAsyncClick() {
setTimeout(() => {
setCount(c => c + 1); // 触发渲染
setName('Bob'); // 触发渲染(第二次)
}, 1000);
}
在 React 17 中,以上代码会导致两次渲染。这种不一致的行为给开发者带来了额外的心智负担,也容易引发性能问题。
React 18 的自动批处理(Automatic Batching)
React 18 引入了 自动批处理,将批量更新的能力扩展到了所有场景,包括 setTimeout、Promise、原生事件等异步代码。无论你在哪里调用 setState,React 都会自动将同一“可调度”上下文内的多次更新合并为一次渲染。
实现原理(简化理解):
- React 内部使用一个全局标志(
isBatchingUpdates)来控制是否处于批量处理模式。 - 合成事件和生命周期方法在调用前会打开该标志,调用完毕后关闭,并触发一次渲染。
- React 18 通过引入新的协调引擎(Fiber + Lane 模型),将状态更新调度统一到一个内部队列中,异步 flush 队列,从而在多种异步场景下也能合并状态更新。
React 18 中自动批处理示例:
function handleAsyncClick() {
setTimeout(() => {
setCount(c => c + 1); // 不立即渲染
setName('Bob'); // 不立即渲染
// React 18 会在此处自动合并,最终只触发一次渲染
}, 1000);
}
此时,组件只会重渲染一次,体验和性能得到双重提升。
如果需要立即同步更新怎么办?
极少数情况下,你可能需要同步获取最新 DOM(例如测量元素位置)。React 提供了 flushSync 方法,可以强制绕过批处理立即同步更新。
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1); // 立即触发渲染,DOM 立即更新
});
// 这里可以立刻读取更新后的 DOM
console.log(containerRef.current.textContent);
}
flushSync 会暂停批处理,所以应该谨慎使用,以免破坏性能优化。
批量更新与函数式更新
批量更新机制配合 函数式更新(setState(c => c + 1))使用最佳。因为多次函数式更新在同一个渲染批次内可以累积计算,确保状态正确性。
function handleClick() {
setCount(c => c + 1);
setCount(c => c + 1); // 基于上一次更新结果
// 最终 count 会 +2,而不是只 +1
}
如果使用普通值更新(setCount(count + 1)),在批量更新中会因为闭包捕获旧值而导致状态丢失,这是一个常见的坑点。
实际开发中带来的简化
React 18 的自动批处理让开发者不再需要手动区分“是否是合成事件”,也不需要为了性能刻意减少 setState 调用。你可以专注于业务逻辑,将状态更新自然地放在循环、异步请求回调等任意位置,React 会自动优化渲染次数。
总结一句话:React 通过批量更新将多次状态变化合并为一次渲染,React 18 进一步抹平了同步和异步场景的差异,使这种优化无处不在。