文章

setState 同步异步与批量更新深入

面试向讲透「setState 是同步还是异步」:更新同步入队、渲染异步批量; React 17 只在合成事件里批量,18 起 createRoot 全面自动批处理; 批处理窗口是一个同步执行块而非整个 async 函数;值形式与函数式更新的经典坑; flushSync 的语义与官方四条 caveat;以及类组件合并 vs hooks 替换。

setState 同步异步与批量更新深入

一句话概括

面试官问”setState 是同步还是异步”,最忌讳直接答”异步”。准确的说法是一句话:setState 调用本身是同步的(更新立刻入队),由此触发的重新渲染才是异步且被批量合并的。

这题其实在考三件事:

  1. 你能不能分清”更新入队”和”组件渲染”是两件不同的事;
  2. 你知不知道 React 17 和 18+ 的行为差异(18 起全面自动批处理);
  3. 你知不知道唯一能强制同步的出口是 flushSync,以及它的代价。

顺便说一个很多人栽过的地方:函数组件里 setCount(1) 之后 console.log(count) 读到旧值,很多人以为是”异步”,其实那是闭包——count 是本次渲染的一个常量,和异步没有半点关系。把这点讲出来,面试官基本就知道你是真懂而不是背题。

核心知识点

1. 先把名词对齐:同步的是”入队”,异步的是”渲染”

1
2
3
4
5
6
7
8
9
10
function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);   // 这一行同步执行完毕,更新已经进了 fiber 的更新队列
    console.log(count);    // 依然打印 0
  };

  return <button onClick={handleClick}>{count}</button>;
}

拆成两句话:

  • setCount(...) 是个普通函数调用,执行完就返回。把更新对象插进更新队列是同步动作,不存在”延迟入队”。
  • count 是 useState 在本次渲染时给出的值,不是 getter。后面怎么读都是这一次渲染的快照,这是闭包,不是异步。

真正被推迟的只有一件事:用新队列重新渲染组件。而”批量”就是在这个推迟过程中,把同一个批次里的多次更新合并成一次渲染。

一句话给面试官的答案:更新同步入队,渲染异步批量。批处理的目的是”一次用户交互里改 N 个 state,只渲染 1 次”。

2. React 17 及以前:只有 React 自己的事件里才批量

老规则的核心是”看这次更新发生在哪”:

更新发生的位置React 17 及以前
React 合成事件(onClick / onChange 等)批量,合并成一次渲染
React 生命周期函数批量
setTimeout / setInterval 回调不批量,每个 setState 各渲染一次
Promise.then / async 回调不批量
原生 addEventListener不批量

原因很朴素:React 只在自己的合成事件系统里挂了个”正在批处理”的标志位,事件处理函数跑完后再统一 flush。你把更新扔进 setTimeout,标志位早没了,React 只能来一个更新就渲染一次。

所以那个年代会有个著名的怪现象:同样的三行 setState,写在 onClick 里渲染 1 次,写在 setTimeout 里渲染 3 次,中间几次渲染还是”半新半旧”的状态。库作者只能靠 unstable_batchedUpdates 手包一层来兜。

那”setState 有时是同步的”这个说法从哪来?来自类组件的一个实现细节:

1
2
3
4
5
6
// React 17:类组件在 setTimeout 里能同步读到刚 set 的值
setTimeout(() => {
  this.setState(({ count }) => ({ count: count + 1 }));
  console.log(this.state);        // React 17 打印 { count: 1 }(已经更新了)
  this.setState(({ flag }) => ({ flag: !flag }));
});

因为 this.state 是个可变对象,没批量时 React 立刻渲染,属性就被就地改掉了,所以”能同步读到”。React 18 因为在这里也批处理了,同样的代码打印的是 { count: 0 }——这也是官方升级指南里点名的破坏性变更之一。

注意:函数组件根本不存在这个问题——const [count] = useState() 拿到的是一个普通常量(本次渲染的快照),批不批处理都读不到新值。“setState 同步还是异步”争论的真正源头就是类组件的 this.state,而它在 hooks 时代已经不复存在了。 这句话答出来,这道题就答到根上了。

3. React 18 起:自动批处理(Automatic Batching)

React 18 换了并发渲染器(由 createRoot 开启),批处理不再看更新来源:setTimeout、Promise、原生事件、任何异步回调里的更新,默认都合并。

1
2
3
4
5
// React 18/19:setTimeout 里改两个 state,只渲染一次
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
}, 1000);

⚠️ 前提是用了 createRoot。还用老的 ReactDOM.render 就没这个行为;不过 React 19 已经把 ReactDOM.render / hydrate / findDOMNode 全删了(实测 react-dom@19.2.8 里这几个都是 undefined),所以 React 19 里自动批处理是默认且唯一的,没有”降级开关”。

实测(react-dom@19.2.8 + 真实 DOM 环境):

场景渲染次数
onClick 里连写 2 次 setState1
setTimeout 回调里连写 2 次 setState1
连续两次独立的 flushSync2

顺带一句:unstable_batchedUpdates 在 19 里仍然存在(实测还是函数),但已经不需要了,新代码别写——名字里的 unstable 就是警告。

4. 批处理的边界:一个”同步执行块”,不是整个 async 函数

这是这道题最容易被讲错、也最容易加分的点。

批处理窗口是当前这一次宏任务里的同步执行段。一旦 await 到真正的异步边界(网络返回、定时器),React 会先把这一轮的更新 flush 掉,再进入下一个任务段。

1
2
3
4
5
6
7
async function load() {
  setLoading(true);                      // 第 1 次渲染(哪怕 loading 通常只闪一下)
  const res = await fetch('/api/list');  // 让出主线程,React 已经渲染过 loading 了
  const data = await res.json();
  setList(data);                         // 这两行在同一个同步块里
  setLoading(false);                     // → 合并成第 2 次渲染
}

实测结论:跨宏任务 await 的两次更新各自渲染一次(计数 1 → 2);而网络回来后同一个同步块内的多次 set 依然合并成一次渲染。

所以面试可以这样答:“React 18 的批量是 per-task 的,不是把整个 async 函数当成一个事务。所以 loading 和最终数据之间通常还会有一次中间渲染——这不是漏了批处理,恰恰是我们需要的。”

5. 值形式 vs 函数式更新:批处理里最经典的坑

1
2
3
4
5
6
7
8
9
const [count, setCount] = useState(1);

// ❌ 结果只 +1:两行读的是同一个渲染快照里的 count
setCount(count + 1);
setCount(count + 1);

// ✅ 结果 +2:每个 updater 依次拿到"上一个待处理值"
setCount(c => c + 1);
setCount(c => c + 1);

实测:从 count = 1 开始,值形式连写两次 → 结果是 2;函数式连写两次 → 结果是 3。

关键的一句解释:这不是批处理的 bug,是闭包和批处理叠加的必然结果。 两个 count + 1 在入队时就已经用同一个旧快照算完了,等于把同一个 2 排了两遍;而函数式更新把计算推迟到渲染时按队列顺序执行,每次都能拿到上一次的结果,天然可叠加。

由此得到一条可以直接背的规则:只要新值依赖旧值,一律用函数式更新。 哪怕当前不在批处理里也一样写。

6. flushSync:唯一能强制同步的出口

1
2
3
4
5
6
7
import { flushSync } from 'react-dom';   // 是 react-dom,不是 react

flushSync(() => {
  setCount(c => c + 1);
});
// 执行到这里,DOM 已经更新完毕,可以立刻读
console.log(ref.current.getBoundingClientRect().height);

它的作用是:立刻 flush 这个回调里的更新并完成 commit,代价是主动放弃了批处理带来的性能收益。

官方 caveats 里这几条必须知道(面试高频追问):

  • 可能强制 pending 的 Suspense 边界显示 fallback;
  • 可能顺带执行 pending 的 Effect,并把 Effect 里的更新也同步应用;
  • 必要时会 flush 回调之外的更新(比如点击事件里遗留的 pending 更新);
  • 在渲染过程中调用无效:在组件函数体、useEffect / useLayoutEffect、类生命周期里调用,React 不会 flush,只会打警告。

最后一条实测:在 useEffect 里包 flushSync(() => setN(1)),控制台会警告 flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering...,更新照样生效,但走的是正常调度,不是同步的。

还有两个实测细节,讲出来很加分:

1
2
3
4
5
6
// 1) 回调内部依然会合并,它是"在回调结束处 flush",不是每行都 flush
flushSync(() => { setA(1); setB(2); });   // → 1 次渲染

// 2) 想真的每次都同步,得写两次独立的 flushSync
flushSync(() => setA(1));                 // → 渲染
flushSync(() => setB(2));                 // → 又一次渲染

那什么时候该用?只有”更新后必须立刻读到 DOM”的时候:列表新增一项后马上 scrollIntoView;官方例子里的 onbeforeprint——要把打印样式切过去再调 window.print();以及某些要求同步 DOM 的第三方库。除此之外一律别用。

7. 顺手一起被追问的:类组件的”合并” vs hooks 的”替换”

 写法语义
类组件this.setState({ name })浅合并进 this.state,其他字段保留
hookssetObj({ name })整体替换,其他字段直接丢失
1
2
3
4
const [user, setUser] = useState({ name: 'A', age: 18 });

setUser({ name: 'B' });                    // ❌ age 没了
setUser(prev => ({ ...prev, name: 'B' })); // ✅ 手动展开合并

这也是 hooks 里更推荐函数式更新的第二个理由:不只是为了批处理叠加,还因为只有 updater 能稳定拿到 prev。

8. StrictMode 下 updater 会被调用两次

官方 StrictMode 文档明确列了开发环境下被双调用的纯函数:组件函数体、传给 useState / setter / useMemo / useReducer 的函数、以及部分类方法。也就是说:

1
2
3
4
5
6
setCount(c => {
  track(c);            // ❌ updater 里写副作用,开发环境会被执行两次
  return c + 1;
});

setCount(c => c + 1);  // ✅ updater 保持纯函数

推论:别再往 updater 里塞日志、埋点、改外部变量。这和”reducer 必须是纯函数”是同一条规则——React 需要靠重复执行来验证你的纯函数是否真的纯。

其实你每天都在用

  • 提交表单时一次性 setLoading(true) + setError(null) + setData(null),界面只闪一下 → 自动批处理在起作用。
  • await fetch 之前 setLoading(true),回来再 set 数据 → 中间那次 loading 渲染是必要的,不是”多余渲染”。
  • 列表点”加载更多”:setList(prev => [...prev, ...more]) 和 setPage(p => p + 1) 一起写 → 合并成一次。
  • 想加 2 却只加了 1:setCount(count + 1) 写了两行 —— 改成函数式立刻好,跟 React 版本无关。
  • 弹窗里新增一条记录后要滚到底部,scrollIntoView 找不到新节点 → 没做 flushSync(或者该用 useLayoutEffect)。
  • 用 window.print() 导出前切换打印样式,打印出来还是旧样式 → 官方推荐用 flushSync 的典型场景。
  • 严格模式下埋点日志打了两遍 → 十有八九写在 updater 或 reducer 里了。
  • 类组件时代 this.setState({ a: 1 }) 不会覆盖 b,迁到 hooks 后直接 setObj({ a: 1 }) 把 b 弄丢了 → 这是迁移时最常见的翻车点。

常见误解(FAQ)

❌ 误区1:”setState 是异步的,所以 console.log 读到旧值。”

读到旧值的原因是闭包:count 是本次渲染的常量快照,不是响应式的 getter。准确表述是”同步入队、异步批量渲染”。如果你答”因为 setState 异步所以读不到新值”,面试官追问一句”那 setState 函数本身返回的是什么”就露馅了。

❌ 误区2:”React 18 之前 setState 在 setTimeout 里是同步的。”

准确说法是:类组件在 React 17 的 setTimeout 里确实能”同步读到新值”,但那是 this.state 被就地改写的副作用,不是 setState 变成了同步函数。 setState 从来都是同步入队,变的只是 flush 时机——React 17 在这里不批处理,所以更新立刻渲染、this.state 立刻被改掉;React 18 也批处理了,就读不到了。而函数组件从始至终都读不到新值。把这个层次分清,才解释得清”为什么中间会渲染出半新半旧的状态”。

❌ 误区3:”React 18 之后所有 setState 都会合并成一次渲染。”

批处理是 per-task 的,不是把整个 async 函数当一个事务。跨 await 的真实异步边界会各自 flush,所以 setLoading(true) → await → setData() 依然是两次渲染。这也解释了为什么”开了自动批处理还是有中间态”。

❌ 误区4:”setCount(count + 1) 写两次只加 1,是 React 18 引入的 bug。”

不是。React 17 在 onClick 里同样是只加 1——只要在同一次渲染快照里读两次 count,结果就一样。这从来是闭包问题,不是批处理问题。同理,useState 的 setter 都不是”合并器”,对象要自己展开。

❌ 误区5:”flushSync 能把 setState 变成同步的,可以随便用。”

三处代价:显著伤性能;可能强制 pending 的 Suspense 边界掉回 fallback;在渲染中(组件函数体 / Effect / 类生命周期)调用只会打警告,根本不 flush。另外它也不是”每行同步”——回调内部仍然合并,只有回调结束才 flush 一次。官方原话就是把它当作 last resort。

❌ 误区6:”hooks 的 setState 传对象会自动合并,跟类组件一样。”

完全相反。类组件的 setState 是浅合并,hooks 是整体替换。所以 class 迁 hooks 时,凡是 setState({ 部分字段 }) 的写法都得补上 ...prev,否则字段会静默丢失(不报错,最难查)。

❌ 误区7:”unstable_batchedUpdates 已经过时了,React 19 删掉了。”

实测 react-dom@19.2.8 里它还在,只是因为自动批处理已默认开启而不再需要。别答”已删除”,这种”记得住结论但记错细节”的答案反而会让面试官怀疑其他部分是背的。

一句话总结

setState 同步入队、异步批量渲染——React 18 起自动批处理覆盖了 setTimeout / Promise / 原生事件等所有来源,但批量窗口只是一个同步执行块(跨 await 的宏任务边界会各自 flush);新值依赖旧值一律用函数式更新,要立刻读 DOM 才用 flushSync,且它只是最后手段。

本文由作者按照 CC BY 4.0 进行授权