
一句话:React 会把「同一个事件循环里发生的多次
setState」合并成一次渲染——这叫批处理;React 18 之前只在合成事件里批处理,React 18+ 在所有场景(含setTimeout、Promise、原生事件)都自动批处理。
一、什么是批处理
function Counter() {
const [count, setCount] = useState(0);
console.log('渲染'); // 观察渲染次数
const handleClick = () => {
setCount((c) => c + 1); // 三次 setState
setCount((c) => c + 1);
setCount((c) => c + 1);
};
return <button onClick={handleClick}>{count}</button>;
}
// 点击一次 → 打印【一次】「渲染」→ 显示 3
// 三次 setState 被【合并】成一次渲染
批处理的价值:
① 减少渲染次数(3 次 → 1 次)
② 避免「中间态」出现在界面上
(比如同时 setLoading(true) 和 setData(x),用户不会看到「loading 且旧数据」)
③ 减少 commit 阶段的 DOM 操作与布局计算
④ 避免「渲染 → 再渲染 → 再渲染」导致的布局抖动(layout thrashing)
二、React 17 与 18 的差异(重点)
// React 17:
const handleClick = () => {
// ① 在【合成事件】里 → 批处理 ✅
setCount((c) => c + 1);
setFlag((f) => !f);
// → 一次渲染
// ② 在【异步回调】里 → 不批处理 ❌
setTimeout(() => {
setCount((c) => c + 1); // 一次渲染
setFlag((f) => !f); // 又一次渲染
}, 0);
// ③ 在【原生事件监听】里 → 不批处理 ❌
// ④ 在 Promise.then 里 → 不批处理 ❌
};
// React 18:
const handleClick = () => {
// ① 合成事件 → 批处理 ✅
setCount((c) => c + 1);
setFlag((f) => !f);
// ② setTimeout → ⭐ 也批处理 ✅(自动批处理 / Automatic Batching)
setTimeout(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
}, 0);
// ③ Promise.then → ✅ 批处理
fetch('/api').then(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
});
// ④ 原生事件监听 → ✅ 批处理
// ⑤ 任何「同一个事件循环里」的多次更新 → ✅ 批处理
};
| 场景 | React 17 及之前 | React 18+ |
|---|---|---|
| 合成事件(onClick 等) | ✅ 批处理 | ✅ 批处理 |
setTimeout / setInterval |
❌ 各触发一次渲染 | ✅ 批处理 |
Promise.then / async/await |
❌ | ✅ |
| 原生事件监听 | ❌ | ✅ |
useEffect 里 |
✅(本身在批处理上下文) | ✅ |
| 同一个事件循环里的多次更新 | 部分 | 全部 |
React 18 的「自动批处理」是怎么做到的?
① React 自己实现了一套调度,不再依赖「React 事件系统」这个上下文
② 更新的入队会「按优先级分车道(lane)」并安排一次调度
③ 在同一次「宏任务」里入队的更新会落在同一个批次里
→ 调度器在微任务/下一帧统一执行这些更新
④ 因此「在什么地方调用 setState」不再影响是否批处理
⚠️ 前提:必须使用 createRoot(React 18 的新入口)。
如果还在用 ReactDOM.render(legacy 模式),自动批处理【不生效】。
三、批处理的边界
// ❌ 跨「宏任务」的更新不会被批处理(本来就不该期待被批处理)
const handle = async () => {
setCount((c) => c + 1); // 批次 1
await delay(1000); // ⚠️ 宏任务结束
setCount((c) => c + 1); // 批次 2(另一次渲染)
};
// ⚠️ 两个「不同的 await 之间」的更新会分开
const handle = async () => {
const a = await api1(); // await 之后是新的一次微任务延续
setCount(a);
const b = await api2();
setFlag(b); // → 另一次渲染
};
// 因为两次 setState 分别落在「不同的微任务」里
// ✅ 如果希望合并,可以把结果先存起来,最后一起 setState
const handle2 = async () => {
const [a, b] = await Promise.all([api1(), api2()]);
setCount(a);
setFlag(b); // ⭐ 同一次微任务 → 合并成一次渲染
};
结论:
「同一个宏任务(或同一段连续同步代码)里的多次更新」→ 批处理
「被 await / setTimeout 分隔开的更新」→ 各自成批次
⚠️ 但 React 18 之后,即使是「setTimeout 回调内部」的多行 setState
也会被批处理(因为都在同一个回调的同步代码里)。
四、从「批处理」理解 setState 的「异步」
function Demo() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // ⚠️ 0(不是 1)
};
return <button onClick={handleClick}>{count}</button>;
}
准确的表述(面试要能说清):
① setCount 调用本身是【同步】的
—— 它只是把「更新」放进队列(并安排一次调度)
② 「计算新状态 + 渲染 + 提交」是【异步/延后】的
—— 在批处理结束后统一执行
③ 所以在事件处理函数内部读 count 得到的是【本次渲染的快照值】
④ 下一次渲染时,useState 会返回新的值
所以「setState 是同步还是异步」这个问题本身不严谨,
应该说:「调用是同步的,应用更新是批处理延后的」。
// 想在「当前这次」读到最新值 → 用函数式更新
const handleClick = () => {
setCount((c) => {
const next = c + 1;
console.log('最新值', next); // ✅ 1(在「计算时」读,是最新的)
return next;
});
};
// 或者用 useEffect「监听」状态变化
useEffect(() => {
console.log('count 变成了', count); // ✅ 在渲染之后执行
}, [count]);
五、flushSync(强制同步刷新)
import { flushSync } from 'react-dom';
// ⚠️ 会「跳出批处理」,立即把更新同步应用并渲染
function handleClick() {
flushSync(() => {
setCount((c) => c + 1);
});
// ⭐ 到这里 DOM 已经更新了
flushSync(() => {
setFlag((f) => !f);
});
// ⭐ 又一次同步更新
}
什么时候需要 flushSync?
场景:更新之后需要「立即读取 DOM」
① 打印/导出:先 setState 显示要打印的内容,然后立即 window.print()
❌ 不 flushSync:DOM 还没更新就打印 → 打印到旧内容
✅ flushSync(() => setPrintContent(html)) 然后 window.print()
② 测量:需要「更新后立刻拿到新尺寸」
(但这种情况通常用 useLayoutEffect 更合适)
③ 与「非 React 的库」集成:库要求「你更新完我马上读 DOM」
⚠️ 代价与风险:
① 破坏批处理 → 多次渲染(性能下降)
② 可能导致「渲染还没结束就写 DOM」的警告
③ 在渲染过程中调用会报错(只能在事件处理 / Effect 里用)
④ 过度使用会让「数据流」难以预测
所以:能不用就不用,优先考虑 useLayoutEffect 或 useEffect。
六、批处理与「渲染次数」的实测
// 用一个可观察的例子验证(推荐自己跑)
function BatchDemo() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const renderCount = useRef(0);
renderCount.current++;
const updateBoth = () => {
setA((x) => x + 1);
setB((x) => x + 1);
// → 只渲染 1 次(渲染计数 +1,不是 +2)
};
const updateAsync = () => {
setTimeout(() => {
setA((x) => x + 1);
setB((x) => x + 1);
// React 18:仍然只渲染 1 次
// React 17:渲染 2 次
}, 0);
};
const updateAcrossAwait = async () => {
setA((x) => x + 1);
await delay(0); // ⚠️ 跨了微任务
setB((x) => x + 1);
// → 渲染 2 次(两个批次)
};
return (
<>
<p>渲染次数:{renderCount.current}</p>
<button onClick={updateBoth}>同步更新两个</button>
<button onClick={updateAsync}>异步更新两个</button>
<button onClick={updateAcrossAwait}>跨 await 更新</button>
</>
);
}
七、常见坑
// ① 以为「React 18 之后所有更新都会合并成一次」
// → 被 await / 多个宏任务分隔的更新依然各自成批
// ② 在 legacy 模式(ReactDOM.render)下期待自动批处理
ReactDOM.render(<App />, root); // ⚠️ 自动批处理不生效
// ✅ createRoot(root).render(<App />)
// ③ 在事件处理里读 setState 后的新值
setCount(count + 1);
console.log(count); // ❌ 旧值
// ④ 用「临时变量」绕过批处理
let next = count + 1;
setCount(next);
console.log(next); // ⚠️ 这是你自己算的值,不是 state 的值
// ⑤ 期待「批处理能跨 await」
// ⑥ 用 flushSync 做「小事」(如只是想读一个值)→ 得不偿失
// ⑦ 在渲染阶段调用 flushSync → 报错
// ⑧ 认为「批处理是性能优化,所以不批处理是 bug」
// → 它是实现细节层面的行为,业务逻辑不应该依赖「渲染了几次」
// ⑨ 在 useEffect 里连续 setState 期待「一次渲染」
useEffect(() => {
setA(1);
setB(2);
// ✅ 同一次 Effect 内的会合并(一次渲染)
}, []);
// ⑩ 用「渲染计数」判断「逻辑是否正确」(渲染次数是优化细节,不该被依赖)
// ⑪ 在第三方库的回调里连续 setState 却发现渲染两次
// → 可能跨越了「原生事件 → 异步 → 另一个宏任务」的边界
// ⑫ 认为「批处理会让 setState 的更新被丢弃」(不会,只是延后)
八、面试延伸
- 「什么是批处理?」
批处理指「把同一个事件循环里发生的多次状态更新合并成一次渲染」。比如在一次点击里调用三次setCount,React 只会重新渲染一次(最终值正确,但只渲染一遍)。它的价值是:减少渲染次数、避免中间态出现在界面上、减少 DOM 操作与布局计算。React 18 起批处理是「自动」的——不管setState是在事件处理、setTimeout、Promise 还是原生事件回调里。 -
「React 18 的自动批处理改了什么?」
React 17 及之前,只有 React 事件处理函数里的更新会被批处理;在setTimeout、Promise.then、原生事件监听里的多次更新会各自触发一次渲染。React 18 起(需使用createRoot),所有更新都自动批处理(无论在哪里触发,只要在同一个事件循环里)。这也让「setState是同步还是异步」这个老问题的答案变得更统一。 -
「
setState是同步还是异步的?」
更准确的表述是「调用是同步的,应用更新是批处理延后的」。setState调用时会同步地把更新入队(并安排调度),但「计算新状态 → render → commit」发生在批处理结束时(异步/延后)。所以在事件处理函数内部读 state 得到的是当次渲染的快照值;要在「下一次渲染」拿到新值就用useEffect,要在「计算时」拿到最新值就用函数式更新setCount(c => c + 1)。 -
「批处理的边界在哪里?」
边界是「同一个宏任务 / 同一段连续同步代码」。① 同一个事件处理函数里的多次setState→ 合并;② 同一个setTimeout回调里的多次setState→ 合并(React 18);③ 被await或不同宏任务分隔的更新 → 各自成批次(如setA(); await x; setB();会渲染两次)。想合并跨await的更新,可以在最后统一setState(如先Promise.all取结果,再一次性更新)。 -
「为什么 React 18 之前不批处理异步回调里的更新?」
因为早期 React 判断「是否在批处理上下文」的方式是「是否处于 React 事件系统的调度中」——合成事件处理函数执行时 React 会设置一个标志位,回调结束后统一 flush。而setTimeout/ Promise 的回调不在这个上下文里,所以每次setState都会立即(同步)走一遍「调度 → 渲染」。React 18 重构了调度机制(不依赖事件系统,改用微任务统一的批处理),因此不再有这个限制。 -
「
flushSync是干什么的?什么时候用?」
flushSync(callback)会跳出批处理,立即同步执行callback里的更新并完成渲染(DOM 在函数返回前就已更新)。它主要用于「更新后必须立刻读取 DOM」的场景,典型的是「打印」:先flushSync(() => setPrintContent(html)),然后立即调用window.print(),否则打印的会是旧内容。但它的代价是破坏批处理(多次渲染、性能下降),所以能不用就不用——优先考虑useLayoutEffect。 -
「批处理会导致更新被丢弃吗?」
不会。批处理只是延后执行,不会丢弃任何更新——同一个 state 的多次更新会被按顺序应用(所以setCount(c => c+1)三次最终会 +3)。这也是为什么「连续三次setCount(count + 1)只加 1」不是「更新被合并丢了两个」,而是「三次传入的值都是同一个旧值计算出来的 1」。用函数式更新就能得到 1、2、3 的效果。 -
「批处理对我们写业务代码有什么影响?」
三点:① 不要在事件处理里依赖「读到的 state 是新值」——用函数式更新或useEffect;② 不要依赖「渲染次数」(它是优化细节,React 有权调整);③ 需要「更新后立刻读 DOM」时用useLayoutEffect或flushSync(后者慎用)。此外,了解批处理有助于理解「为什么既有 3 次setState却只渲染一次」以及「为什么跨await的更新会渲染两次」。
一句话速记
批处理 = 「同一事件循环里的多次更新合并成一次渲染」(减少渲染、避免中间态);React 17 及之前只在合成事件里批处理(
setTimeout/Promise/原生事件里各触发一次),React 18+ 全场景自动批处理(前提是用createRoot);被await或多个宏任务分隔的更新仍会分成多个批次;所以准确说法是「setState调用同步、应用更新批处理延后」,要读最新值就用函数式更新set(c => ...);flushSync能强制同步刷新(用于「更新后立刻读 DOM」,如打印),但会破坏批处理,慎用。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!