
一句话:
setState的「调用」是同步的,「应用更新并重新渲染」是批处理延后的——所以事件处理里读不到新值;React 18 起所有场景都自动批处理(不再有「异步回调里同步渲染」的例外)。
一、结论先行(三层回答)
问题:「setState 是同步还是异步?」
① 最简洁的回答:
「setState 本身是同步的(它只是把更新入队),
但『应用更新 + 重新渲染』是异步的(批处理后在事件处理结束时统一执行)。」
② 更准确的说法:
「调用同步、渲染异步;React 18 起所有场景都自动批处理。」
③ 面试加分版:
「这个问题的『同步/异步』本身不严谨——
准确的划分是『入队是同步的、flush 是延后的』,
而 flush 的时机取决于『优先级与调度』(并发渲染下更进一步:
过渡优先级的更新可能被推迟到更晚)。」
二、代码验证(事件里读不到新值)
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log('事件处理里读到的 count =', count); // ⚠️ 0(不是 1)
// 强制同步刷新后再读
flushSync(() => setCount((c) => c + 1));
// ⚠️ 但这里 console.log 读到的仍然是「本次渲染的 count 变量」(它不会变)
};
return <button onClick={handleClick}>{count}</button>;
}
⭐ 关键理解:
① `count` 是「当次渲染闭包里的常量」——
无论怎么 flush,这个变量在本次函数执行中都不会变
② `setCount` 的执行是同步的(入队完成)
③ 但「用新值重新渲染」发生在「事件处理函数执行完之后」
→ 所以「读不到新值」不是因为「setState 内部有 setTimeout」,
而是因为「组件函数还没重新执行」
// ✅ 想「读到最新值」的正确姿势
const handleClick = () => {
setCount((c) => {
const next = c + 1;
console.log('在更新函数里读到的 =', next); // ✅ 1(这里是最新值)
return next;
});
};
// ✅ 或者在「下一次渲染」里读
useEffect(() => {
console.log('count 变成了', count); // ✅ 渲染之后
}, [count]);
// ✅ 或者用 ref 同步一份
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
三、React 18 与 17 的差异(重点)
// React 17:只有「React 事件处理」里才批处理
const handleClick = () => {
setA(1);
setB(2);
// ✅ 批处理(一次渲染)
setTimeout(() => {
setA(3); // ❌ 单独触发一次渲染
setB(4); // ❌ 再触发一次渲染
}, 0);
fetch('/api').then(() => {
setA(5); // ❌ 单独触发
setB(6); // ❌ 再触发
});
};
// React 18:所有场景都自动批处理(需要 createRoot)
const handleClick = () => {
setA(1);
setB(2);
// ✅ 一次渲染
setTimeout(() => {
setA(3);
setB(4);
// ✅ 也是一次渲染(自动批处理)
}, 0);
fetch('/api').then(() => {
setA(5);
setB(6);
// ✅ 一次渲染
});
};
| 场景 | React 17 | React 18(createRoot) |
|---|---|---|
| React 事件处理 | ✅ 批处理 | ✅ 批处理 |
setTimeout / setInterval |
❌ 各自渲染 | ✅ 批处理 |
Promise.then / async/await |
❌ | ✅ |
| 原生事件监听 | ❌ | ✅ |
| 同一个宏任务里的多次更新 | 部分 | 全部 |
跨 await 的更新 |
❌ | ❌(仍是多个批次) |
⭐ React 18 是怎么做到的?
① React 不再依赖「React 事件系统上下文」来判断「是否在批处理」
② 更新入队时「按优先级分车道」并安排一次调度
③ 同一个「宏任务」里入队的更新会落在同一个批次
→ 调度器在微任务/下一帧统一 flush
⚠️ 前提:必须用 createRoot(legacy 的 ReactDOM.render 不享受自动批处理)
四、flushSync:强制同步刷新
import { flushSync } from 'react-dom';
function App() {
const [items, setItems] = useState([]);
const ref = useRef<HTMLDivElement>(null);
const handleAdd = () => {
// ⚠️ 场景:加完之后「立即读取 DOM 的新高度」
flushSync(() => {
setItems((prev) => [...prev, newItem]); // ⭐ 同步渲染完成
});
// 到这里 DOM 已经更新了(可以读到新高度)
console.log(ref.current?.scrollHeight);
};
return <div ref={ref}>{items.map((it) => <div key={it.id}>{it.text}</div>)}</div>;
}
⭐ flushSync 的典型场景与代价:
✅ 场景:
① 【打印】——先把「要打印的内容」同步渲染进去,再调 window.print()
(否则打印的是旧内容)
② 【测量】——更新后立刻要读新尺寸(但这种情况通常用 useLayoutEffect 更合适)
③ 【与第三方库集成】——库要求「更新完立刻读 DOM」
⚠️ 代价与限制:
① 破坏批处理 → 多次渲染(性能下降)
② 在「渲染过程中」调用会报错(只能在事件处理/Effect 里用)
③ 会让「数据流」更难预测(过度使用是坏味道)
⭐ 所以:能不用就不用;优先「useLayoutEffect」或「把读 DOM 的时机挪后」。
⭐ `useLayoutEffect` 与 `flushSync` 的区别:
· `useLayoutEffect`:「DOM 更新后、绘制前」执行——
用于「读取布局并立即修正」(如测量后 setState),避免闪烁
· `flushSync`:「在事件处理里」强制「立刻把更新刷进 DOM」——
用于「更新后马上要做别的事并依赖新 DOM」
两者都「同步」,但「位置不同」:一个在 Effect 里,一个在事件处理里。
五、源码层面的解释(加分项)
一次 setState 经历了什么(简化):
① 【同步】更新入队
setState → dispatchSetState →
在 fiber 的 hook.queue 上「入队一个 update 对象」
→ 计算优先级车道(Lane)
→ 调用 scheduleUpdateOnFiber(安排调度)
② 【同步】判断「能否立即 flush」
如果「当前不在渲染中」且「优先级允许」→ 可能直接进入渲染流程
(但通常会被「批处理」机制合并,等事件处理结束再统一处理)
③ 【异步/延后】调度渲染
React 用 Scheduler 在「微任务/下一帧」执行渲染
→ 渲染 + commit 完成后,DOM 才真正更新
⭐ 所以「同步/异步」的准确描述是:
「入队同步、flush 由调度器决定(批处理 + 优先级)」
⚠️ 一个历史细节(面试可能问):
React 17 及之前的批处理靠「isBatchingUpdates 标志位 + React 事件系统」:
· 合成事件处理时设置标志位 → setState 只入队
· 事件处理结束(batchedUpdates 回调返回)→ 统一 flush
所以在「React 事件系统之外」的 setState(setTimeout/Promise)会立刻 flush
→ 表现为「同步渲染」。
React 18 改成「基于调度的自动批处理」,不再依赖事件系统 →
所有场景都批处理。
六、常见追问
Q: 那「setState 之后立刻读 state」为什么读不到?
A: 因为「读的变量」是「本次渲染的闭包快照」——
与 setState 是否同步无关,它就是个常量(React 的设计如此)。
Q: 为什么 React 要「批处理」?
A: ① 减少渲染次数(性能)
② 避免「中间态」出现在界面上(如「loading 且没有数据」的闪烁)
③ 减少 DOM 操作与布局抖动
Q: 同一个事件里 setCount(count+1) 三次为什么只加 1?
A: 因为三次传的都是「基于同一个快照算出的 1」→ 最终设为 1。
用 setCount(c => c+1) 才会累加(因为计算被延迟到「应用更新时」)。
(这也说明「函数式更新」是这个模型的必然要求。)
Q: 不加 flushSync 能实现「更新后立刻读 DOM」吗?
A: 能——用 useLayoutEffect(它本身就在「DOM 更新后、绘制前」执行)。
flushSync 的价值主要在「事件处理里需要立刻有 DOM 结果」的场景(如打印)。
Q: 并发渲染下「异步」的表现会变吗?
A: 会更进一步:过渡优先级(useTransition/useDeferredValue)的更新
可能被「推迟到更晚」甚至「被打断丢弃」。
所以「异步」在并发下不只是「延后一次」,而是「优先级调度」。
Q: StrictMode 下的双调用会影响 setState 的行为吗?
A: 会让「组件函数执行两次」与「Effect 双执行」,但「更新本身」不会被应用两次
(因为更新入队是按 action 走的,双调用只是「调用组件函数」两次)。
七、常见坑
// ① 在事件处理里期待读到新值
setCount(count + 1);
console.log(count); // ❌ 旧值(快照)
// ② 连续更新不用函数式
setCount(count + 1);
setCount(count + 1); // ❌ 只加 1
// ③ 用「临时变量」假装解决
const next = count + 1;
setCount(next);
console.log(next); // ⚠️ 这是你自己算的,不是 state
// ④ 认为「React 里的 setState 是异步的,所以内部用了 setTimeout」
// ⑤ 忘了 createRoot(legacy 模式没有自动批处理)
ReactDOM.render(<App />, root); // ⚠️ 自动批处理不生效
// ⑥ 用 flushSync 处理「小事」(得不偿失)
// ⑦ 在渲染中调用 flushSync → 报错
// ⑧ 期待「跨 await 的更新被合并」
setA(1);
await delay();
setB(2); // ⚠️ 两次渲染
// ⑨ 用「渲染次数」判断逻辑正确性(渲染次数是优化细节)
// ⑩ 在 useEffect 里连续 setState 期待「多次渲染」
useEffect(() => { setA(1); setB(2); }, []); // ✅ 同一次 Effect 里会合并
// ⑪ 用「setState 后立刻读 DOM」却没用 flushSync / useLayoutEffect
// ⑫ 认为「并发渲染让 setState 更『异步』了」——
// ✅ 其实准确说是「优先级调度」,默认优先级下行为与 18 之前一致
// ⑬ 在「原生事件监听」里连续 setState 期待旧行为(React 18 会合并)
// ⑪ 的两种正确做法
// ✅ 方式一:useLayoutEffect(绘制前生效,不会闪)
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
setHeight(ref.current!.offsetHeight);
}, []);
// ✅ 方式二:flushSync(事件里立即生效)
const handlePrint = () => {
flushSync(() => setPrintMode(true));
window.print();
};
八、面试延伸
- 「
setState是同步还是异步的?」
这个问题本身不够严谨,准确的说法是「调用同步、flush 延后」:setState调用时会同步地把更新入队(并安排一次调度),但「计算新状态 → 渲染 → 提交 DOM」是由调度器在批处理结束后执行的。所以在事件处理函数内部读state得到的是当次渲染的闭包快照(它是个常量);要拿到「最新值」应该用函数式更新(set(c => ...))或在useEffect里读。 -
「为什么连续三次
setCount(count + 1)只加了 1?」
因为count是「本次渲染闭包里的常量快照」(值为 0),三次调用传入的都是0 + 1 = 1,React 收到「三次设为 1」的请求 → 最终结果是 1。改成setCount(c => c + 1)后,React 会在「应用每个更新时」把「最新的待处理值」传进函数 → 依次得到 1、2、3。这也是「函数式更新」在这个模型下必然存在的原因——它把「计算」从「调用时」推迟到「应用时」。 -
「React 18 的自动批处理改了什么?」
React 17 及之前,批处理依赖「React 事件系统的上下文」——只有在「合成事件处理函数」里的更新会被合并,而setTimeout、Promise.then、原生事件监听里的多次更新会各自触发一次渲染。React 18 起(需createRoot),React 改用「基于调度的自动批处理」——只要在「同一个宏任务」里入队的更新就会合并,不再依赖事件系统。注意:被await或多个宏任务分隔的更新依然分成多个批次。 -
「
flushSync是干什么的?什么时候用?」
flushSync(callback)会「跳出批处理」,同步执行callback里的更新并完成渲染(函数返回时 DOM 已更新)。典型场景:① 打印——先flushSync(() => setPrintContent(html))再window.print()(否则打印旧内容);② 更新后立刻读 DOM(但这种情况通常useLayoutEffect更合适);③ 与「要求立即看到 DOM」的第三方库集成。代价是「破坏批处理 → 多次渲染、性能下降」,所以能不用就不用。 -
「为什么 React 要批处理?」
三个理由:① 性能——减少渲染次数(3 次 setState → 1 次渲染);② 避免中间态——如果分别渲染,用户可能看到「loading=true但数据还是旧的」这类非法中间状态(界面闪烁);③ 减少 DOM 操作与布局抖动——多次渲染会触发多次「样式计算 + 布局」,合并后只做一次。此外批处理还让「一次交互只产生一次重渲染」成为默认行为,降低了开发者的心智负担。 -
「不加
flushSync,怎么在更新后立刻读 DOM?」
用useLayoutEffect——它本身就在「DOM 已更新、浏览器还未绘制」的时机执行,因此「在里面读 DOM」能拿到新值,且「在里面setState」会在绘制前同步完成(不会闪)。flushSync与它的区别是「位置不同」:useLayoutEffect是「在 Effect 阶段」,flushSync是「在事件处理里强制刷」。所以「测量尺寸后调整样式」应该用useLayoutEffect;「先渲染再打印」才需要flushSync。 -
「并发渲染让
setState更『异步』了吗?」
更准确的说法是「它有了优先级」。在 React 18 之前,「异步」只有「批处理延后一次」这一种含义;React 18 起,更新会按「优先级车道」调度:① Sync/Default 优先级(普通setState)——行为与之前一致(批处理后尽快执行);② Transition 优先级(useTransition/useDeferredValue)——可能被推迟到更晚,甚至「被打断、丢弃」。所以「异步」在并发下不仅是「延后」,还有「谁先做、能不能被打断」的语义。 -
「
StrictMode的双调用会影响setState吗?」
不会让「更新被应用两次」。StrictMode 的「双调用」是「把组件函数执行两次」(用来暴露不纯的渲染),但「更新的入队」是按「你调用了多少次setState」走的——组件函数被调用两次不代表「更新被入队两次」。不过要注意:如果在「渲染过程中调用setState」(错误做法),双调用会「让这个错误更明显」(可能直接触发无限循环的报错),这正是 StrictMode 的价值。
一句话速记
准确说法是「
setState调用同步(只是入队)、应用更新与重新渲染由调度器延后(批处理)」——所以事件处理里读到的是「闭包快照」(用函数式更新set(c => ...)或useEffect才能拿到最新值);连续三次setCount(count+1)只加 1 是因为三次传的都是同一个旧值算出的 1;React 17 只在合成事件里批处理(setTimeout/Promise/原生事件各自渲染),React 18 起全场景自动批处理(前提createRoot,但跨await仍分批);flushSync能「强制同步刷新」(打印、更新后立刻读 DOM 时用),代价是破坏批处理;「更新后立刻读 DOM」优先用useLayoutEffect;批处理的三个目的是「减少渲染、避免中间态、减少布局抖动」;并发渲染下「异步」更进一步变成「优先级调度(可被推迟/打断)」。



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