先记住这个答案
React 17 通常只在 React 管理的事件处理过程中自动批处理,Promise、定时器等回调里的多个更新常产生独立提交。React 18 使用 createRoot 的新根后,把自动批处理扩展到这些来源;仅升级包却继续使用旧 ReactDOM.render,会保留旧式行为,不能据此判断新功能失效。这里比较普通更新,不把整段跨多个 await 的业务流程说成一个不可分割批次,也不把不同有意点击默认合并。后续现代 React 根继续采用这种更新模型。
- 版本与 createRoot 入口需要一起核对
- Promise 和定时器内的相邻更新可自动批处理
- 跨异步阶段的整项业务不等于一个批次
同一个异步回调更新两个状态时会怎样
下面一个回调同时增加计数并切换开关,分别由 Promise 与定时器触发。使用现代根时,普通情况下会直接提交这一轮的完整组合;旧式根可能先提交计数变化,再提交开关变化,产生中间组合。
测量时让一次操作完成后再触发另一种来源,并关闭 Strict Mode 或明确排除其开发检查影响。不能让网络请求、其他组件状态和热更新混入实验,再把所有提交次数差异都归因于自动批处理。
import { useState } from 'react';
export default function AsyncBatching() {
const [count, setCount] = useState(0);
const [active, setActive] = useState(false);
function updateBoth() {
setCount(value => value + 1);
setActive(value => !value);
}
return <>
<button onClick={() => Promise.resolve().then(updateBoth)}>Promise 更新</button>
<button onClick={() => setTimeout(updateBoth, 0)}>定时器更新</button>
<output>{count} / {active ? '开启' : '关闭'}</output>
</>;
}先点 Promise 再点定时器,最终依次显示一与开启、二与关闭。版本差异主要体现在中间提交,而不是最终算术结果;需要结合根 API 和提交观测判断。
升级包后仍看到旧行为应先查入口
React 18 提供旧入口兼容迁移,使团队能分阶段切换。排查时确认真正挂载这个组件的根,而不是只在 package.json 看到版本十八;微前端或多个根的应用还可能混用不同入口。
React 19 已不再保留旧 render 作为常规可用入口,新项目应按当前官方 API 创建根。迁移测试应检查依赖同步读取 DOM 的代码是否暴露问题,而不是仅把计数日志从两条变成一条就宣布迁移完成。
自动批处理不能保证所有异步步骤一起完成
async 函数在 await 前后可能处于不同调度阶段,不能假定启动时设置 loading 与远端结果返回时写入数据永远只提交一次。业务本来就可能需要先显示加载状态,再显示结果,强行追求一次提交反而会破坏反馈。
若第三方同步接口要求立即读取刚更新的 DOM,可以审慎使用 flushSync,但这会主动改变正常安排并可能增加工作量。自动批处理也不会防止请求重复、恢复失败或过期结果覆盖,这些仍属于异步任务和状态建模的问题。
容易答错的地方
- 只要装上 React 18,所有旧入口都会自动变成新行为
- React 18 的根入口会影响迁移模式,必须核对实际挂载代码;不能只依据依赖版本就推断 Promise 回调中一定如何提交。
- 一个 async 函数里的所有更新永远只提交一次
- await 会把流程拆成多个阶段,其他调度也可能介入。应按实际异步边界和用户反馈需求设计状态,不能依靠过强的提交次数假设。
面试官还会怎么问?
为什么现代根里仍可能看到两条组件日志?
开发检查、其他更新或渲染尝试都可能产生额外调用,日志不是提交次数。应隔离实验并使用提交阶段观测,确认究竟有哪些界面结果被发布。
自动批处理会改变 updater 的先后含义吗?
不会把相对更新变成任意顺序。updater 仍依据队列语义组合,变化主要在适合一起处理的更新如何安排渲染与提交。
旧代码立即读取 DOM 失败时应先怎么处理?
先确认是否本应在提交后执行,以及能否调整集成流程。只有同步外部接口确实要求当前调用栈拿到新 DOM 时,再考虑有限使用 flushSync。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。