从React源码分析setState到底是同步还是异步?

5,144 阅读7分钟

前言

本文基于React版本17.0.2,这篇文章会基于Event Loop的机制来分析,对于Event Loop不熟悉的同学可以看我这篇文章从event loop探究javaScript异步

源码

那我们直接看关键的源码部分,如果感兴趣的同学可以看我这篇文章React源码解析之Scheduler

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {

  ......
  
  // Schedule a new callback.
  // 开始调度任务
  // 判断新任务的优先级是否是同步优先级
  // 是则使用同步渲染模式,否则使用并发渲染模式(时间分片)
  let newCallbackNode;
  if (newCallbackPriority === SyncLane) {
  
   ......
   
    if (supportsMicrotasks) {
      if (__DEV__ && ReactCurrentActQueue.current !== null) {
        ReactCurrentActQueue.current.push(flushSyncCallbacks);
      } else {
        scheduleMicrotask(flushSyncCallbacks);
      }
    } else {
      scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
    }
    newCallbackNode = null;
  } else {
  
   ......
   
    //将react与scheduler连接,将react产生的事件作为任务使用scheduler调度
    newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root),
    );
  }

 ...
 
}

可以看到首先判断了当前的任务的优先级,如果是同步优先级则则执行if代码块中的代码,而if中根据当前环境是否支持微任务调用了两个方法:scheduleMicrotaskscheduleCallback, 在else中也是调用了scheduleCallback方法:

  1. scheduleMicrotask这个方法主要作用是将任务添加进微任务队列:
export const scheduleMicrotask: any =
  typeof queueMicrotask === 'function'
    ? queueMicrotask
    : typeof localPromise !== 'undefined'
    ? callback =>
        localPromise
          .resolve(null)
          .then(callback)
          .catch(handleErrorInNextTick)
    : scheduleTimeout; 

可以看到scheduleMicrotask其实是使用了queueMicrotask方法,如果queueMicrotask方法不支持,则会使用Promise,如果Promise也不支持,最后会使用setTimeout来实现。queueMicrotask是将传入的回调函数添加进微任务队列中,Promise.then方法也是被添加进微任务队列中,setTimeout则是宏任务,总的来说scheduleMicrotask方法是异步的。

  1. scheduleCallback则是Scheduler调度任务的入口,而Scheduler则是使用MessageChannel来实现的:
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  // 使用setImmediate的主要原因是因为在服务端渲染,MessageChannel会阻止nodejs的进程退出
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // 使用MessageChannel的原因是因为
  // setTimeout如果嵌套的层级超过了 5 层,并且 timeout 小于 4ms,则设置 timeout 为 4ms。
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  //在以上方案都不能实现的时候,则降级使用setTimeout来实现创建调度者
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

可以看到Scheduler根据宿主环境会使用不同的方式实现:

  1. 在node.js中会使用setImmediate来实现
  2. 在浏览器中会使用MessageChannel来实现
  3. 在以上方案都不能实现的时候,则降级使用setTimeout来实现 在这里我们值讨论浏览器环境,node环境之后再单独来分析。

MessageChannel是通过实例化,使用消息通道来实现消息传递,是一个宏任务。

setTimeout也是宏任务,所以Scheduler也是异步的。

例子

接下来,我们举个例子来验证上面的结论。

例子1:

this.state = {num: 0};

function onClick() {
    this.setState({
      num: this.state.num + 1
    });
    console.log('num:', this.state.num);
    setTimeout(() => {
      console.log('num:', this.state.num);
    }, 0);
}

在这个例子中,首先调用setState去更新num的值,紧接着使用console打印num的值,结果为:0,随后使用了setTimeout在回调函数中打印num的值,结果为:1,下面我们来分析一下:

  1. 首先classComponent组件实例化,会将state初始化,num为0
  2. 然后我们在点击事件中调用了setState,之我们分析了setState的实现方式,在Chrome中是支持微任务的,所以会使用queueMicrotask,将更新任务添加进微任务队列中
  3. 接着调用了console打印num的值,console是同步代码,所以num的值为初始化的值:0
  4. 随后使用了setTimeout在回调函数中打印num的值,setTimeout是属于宏任务,会被添加进宏任务队列中。

记得Event Loop的执行机制:当代码运行时(一次事件循环开始),从上到下依次执行,遇到同步代码会马上执行,遇到微任务会将任务添加进微任务队列,在调用栈清空时执行所有的微任务,遇到宏任务,添加进宏任务队列,在下一次事件循环时开始执行(一次事件循环只执行一个宏任务)。

所以上面的执行顺序是:

  1. 调用了setState,将更新任务添加进微任务队列,此时num还未更新为0
  2. 接着调用了console打印num的值:0
  3. 使用了setTimeout,会被添加进宏任务队列中
  4. 同步代码执行完成,开始执行微任务,也就是setState,更新num的值为:1
  5. 微任务执行完成,浏览器进行页面渲染
  6. 页面渲染完成,开启新一轮的事件循环,从宏任务队列中取出宏任务,也就是setTimeout,执行回调函数,打印出num值为:1

例子2:

class Demo extends Component {

  state = { num: 0 }

 componentDidMount() {
    setTimeout(() => {
      this.setState({ val: this.state.num + 1 })
      console.log(this.state.num) // 输出更新后的值 --> 0
    }, 0)
 }

  render() {
    return (
      <div>
        {`num is: ${this.state.num}`}
      </div>
    )
  }
}

举这个例子的目的,是因为在之前React 16.xx版本时,在原生事件和 setTimeout 中都是同步的,那时候React的内部实现并不是真正的异步,而现在17.xx版本内部实现都是使用了异步的方式,所以现在不会再出现在不同的地方调用setState,有的地方出现同步,有的地方出现异步的这种情况。

得出结论:setState是异步的。

为什么使用异步?

我们来看个例子:

this.state = {num: 0};

function onClick() {
    this.setState({
      num: this.state.num + 1
    });
    this.setState({
      num: this.state.num + 2
    });
}

我们在onClick事件中连续调用setState去更新num的值,最终的结果是多少呢?有的同学可能认为是:3,NO,是:2,为啥?

这和React中的Lane机制有关,在React合成事件中连续调用的setState的优先级是一样的,在第一个setState调用后,再调用第二个时,会将第一个更新任务的优先级与第二个更新任务的优先级进行比较,如果优先级一样,则不会执行第二个更新任务,而是将第二个任务的更新内容与第一个的更新内容进行合并,最终只会进行一次更新渲染,这样的做法叫做批量更新

这样做的目的,是为了避免短时间内连续调用setState造成不必要的渲染,增加性能的开销。

有兴趣的同学可以看我这篇关于优先级调度的文章React源码解析之优先级Lane模型上

再举个例子:

this.state = {num: 0};

function onClick() {
    this.setState({
      num: this.state.num + 1
    });
    this.setState({
      num: this.state.num + 2
    });
    setTimeout(() => {
      this.setState({
        num: this.state.num + 3
      });
      console.log('setTimeout=====num:', this.state.num);
    }, 0);
}

与上一个例子的不同,是在最后使用setTimeout调用了setState,那么这次的结果是多少呢?

揭晓答案:5

有的同学就可能有疑惑了,为什么不是3,而是5,不是说好的批量更新吗?

下面我们来分析一下:

  1. 首先连续调用了setState,我们上面提到过setState是一个微任务,那么这次连续调用setState则会被批量更新,只会产生一个微任务,但是更新内容会被合并,而setTimeout是一个宏任务,会在下一次的事件循环中被执行的,本次事件循环中则只会执行连续调用了setState产生的微任务,所以结果为:2
  2. 当执行setTimeout中的setState时,num值已经为2,然后加3,所以结果为5

从这个例子中可以看出,批量更新只会在一个任务中进行,而使用setTimeout则产生了一个宏任务,事件循环会先让微任务执行,得出结果:2,然后再执行宏任务,得出结果:5,所以使用setTimeout调用setState和外部调用的setState是不会进行批量更新的,这主要是和事件循环的机制有关。

继续看个例子:

this.state = {num: 0};

function onClick() {
    this.setState({
      num: this.state.num + 1
    });
    this.setState({
      num: this.state.num + 2
    });
    Promise.resolve().then(() => {
      this.setState({
        num: this.state.num + 3
      });
    });
    setTimeout(() => {
      this.setState({
        num: this.state.num + 3
      });
      console.log('setTimeout=====num:', this.state.num);
    }, 0);
}

在上个例子的基础上添加了Promise,那么这次的结果是多少呢?

答案:8

我们上个例子说说过,批量更新只会在一个任务中执行,连续调用的两个setState会产生一个微任务,使用Promise本身就会产生一个微任务,setTimeout产生一个宏任务,连续调用的两个setState被批量更新,得出结果:2,然后执行Promise,得出结果:5,最后执行setTimeout,得出结果:8。

总结

React的setState是异步的,因为React的渲染机制中使用queueMicrotask或者MessageChannel将更新任务添加进微任务队列或者宏任务队列中以此实现异步渲染。

为什么要使用异步渲染?在React中会将连续调用的setState进行批量更新,这样做的目的,是为了避免短时间内连续调用造成不必要的渲染,增加性能的开销。

批量更新只会在一个微任务或宏任务中进行。