深入理解 setState 执行机制

0 阅读11分钟

文章顶部.png 作者卡片

深入理解 setState 执行机制

一、引言:对于 setState “同步/异步”的认识

在 React 开发中,setState是最基础也最容易被误解的 API。几乎所有初学者都会遇到这样一个经典面试题:

setState 是同步的还是异步的?”

如果你回答“它是异步的”,面试官可能会追问:

“那为什么在 setTimeout 里它表现得像同步?或者为什么在原生 DOM 事件中它又是同步的?”

如果你回答“它是同步的”,现实代码中 console.log(this.state) 紧接着 setState 打印出的却是旧值,又该如何解释?

这种认知的混乱源于我们习惯用时间维度(同步/异步)去定义它。事实上,setState 的本质并非时间上的延迟,而是 更新批处理(Batching) 策略的体现。React 为了性能优化,会将多次状态更新合并为一次渲染。所谓的“异步”,其实是更新被暂存到了队列中,等待当前执行栈清空后统一处理的结果;而所谓的“同步”,则是批处理机制未生效,导致每次更新都立即触发重渲染。

理解这一机制,是解决 state 值更新滞后 Bug、避免性能问题以及掌握 React 18 并发特性的基础。

二、核心机制

2.1、React 16:isBatchingUpdates

React 内部维护了一个关键的布尔值变量:isBatchingUpdates,这个变量决定了 setState 的即时行为:

  • 当处于“批处理模式”时:
    React 会将 setState 的请求放入一个更新队列(pendingUpdateQueue)中,而不是立即执行。只有在当前所有的同步代码执行完毕,React 才会统一从队列中取出所有更新,计算最终状态,并触发一次渲染。
    表现:开发者感觉 setState 是“异步”的,因为紧接着读取 state 拿不到新值。

  • 当处于“非批处理模式”时:
    React 会立即执行更新,同步触发 Diff 算法和 DOM 更新。
    表现:开发者感觉 setState 是“同步”的,且如果在一个循环中调用多次,就会触发多次渲染,造成性能损耗。

下面的流程图直观的体现了这一更新行为。

image.png

// 源码位置:packages/react-dom/src/events/ReactDOMUpdateBatching.js (React 16.14.0)
// 以下是源码精简版本,只保留了核心逻辑

import { unbatchedUpdates, interactiveUpdates } from 'react-reconciler';

// 【关键点】这是一个模块级的布尔变量,仅在 react-dom 的事件系统中有效
let isBatchingUpdates = false; 

function batchedUpdates(fn, a) {
  // 如果已经在批处理中,直接执行(支持嵌套)
  if (isBatchingUpdates) {
    return fn(a);
  }
  
  // 否则,开启批处理标志
  isBatchingUpdates = true;
  try {
    return fn(a);
  } finally {
    // 恢复标志
    isBatchingUpdates = false;
    // 如果此时有累积的更新,则触发刷新
    // 注意:React 16 的刷新逻辑相对简单,主要是同步刷
    flushSyncWork(); 
  }
}

// 供 reconciler 调用的判断函数
function isInsideBatch() {
  return isBatchingUpdates;
}

2.2、React 17/React 18:**executionContext**位掩码

在 React 16 中,我们使用的是简单的布尔值 isBatchingUpdates。而在 React 17 及之后的版本中,React 引入了 位掩码 来管理复杂的执行状态。

React 17 executionContext 用于描述“当前执行阶段”,而非优先级或并发调度本身。React 18 的优先级和并发能力主要由 lanes 模型承担,executionContext 仅作为辅助标记执行环境。

executionContext 这是一个定义在 ReactFiberWorkLoop.new.js 中的全局变量(数字类型),它通过按位或 (|) 添加状态,通过按位与 (&) 检测状态。

// React 17:
export const NoContext = /*             */ 0b0000000;
const BatchedContext = /*               */ 0b0000001;
const EventContext = /*                 */ 0b0000010;
const DiscreteEventContext = /*         */ 0b0000100;
const LegacyUnbatchedContext = /*       */ 0b0001000;
const RenderContext = /*                */ 0b0010000;
const CommitContext = /*                */ 0b0100000;
export const RetryAfterError = /*       */ 0b1000000;

// React 18:
export const NoContext = /*             */ 0b000;
const BatchedContext = /*               */ 0b001;
const RenderContext = /*                */ 0b010;
const CommitContext = /*                */ 0b100;
常量 (Hex/Binary)含义用途
NoContext (0b0000)无上下文初始状态
BatchedContext (0b0001)批处理上下文标记当前在批处理中 (对应旧版的 isBatchingUpdates)
EventContext (0b0010)事件上下文处理事件回调
DiscreteEventContext (0b0100)离散事件如点击、输入
LegacyUnbatchedContext (0b1000)旧版非批处理用于首次渲染等特殊场景
RenderContext (0b0010)渲染上下文判断渲染阶段
CommitContext (0b0100)提交上下文DOM 更新
RetryAfterError (0b1000000)错误边界当 React 捕获错误并开始重试渲染时,会设置此标志

具体的位运算逻辑如下:

开启状态 (按位或 |):
当进入批处理环境时,React 会将当前上下文与 BatchedContext 进行“按位或”操作。

// 进入事件处理
executionContext |= BatchedContext; 
// 假设之前是 0b0000 (NoContext)
// 现在变为 0b0001 (BatchedContext)

判断状态 (按位与 &): 在 scheduleUpdateOnFiber (调度更新的核心函数) 中,React 会检查当前上下文是否包含“批处理”标志。

// 源码逻辑简化
if (executionContext & BatchedContext) {
    // 如果结果不为 0,说明处于批处理中
    // 将更新暂存到队列,不立即执行
    ensureWorkScheduled(root, lane);
} else {
    // 否则,立即同步执行(React 17 中,setTimeout 会走这里)
    flushSyncCallbackQueue();
}

关闭状态 (按位异或 ^ 或 减法): 事件处理完毕后,React 会移除 BatchedContext 标志。

executionContext = executionContext ^ BatchedContext; 
// 0b0001 ^ 0b0001 = 0b0000

三、执行上下文

3.1、React 托管上下文(自动开启批处理)

在 React 合成的事件处理函数(如 onClick)、生命周期方法、以及函数组件的执行过程中,React 会在入口处分发任务前将批处理标志位设为 true

handleClick = () => {
  this.setState({ count: 1 }); // 进入队列
  this.setState({ count: 2 }); // 进入队列
  console.log(this.state.count); // 输出旧值,因为还没 flush
  // 函数执行结束,React 统一处理队列,触发 1 次渲染
};

3.2、非托管上下文(默认关闭批处理 - React 17及以前):

一旦代码执行流跳出 React 的控制范围,例如进入原生的 DOM 事件监听器、setTimeout / setInterval 回调、Promise 的 .then() 回调,React 无法自动感知这些上下文的开始。因此,在这些地方,批处理标志位(isBatchingUpdates)默认为 false

setTimeout(() => {
  this.setState({ count: 1 }); // 立即渲染
  this.setState({ count: 2 }); // 立即再次渲染
  console.log(this.state.count); // 输出最新值
}, 0);

这就是为什么在定时器中 setState 表现为“同步”且会导致多次渲染的根本原因。

四、实战复盘:一次循环调用 setState 引发的性能问题

4.1、问题现象及排查

版本信息:

  • React:16.4.1

问题描述:

一次线上工单解决过程中,发现页面中接口一直处于 pending 导致请求超时报错,但通过手动将接口请求URL复制到浏览器中访问时,接口响应时间正常,并且日志中接口也未见超时日志。

q3ltj-5uoe4 (1).gif

带着一连串疑问,是不是渲染导致了接口慢?果断打开 Chrome 控制台,分析一下页面性能。

image2023-7-11_21-5-24.png

image2023-7-11_21-5-29.png

通过性能分析工具可以发现,Script 占比最高,整整20s都在执行渲染页面假死,从 performSyncWorkOnRoot 调用看,是被同步执行了。

导致XHR接口请求被阻塞,无法执行响应,也就出现了上述接口pending的问题。

image2023-7-11_21-5-35.png

4.2、问题原因

示例代码:

// 初始状态
state = {
  params: {}
}

// 模拟 99 个数据项
const itemList = [...Array(99)].map((_, i) => ({ key: `k${i}`, value: `v${i}` }));

// ❌ 错误示范
itemList.forEach(item => {
  setTimeout(() => {
    // 问题1:直接操作了state对象引用
    let result = this.state.params;
    result[item.key] = item.value; 
    
    // 问题2:在 setTimeout 中调用 setState
    // 在 React 16 中,这里 isBatchingUpdates = false
    this.setState({
      params: result 
    });
  }, 0);
});

上述代码主要有几个问题:

1、原代码:let result = this.state.params; 只是复制了引用(指针)。随后 result[item.key] = item.value 直接修改了 React 状态树里的原始对象。

React 的 Diff 算法比较后发现引用地址没变,React 可能认为“数据没变”而跳过更新。多个定时器共享同一个被污染的对象,导致数据错乱,最终结果不可预测(可能只保留了最后一次的值,或者中间值丢失)。

2、setState  代码运行在 setTimeout 回调中,会导致批处理失效。

在 React 16 环境下,这里不属于 React 的合成事件系统,isBatchingUpdates 为 false。导致每次 setState 都立即触发一次昂贵的同步渲染,将 O(1) 的批量更新退化成了 O(N) 的串行更新。

4.3、解决方案

优化点:只触发 1 次 setState,1 次 Diff,1 次 DOM 更新。

// ✅ 最佳实践:在 JS 内存中累积数据,只触发一次 setState
const newParams = {};
itemList.forEach(item => {
  newParams[item.key] = item.value;
});

// 如果需要异步,可以在数据处理完后统一设置
setTimeout(() => {
  this.setState({ params: newParams });
}, 0);

优化后效果,问题成功解决。

image2023-7-11_21-19-30.png

五、版本演进

5.1、React 17及以前 与React 18 更新机制差异

React 17及以前:有限的批处理

  • 仅在 React 事件处理器中自动批处理。

  • 在 setTimeout、Promise、原生事件等场景中,批处理失效(即“批处理泄漏”),导致上述的性能事故频发。

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

  • 全场景覆盖:无论代码运行在何处(Promise、setTimeout、原生 addEventListener、甚至自定义事件),React 18 默认都会开启批处理。

  • 效果:回到上面的“实战复盘”案例,如果在 React 18 中运行那段“错误代码”(循环调用 99 次 setState),React 会自动将这 99 次更新合并为 1 次 渲染。页面不再卡死。

5.2、并发模式下的优先级调度与强制同步**flushSync**

React 18 的更新机制不仅仅是“合并”,更是为了支持并发渲染(Concurrent Rendering)。

  • 优先级调度(Priority Lanes):
    在并发模式下,setState 可以携带优先级信息。React 能够中断低优先级的渲染(如大数据列表渲染),优先响应高优先级的交互(如输入框打字)。

    • startTransition:允许开发者将某些状态更新标记为“非紧急”(低优先级)。

    • useDeferredValue:用于延迟更新某些耗时的派生值。

  • 逃生舱:flushSync
    既然默认都是异步批处理,那如果我真的需要立即拿到更新后的 DOM 节点(例如测量滚动位置、聚焦输入框)怎么办?
    React 18 提供了 ReactDOM.flushSync()

import { flushSync } from 'react-dom';

handleScroll = () => {
  flushSync(() => {
    setPosition(newPos); // 强制同步更新,立即触发渲染
  });
  // 此处可以直接读取最新的 DOM 布局信息
  measureLayout();
};

警告:flushSync 会禁用并发特性,强制浏览器同步重绘。滥用会导致性能回退到 React 17 甚至更差,仅在极少数涉及 DOM 测量的场景下使用。

5.3、总结对比

特性React 16 经典React 17 过渡React 18(自动 + 并发)
数据结构booleannumber(位掩码 Bitmask)number(位掩码 Bitmask)
核心机制isBatchingUpdatesexecutionContext(执行阶段标记)executionContext(执行阶段) + lanes(优先级系统)
判断逻辑if (!isBatchingUpdates)if (executionContext & BatchedContext)不再依赖 executionContext 判断批处理,主要由 调度系统统一控制(Scheduler + lanes)
状态表达能力仅能表示是否批处理可表达多种执行阶段(render / commit / event / batch)同左(executionContext 未显著增强)
是否包含优先级有基础调度(expirationTime),但不在 executionContext 中由 lanes 表达(与 executionContext 解耦)
批处理覆盖范围React 合成事件React 合成事件 + unstable_batchedUpdates全场景自动批处理(基于统一调度)
并发支持不支持架构准备阶段(未默认启用)支持(Concurrent Rendering)
调度核心同步递归同步 + 简单调度Scheduler + lanes + 可中断渲染

六、总结:最佳实践与避坑指南

深入理解 setState 的执行机制,能帮助我们在开发及排查问题中提供更好的支持。以下是最佳实践清单:

  1. 摒弃“同步/异步”的执念:始终假设 setState 是批处理的。不要依赖 setState 后立即读取 state 的值。

  2. 首选函数式更新:当新状态依赖于旧状态时,务必使用 setState(prev => ...) 形式,以确保获取到最新的状态快照。

  3. 大数据量更新策略:无论 React 版本如何,对于循环或批量数据,先在内存中处理好数据,再一次性 setState,对于性能提升非常有帮助。

  4. 拥抱 React 18:尽快迁移至 React 18+,享受自动批处理带来的性能兜底,移除代码中手动的 unstable_batchedUpdates

  5. 慎用 flushSync:将其视为最后的手段,仅在必须立即读取 DOM 布局且无法通过 useLayoutEffect 解决时才使用。

setState 看似简单,实则蕴含了 React 性能优化的核心哲学。从手动控制到自动批处理,再到并发调度,理解这一演进过程,就是理解 React 如何平衡“开发体验”与“运行效率”的过程。

最后,感谢各位的阅读!

技术之路漫漫,文中有不当之处或值得探讨的细节,还望大家在评论区加以指正,让我们共同学习,一起进步。

参考资料: