React 18 自动批量更新为何在定时器里失效:原理与写法

编程狮(w3cschool.cn) 2026-10-25 07:05:42 浏览数 (44)
反馈

先给结论:两个 setState 写进同一个 setTimeout 回调,React 18 只触发一次渲染,React 17 会触发两次。如果你在 React 18 里仍然观察到两次,原因几乎总是三个之一——入口还在用旧的 ReactDOM.render、开发环境被 StrictMode 的双调用干扰了计数、或者中间插了一次 flushSync。

React 18 自动批量更新为何在定时器里失效?

自动批量更新指的是 React 把同一个任务里的多次状态更新合并成一次渲染。React 17 及更早版本只在 React 自己的事件处理函数里做这件事;放在定时器、原生事件监听或 Promise 回调里的更新,每调一次 setState 就渲染一次。React 18 把这条规则推广到所有场景,前提是入口换成 createRoot。

本文基于 React 18 起的公开行为,示例用函数组件加计数器复现。所有输出为预期结果(未在本机执行),读者需要按文中的命令自己跑一遍确认。适用边界也写清楚:服务端渲染框架、并发特性与 StrictMode 的行为不在同一层,不能混着判断。

先看结论

  • 入口决定行为:只有用 createRoot 挂载,自动批量更新才生效;ReactDOM.render 保持旧行为。
  • 定时器里同样合并:React 18 不再区分更新来自哪里,同一任务内的多次更新合并为一次渲染。
  • 两次渲染不等于失效:StrictMode 在开发环境下故意双调用渲染逻辑,计数会翻倍。
  • 需要同步读 DOM 时用 flushSync,它是一次显式的、跳出批量更新的逃生舱。

React 的事件与状态体系较大,需要系统过一遍可以看 React 教程,重点看状态更新与渲染流程两章。

一、先把现象复现出来

把问题定死:一个组件有两个状态,在同一个定时器回调里各改一次,渲染函数到底执行几次。

import { useRef, useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)
  const [flag, setFlag] = useState(false)
  const renders = useRef(0)
  renders.current += 1
  console.log('render:', renders.current)

  function handle() {
    setTimeout(() => {
      setCount((c) => c + 1)
      setFlag((f) => !f)
    }, 0)
  }

  return <button onClick={handle}>点了 {count} 次</button>
}

这段做的是在定时器里连续触发两次状态更新,并用 ref 累计渲染函数被调用的次数。关键点是 renders.current += 1 写在渲染函数体内,每渲染一次就加一。

import { createRoot } from 'react-dom/client'
import Counter from './Counter.jsx'

createRoot(document.getElementById('root')).render(<Counter />)

用 createRoot 挂载时,点一次按钮,控制台预期只打印一次 render: 数字加二之类的一行——准确说是只新增一次渲染,数字从 n 跳到 n+1。

如果把入口换成旧的写法,同样的点击会新增两次渲染:

import ReactDOM from 'react-dom'

ReactDOM.render(<Counter />, document.getElementById('root'))

这就是"定时器里自动批量更新失效"最常见的真相:不是机制失效,而是入口没换。

二、createRoot 与版本的适用边界

判断自动批量更新是否生效,第一步永远是看入口。

React 18 起,官方把并发渲染相关能力统一收敛到 react-dom/client 的 createRoot 上。createRoot 挂载的树默认对所有更新做批处理,不再区分更新是不是来自 React 事件。旧入口 ReactDOM.render 被保留为兼容模式,行为与 17 完全一致,因此在定时器里依然是每次 setState 一次渲染。

版本方面需要注意的边界:

  • React 18.0 起提供 automatic batching,后续大版本沿用这套行为;
  • 只要入口是 createRoot,定时器、原生事件、Promise 回调里的更新都会被合并;
  • 同一个页面里混用两种入口时,两棵树的批处理行为各自独立,这也是排查时最容易忽略的一点。

还有一种常见误判:把两个 setState 写在 await 的两侧。await 会把后续代码切到新的微任务,两次更新落在不同任务里,本来就不该合并。这是任务边界的问题,不是批量更新失效。

三、flushSync 与 StrictMode 的两个可疑现象

第二个坑来自 StrictMode。开发环境里 StrictMode 会故意对渲染逻辑做双调用,用来暴露副作用里的隐患。于是你的 renders.current 会比预期多一倍,看上去像"合并失败"。判断方法很简单:把 StrictMode 去掉再数一次,或者直接看生产构建的输出。

第三个坑来自 flushSync。某些场景必须在状态更新后立刻读取真实 DOM,比如测量元素高度后再决定滚动位置。这时需要显式跳出批处理:

import { flushSync } from 'react-dom'

function handle() {
  flushSync(() => {
    setCount((c) => c + 1)
  })
  // 到这里 DOM 已经更新,可以读取
  const height = document.querySelector('.panel').offsetHeight
  setFlag((f) => !f)
}

flushSync 的含义是:传进去的更新立刻同步刷新到 DOM,不参与合并。代价是它强制一次额外的渲染,用多了会把自动批量更新的收益全部抵消,所以只应该在确实需要同步读取 DOM 的那一行前面用。

三个现象的区别:

  • 入口没换 → 每次 setState 都渲染,整个应用都这样;
  • StrictMode → 只在开发环境、且渲染逻辑被双调用,生产构建消失;
  • flushSync → 只有被包住的那次更新提前刷新,其余仍然合并。

两次渲染的三种成因与各自判断依据

四、失败排查顺序与验证方式

把排查固化成一个可复现的流程(预期结果,未在本机执行):

  • 安装:确认 react 与 react-dom 版本一致,npm ls react react-dom 不应出现两个大版本;
  • 配置:全局搜索 ReactDOM.render,命中即视为旧入口,替换成 createRoot;
  • 检查:在渲染函数里打印渲染次数,关闭 StrictMode 后再数一次做对比;
  • 运行:点击一次按钮,观察控制台新增的行数;
  • 预期:createRoot 下新增一次,旧入口下新增两次;
  • 失败:仍然两次,且入口已换,检查是否有 flushSync,或两次更新被 await 切开;
  • 修复:移除不必要的 flushSync,或把两次更新合并到一个同步块内;
  • 验证:改用生产构建 npm run build 后重新数一次,排除 StrictMode 干扰。

微任务与宏任务的执行顺序容易记混,翻 JavaScript 速查手册 里的事件循环一节可以核对一遍。

性能上要记住一点:自动批量更新省下的是渲染次数,不是状态计算次数。如果你的渲染函数里做了重计算,批处理只是让重计算少跑几次,该用 useMemo 的地方仍然要用。

从点击到合并渲染的更新流程

总结

React 18 的自动批量更新把"什么时候合并"从"是不是 React 事件"改成了"是不是同一个任务",入口换成 createRoot 就自动生效。

四条带走:

  • 先看入口,createRoot 与 ReactDOM.render 行为不同;
  • 定时器里的更新在 React 18 下是会合并的,观察到两次先查 StrictMode;
  • flushSync 是逃生舱,只在必须同步读 DOM 时用;
  • await 会切开任务,两次更新落在不同任务里本就不该合并。

下一步建议把渲染次数的统计封成一个调试用的小组件,需要排查时挂上去,不用每次手改代码。

常见问题

Q:怎么判断自动批量更新有没有生效?

A:在渲染函数里用 ref 累计次数,点一次按钮看新增几行日志。新增一次说明合并成功,两次说明没生效。

Q:React 18 里一定要用 createRoot 吗?

A:想用自动批量更新就必须用它。旧入口是兼容通道,行为与 17 一致,不会启用新机制。

Q:为什么我数出来的渲染次数总是翻倍?

A:先确认是不是开发环境开了 StrictMode。它故意双调用渲染逻辑来暴露副作用问题,生产构建不会出现。

Q:flushSync 会不会让性能变差?

A:会。它强制一次同步渲染,等于放弃这次合并。只在必须立刻读 DOM 的地方用,不要用它包裹大段逻辑。

Q:await 之后的 setState 为什么没被合并?

A:await 把后面的代码切到新的微任务,两次更新不在同一个任务里,本来就不属于合并范围。

延伸学习

想把这个机制吃透,可以按这个顺序来:

  1. 想把状态与渲染这条线做扎实,腾讯CodeBuddy+AI设计网页实战课程 可以跟一遍;
  2. 读一篇相关笔记 React 入门怎么练,看别人怎么理解组件更新;
  3. 想随手验证一段异步逻辑,用 JavaScript 在线运行 跑一遍最快。

0 人点赞