likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 3

前言

首先明确一下:

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

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

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

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

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

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

一道面试题

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

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;

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

彻底搞懂setState到底是同步还是异步(一) 分析下:

  • 第一个输出 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 中所有的合成事件都会经过如下函数处理:

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

重点看下这个batchedEventUpdates 函数

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节点,调度主要函数代码如下:

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);
  }
}

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

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

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

总结

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

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

合集文章