Notes

React 18 自动批处理(Automatic Batching)与 flushSync

一句话结论:React 18 默认会把同一“事件回合”(tick)里产生的多次更新合并渲染,以减少渲染次数;当你必须立刻把更新落到 DOM(例如与第三方库交互、读取布局)时,用 flushSync 作为“强制同步提交”的逃生阀,但要慎用。

1. 它解决的问题是什么?

在 React 18 之前,只有 React 合成事件(onClick/onChange 等)里会批处理更新;而在 setTimeout / Promise / 原生事件 里,更新可能会多次渲染

React 18 把批处理扩展为“默认更广泛的批处理”,让你在更多场景下:

  • 更少的渲染次数(性能更好)
  • 更一致的行为(心智负担更小)

2. React 18 的自动批处理:到底批到哪里?

2.1 典型现象(React 18)

  • React 合成事件里多次 setState:批处理(一次 render)
  • setTimeout 回调里多次 setState也会批处理(一次 render)
  • Promise.then / async/await 之后的多次 setState也会批处理

直观记法:React 18 更倾向于“这一轮任务里触发的更新一起算”。

2.2 你需要理解的边界

  • 批处理发生在 render/commit 的调度层面:多次更新会被合并成一次渲染提交
  • 但并不是“永远只渲染一次”:
    • 如果你跨了多个 tick(多个宏任务/多次事件触发),仍然会多次渲染
    • flushSync 会打断批处理(见下文)

3. 为什么你会“感觉行为变了”?(项目常见坑)

坑 1:第三方库需要立刻读 DOM,但 DOM 还没更新

典型场景:

  • 调用 setState 后马上初始化/刷新一个第三方 UI(图表、富文本、地图)
  • 或者你需要在更新后立刻 getBoundingClientRect() 读取布局

在 React 18 的自动批处理下,这个“马上”可能发生在 DOM commit 之前。

坑 2:依赖“渲染次数”的副作用(不推荐的写法)

例如把某些逻辑隐式地绑定到“每次 setState 都会触发一次渲染/一次 effect”。React 18 更强的批处理会让这种假设失效。

4. flushSync:什么时候用?怎么用?

flushSync 的作用:

  • 让 React 立即处理并提交你包裹的更新
  • 强行把更新从“可能批处理/延后 commit”变成“现在就 commit”

示例(伪代码):

import { flushSync } from 'react-dom'

function onOpen() {
  flushSync(() => {
    setOpen(true)
  })
  // 这里开始,DOM 已经提交(open=true 对应的节点已存在)
  thirdPartyWidget.mount(document.querySelector('#panel')!)
}

使用建议(架构层面的约束)

  • 优先解法:把“读 DOM / 调第三方库”的逻辑放到
    • useLayoutEffect(需要同步读布局)
    • useEffect(不需要同步)
  • 只在必要时使用 flushSync
    • 例如必须在同一调用栈内拿到最新 DOM
    • 或外部系统的 API 约束无法改变

记住:flushSync 是“逃生阀”,不是常规工具。滥用会降低并发能力,影响响应性。

5. 与并发(Concurrent)/ Transition 的关系

  • 自动批处理解决的是“同一轮更新少渲染
  • Transition 解决的是“让不紧急的更新可被打断/延后”,优先保证交互
  • flushSync 则相反:它要求“立刻同步提交”,会压缩 React 的调度空间

因此在体验治理上通常是:

  • 大型更新:考虑 Transition
  • 必须立刻落 DOM:才用 flushSync

6. 自检清单(Checklist)

  • 是否存在 setState 后立刻读 DOM 的逻辑?能否迁移到 useLayoutEffect
  • 是否存在依赖“渲染次数/时序”的隐式逻辑?能否改成显式状态机/显式 effect?
  • 是否因为第三方库被迫用 flushSync?是否能封装成适配层,避免扩散?

关联阅读

cd ..