彻底搞懂setState到底是同步还是异步(一)

前言

首先明确一下:

所谓的同步还是异步其实指的是调用setState后能否马上得到更新后的值,即是否立即调用render 函数渲染视图;

能得到最新值则为同步,不能得到最新值则为异步;

而不是指的setState这个函数是同步还是异步,单纯的说 setState 函数肯定是同步的。

开始阅读前,有两点需要注意:

  • 而且因为hooks 存在闭包的问题,容易混淆视听,这里先暂时使用class 组件的形式作为示例,当完全明白class 组件的setState 原理后,再去深入看hooks 你会有一种原来如此的感觉。
  • 因为React v18 引入了自动批处理功能, setState 的表现与 v18 版本以下的版本完全不一样,所以本文会先讲解 v17 版本的 setState 原理,之后再对比 v18 和 v17 版本的差异性。

大家好,我是【前端探险家克鲁】。微信公众号、知乎、掘金、CSDN同名,欢迎查看我的个人简介,一起学习提升。

注意以下代码运行在 react@17.0.2 react-dom@17.0.2 下。

一道面试题

下面先来看一道经典的面试题:以下代码中,点击按钮后控制台输出什么?

javascript 复制代码
import React from 'react';
import './App.css';

class AppClass extends React.Component {
  state = {
    count: 0,
  };

  handleClick = () => {
    this.setState({ count: 1 });
    console.log('count: ', this.state.count);

    this.setState({ count: 2 });
    console.log('count: ', this.state.count);

    setTimeout(() => {
      this.setState({ count: 3 });
      console.log('count: ', this.state.count);

      this.setState({ count: 4 });
      console.log('count: ', this.state.count);
    }, 0);
  };

  render() {
    return (
      <div className='App'>
        <button onClick={this.handleClick}>count = {this.state.count}</button>
      </div>
    );
  }
}

export default AppClass;

思考下,你的答案是什么?看下真实的控制台输出是什么:

分析下:

  • 第一个输出 count: 0 ,说明setState 是异步执行的,所以在调用之后打印count 还是初始值0
  • 第二个输出还是count: 0 ,说明setState 还是异步执行的
  • 第三个输出count: 3 ,而且在打印语句前正是调用setStatecount 置为了3 ,很奇怪,这里的setState 是同步执行的
  • 第四个输出count: 4 ,而且前面也正是调用setStatecount 置为了4 , 这里setState也是同步执行的

是不是有点晕,不慌,继续往下看,一点点理清思路。

结论

先说下结论,带着疑问和结论去分析问题更好理解:

react 可调度范围内的setState 就是异步的,反之,则为同步

问:什么是react 可调度范围内呢?

答:react 合成事件内同步执行的setState 就是可调度范围。

问:什么是react 可调度范围外呢?

答:宏任务:setTimeout ,微任务:.then ,或直接在DOM元素上绑定的事件等都是react 可调度范围外。

有了结论的加持,再来分析下以上的输出:

  • handleClick 函数是react 的合成事件,所以其内部的setState 是异步的
  • 进入handleClick 函数内部,发现前两个setState 是没有被setTimeout 包裹的,在调度范围内,故表现为异步,所以前两次的输出都是0
  • 还有两个setState 是在setTimeout 内的,不在react调度范围内,故表现为同步,所以每次setState 执行后都可以立即获取到更新后的值。

深入原理

从合成事件入手,react 中所有的合成事件都会经过如下函数处理:

scss 复制代码
/* 所有的事件都将经过此函数统一处理 */
function dispatchEventForLegacyPluginEventSystem(){
    // handleTopLevel 事件处理函数
    batchedEventUpdates(handleTopLevel, bookKeeping);
}

重点看下这个batchedEventUpdates 函数

javascript 复制代码
function batchedEventUpdates(fn,a){
    /* 开启批量更新  */
   isBatchingEventUpdates = true;
  try {
    /* 这里执行了的事件处理函数, 比如在一次点击事件中触发setState,那么它将在这个函数内执行 */
    return batchedEventUpdatesImpl(fn, a, b);
  } finally {
    /* 完成一次事件,批量更新  */
    isBatchingEventUpdates = false;
  }
}

如上可以分析出

流程在 React 事件执行之前通过 isBatchingEventUpdates=true 打开开关,开启事件批量更新,这里也就是上面所说的react可调度范围内。

当该事件结束,再通过 isBatchingEventUpdates = false 关闭开关,表示当前调度结束。

当事件函数中存在异步代码即setTimeout等时,同步的batchedEventUpdatesImpl函数已经执行完成,此时的isBatchingEventUpdates 标志已经被置为false

而在用户自定义的事件函数中,根本无法进入react的合成事件中,就不会开启批量更新。

batchedEventUpdatesImpl 函数中会去调度Fiber节点,调度主要函数代码如下:

scss 复制代码
export function scheduleUpdateOnFiber(
  fiber: Fiber,
  expirationTime: ExpirationTime,
) {
  const priorityLevel = getCurrentPriorityLevel();

  if (expirationTime === Sync) {
    if (
      // Check if we're inside unbatchedUpdates
      (executionContext & LegacyUnbatchedContext) !== NoContext &&
      // Check if we're not already rendering
      (executionContext & (RenderContext | CommitContext)) === NoContext
    ) {
      performSyncWorkOnRoot(root);
    } else {
      ensureRootIsScheduled(root);
      schedulePendingInteractions(root, expirationTime);
      // 当前已经调度完成,启动同步刷新
      if (executionContext === NoContext) {
        // Flush the synchronous work now, unless we're already working or inside
        // a batch. This is intentionally inside scheduleUpdateOnFiber instead of
        // scheduleCallbackForFiber to preserve the ability to schedule a callback
        // without immediately flushing it. We only do this for user-initiated
        // updates, to preserve historical behavior of legacy mode.
        flushSyncCallbackQueue();
      }
    }
  } else {
    // Schedule a discrete update but only if it's not Sync.
    if (
      (executionContext & DiscreteEventContext) !== NoContext &&
      // Only updates at user-blocking priority or greater are considered
      // discrete, even inside a discrete event.
      (priorityLevel === UserBlockingPriority ||
        priorityLevel === ImmediatePriority)
    ) {
      // This is the result of a discrete event. Track the lowest priority
      // discrete update per root so we can flush them early, if needed.
      if (rootsWithPendingDiscreteUpdates === null) {
        rootsWithPendingDiscreteUpdates = new Map([[root, expirationTime]]);
      } else {
        const lastDiscreteTime = rootsWithPendingDiscreteUpdates.get(root);
        if (
          lastDiscreteTime === undefined ||
          lastDiscreteTime > expirationTime
        ) {
          rootsWithPendingDiscreteUpdates.set(root, expirationTime);
        }
      }
    }
    // Schedule other updates after in case the callback is sync.
    ensureRootIsScheduled(root);
    schedulePendingInteractions(root, expirationTime);
  }
}

重点看下以下代码并翻译下注释:

scss 复制代码
      // 当前已经调度完成,启动同步刷新
      if (executionContext === NoContext) {
        // 立即刷新所有同步工作,除非我们已经在调度中或处于批处理中。
        // 这段代码故意放在 scheduleUpdateOnFiber 函数中,而不是scheduleCallbackForFiber 中,以保留在不立即刷新它的情况下调度回调的能力。我们只对用户发起的更新执行此操作,以保留传统模式的历史行为。
        flushSyncCallbackQueue();
      }

上面的代码表示如果当前react 处在空闲状态即没有进行调度任务时,则启用同步刷新。

总结

当React的数据变化在合成事件中触发时:

  • React通过设置全局变量isBatchingEventUpdates来标志当前的变化是否发生在React的可调度范围内。
  • 如果在可调度范围内,那么将开启批量更新,即表现为异步刷新。
  • 如果不在可调度范围内,那么将进入flushSyncCallbackQueue函数进行同步刷新。
  • 由于只有在React合成事件中才会设置isBatchingEventUpdates标志,因此像setTimeout自定义监听事件.then等触发的数据更新都无法触发批处理,即表现为同步刷新。

后记

React v18版本即将到来,敬请期待......

相关推荐
小李小李不讲道理5 小时前
「Ant Design 组件库探索」四:Input组件
前端·javascript·react.js
知识分享小能手12 小时前
React学习教程,从入门到精通,React AJAX 语法知识点与案例详解(18)
前端·javascript·vue.js·学习·react.js·ajax·vue3
NeverSettle_17 小时前
React工程实践面试题深度分析2025
javascript·react.js
学前端搞口饭吃17 小时前
react reducx的使用
前端·react.js·前端框架
努力往上爬de蜗牛18 小时前
react3面试题
javascript·react.js·面试
开心不就得了18 小时前
React 进阶
前端·javascript·react.js
谢尔登18 小时前
【React】React 哲学
前端·react.js·前端框架
学前端搞口饭吃20 小时前
react context如何使用
前端·javascript·react.js
GDAL20 小时前
为什么Cesium不使用vue或者react,而是 保留 Knockout
前端·vue.js·react.js
Dragon Wu1 天前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架