setState 到底是不是异步的?这题从 React 16 问到 18,标准答案早就变了

"setState 是同步还是异步的",这大概是前端面试里生命力最顽强的一道题。
网上一半的回答还停留在"因为用了虚拟 DOM 所以是异步的"这种说法上。虚拟 DOM 和更新是不是同步执行,其实半毛钱关系都没有。
这道题真正的坑在于,它的标准答案在 React 18 之后已经变了。而且它经常跟另一个高频问题混着问:Fiber 的"可中断渲染"。很多人一紧张就把两件事答成一件事,越答越乱。今天把这两层拆开讲。
先纠正一个常见的错误认知
setState 本身从来都不是异步函数。调用它不会返回 Promise,也不会进事件循环的任务队列。它的执行是同步的,被延迟的是渲染(re-render),不是这次调用本身。
React 做的事叫批处理(batching):把一次事件里触发的多次状态更新合并成一次渲染,而不是更新一次就渲染一次。这是纯粹的性能优化,跟"异步"是两个概念。验证方式很简单:
jsx
function handleClick() {
console.log(count); // 这里打印的还是旧值,更新是同步排队、渲染是批处理延后
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
// 一次点击只触发一次渲染
// 传值 setCount(count + 1) 连调三次最终只 +1
// 传函数 setCount(c => c + 1) 才会正确叠加成 +3
}
这里藏着一个连环坑。直接传值 setCount(count + 1) 连续调三次,最终只生效一次,因为三次调用读的都是闭包里同一个旧 count。只有传函数 setCount(c => c + 1),React 才会把更新函数排队,按顺序基于上一次的结果计算,三次才会真正叠加成 +3。这也是面试官最爱追问出的分水岭。
React 16/17 时代:批处理只在合成事件里生效
这是很多人栽跟头的地方。React 17 及更早版本,批处理只发生在 React 自己的合成事件处理器(onClick 之类)和生命周期方法里。一旦更新发生在这个范围之外,比如原生 addEventListener 监听器、Promise.then 回调、setTimeout 回调,批处理就会失效,同样的三次 setState 会触发三次独立渲染。
所以同一段"三次 setState"代码,放进 onClick 里和放进 setTimeout 里,观察到的渲染次数完全不同。不少人没意识到这是版本差异,还以为自己代码写错了。
React 18:自动批处理把这条边界抹平了
React 18 引入的自动批处理(automatic batching),把批处理范围扩展到了所有场景。不管更新发生在合成事件、原生事件监听器、Promise、setTimeout,还是别的地方,只要是在 createRoot 渲染的应用里,统统会被合并成一次渲染。四种触发场景下 React 17 与 18 的实际渲染次数对比如下:

如果确实需要绕开批处理、立刻拿到更新后的 DOM(比如要马上读取最新布局),官方提供了 flushSync 作为逃生舱。但这是手动申请拆开合并,代价是放弃了批处理的性能收益,不是默认行为。
批处理和 Fiber 的可中断渲染是两件事
很多人把批处理和 Fiber 的可中断渲染混成一件事回答,这是连环问最容易翻车的地方。
批处理解决的是"同一批更新要不要合并成一次渲染"。Fiber 解决的是另一个问题:一次渲染任务如果计算量很大,会不会长时间占用主线程,导致掉帧、按键卡顿。这两层完全独立。
Fiber 架构的核心,是把原来一次性、递归、不可中断的协调(reconciliation)过程,拆成很多个可以暂停恢复的工作单元,并且维护两棵树(结构见文章开头那张图):
- current 树:当前屏幕上显示的内容对应的 Fiber 树
- work-in-progress 树:React 在内存里正在构建的下一版本
渲染完成后两棵树整体切换指针,这叫双缓冲。用户看到的永远是完整的一整棵树,不会看到渲染到一半的中间态。
渲染流程分两个阶段。render 阶段负责遍历 Fiber 树、计算 diff,不碰真实 DOM;commit 阶段把变更实际应用到 DOM,同步执行,不可中断。只有 render 阶段是可中断的:React 会在每个工作单元之间检查时间预算,大致对齐 16ms 一帧,预算用完就让出主线程,等浏览器画完这一帧再回来接着算。要是这期间来了更高优先级的更新,比如一次点击,React 甚至会直接扔掉正在构建的 work-in-progress 树,先处理紧急的那个。commit 阶段因为要直接操作真实 DOM,必须一次性做完。
React 18 把优先级管理做成了 lanes(车道)模型。点击、输入这类交互是最高优先级车道,普通的数据更新走默认车道,startTransition / useTransition 包裹的更新会被标记为可被打断的低优先级车道。这也是为什么 useTransition 常被拿来和批处理放一起问:它们都是 React 18 的东西,但一个是标记这次更新不紧急、允许被打断,另一个是合并多次更新减少渲染次数,仍然是两件独立的事。
面试官连环问的常见路数
这道题很少只问一层,通常会顺着往下追:
- setState 是同步还是异步的?先纠正提问本身的措辞:调用是同步的,被延迟的是渲染,这叫批处理。
- React 18 前后有什么区别?17 及之前批处理只在合成事件内生效,18 是自动批处理,全场景生效。
- 如果就是要立刻拿到更新后的 DOM 怎么办?
flushSync,代价是放弃批处理带来的性能收益,慎用。 - 批处理和 Fiber 时间切片是一回事吗?不是。前者管渲染次数,后者管单次渲染会不会阻塞主线程,靠 render/commit 两阶段划分、双缓冲树、lanes 优先级实现。
按这个顺序分层回答,比死记硬背更容易在追问里站住。我自己刷模拟面试题(用的是面灵)时就碰到过这道题的好几种变体追问。练下来的感觉是,这题真正考的不是记忆力,是有没有把批处理和可中断渲染这两层模型在脑子里分清楚。
一句话版本
setState 调用是同步执行的。React 18 起,任何场景下的连续更新都会被自动批处理成一次渲染。渲染本身如果计算量大,会被 Fiber 拆成可中断的工作单元,按 lanes 优先级调度,在 16ms 帧预算内让出主线程。批处理管的是要不要合并渲染,Fiber 管的是渲染过程能不能被打断,这是两条互相独立、又经常被放在一起考的线。