likes
comments
collection
share

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

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

前言

书接上回,讲解了 React v17版本中setState何时是同步何时是异步刷新的 这节一起来探讨下 React v18版本中的 setState 在React v18 中新增了自动批处理功能,什么是自动批处理呢?引用官方博客的话如下:

  • 批处理是指,当 React 在一个单独的重渲染事件中批量处理多个状态更新以此实现优化性能。如果没有自动批处理的话,我们仅能够在 React 事件处理程序中批量更新。在 React 18 之前,默认情况下 promise、setTimeout、原生应用的事件处理程序以及任何其他事件中的更新都不会被批量处理;但现在,这些更新内容都会被自动批处理

好嘛,这 React v18 将 v17 中不会批处理的地方都处理成批处理了,有点强。

一起来看看是怎么个事~~~

还是那道面试题

回顾下上一节中的面试题,看下在React v18中表现是什么样的!,呈上面试题:

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到底是同步还是异步(二)

把v17的打印结果也贴一下:

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

对比下结果:

  • 前两次的结果相同,都是0,证明这块是跟 v17 中一样的,都是异步
  • 后两次结果不一样,v17中是同步更新的,所以每次setState 后都可以立即获取到更新后的值,但v18 中打印的是两个2 ,说明是异步更新的,只是这个异步更新跟setTimeout 外部的不在一个批中,setTimeout 中的批处理明显落后外部的批处理。

深入原理

先说结论,根据结论去一步步看源码

结论

React 的每一次 setState调用都会产生一个更新对象 update,更新对象存储在对应的 Fiber 节点上, 同时还会产生一个表示此次更新优先级的标志 Lane, 对于有相同优先级的多次更新,React 只会实际调度第一个更新,而在后续的更新请求中提前返回函数就能实现批处理。 而且因为每一次的更新对象 update都已经存储在对应的 Fiber 节点上,所以在一次更新中, React 可以找到此优先级下的全部update,以此保证更新不会出现错误。

源码分析

首先看下 setState的源码:

Component.prototype.setState = function (partialState, callback) {
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

再来看下 updater(省略部分与此次分析无关的代码) :

const classComponentUpdater = {
  isMounted,
  
  enqueueSetState(inst: any, payload: any, callback) {
    const fiber = getInstance(inst);

    // 1. 获取此次更新的优先级
    const lane = requestUpdateLane(fiber);

    // 2. 生成更新对象
    const update = createUpdate(lane);
    
    update.payload = payload;
    if (callback !== undefined && callback !== null) {
      update.callback = callback;
    }

    const root = enqueueUpdate(fiber, update, lane);
    
    if (root !== null) {
      // 3. 开启调度更新
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitions(root, fiber, lane);
    }

    if (enableSchedulingProfiler) {
      markStateUpdateScheduled(fiber, lane);
    }
  },
  
  enqueueReplaceState(inst: any, payload: any, callback: null) {
    // 省略
  },
  
  enqueueForceUpdate(inst: any, callback) {
    // 省略
  },
};

代码很清晰,主要分为以下几步:

  1. requestUpdateLane 获取此次更新的优先级
  2. createUpdate 生成更新对象 update,并保存在 Fiber 树中
  3. scheduleUpdateOnFiber 开始调度更新 Fiber

scheduleUpdateOnFiber函数主要功能是:标记当前 Fiber 树存在待更新、根据不同情况开启同步刷新还是异步刷新,这里我们使用的是异步刷新,所以接下来函数会走到 ensureRootIsScheduled函数中 ensureRootIsScheduled函数会调用 processRootScheduleInMicrotask函数 processRootScheduleInMicrotask函数会对比已经存在的任务优先级和当前的任务优先级,如果优先级相同则直接返回,这也是批处理的关键逻辑。 代码如下:

function scheduleTaskForRootDuringMicrotask(
  root: FiberRoot,
  currentTime: number,
): Lane {
  const workInProgressRoot = getWorkInProgressRoot();
  const workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes();
  const nextLanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );

  const existingCallbackNode = root.callbackNode;
  // 现有的任务优先级
  const existingCallbackPriority = root.callbackPriority;
  // 新产生的任务优先级
  const newCallbackPriority = getHighestPriorityLane(nextLanes);

  // 关键逻辑,对比现有的任务优先级和先产生的任务优先级,如果相同则最直接返回,复用现有的调度任务
  if (
    newCallbackPriority === existingCallbackPriority
  ) {
    // 优先级没有改变。 我们可以重用现有的任务。
    return newCallbackPriority;
  } else {
    // 取消现有的回调。 我们将在下面安排一个新的。
    cancelCallback(existingCallbackNode);
  }

  // 省略 schedulerPriorityLevel 赋值代码
  let schedulerPriorityLevel;

  // 在调度任务队列中加入一个新的调度任务
  const newCallbackNode = scheduleCallback(
    schedulerPriorityLevel,
    performConcurrentWorkOnRoot.bind(null, root),
  );

  return newCallbackPriority;
}

所以多个相同优先级的setState会复用同一个调度任务,在一次调度完成后会才会实际更新变量的值,所以在调度任务未完成时,我们始终获取的都是初始值。 回到刚才的面试题,函数中直接执行的 setState被认为是一个优先级,为 1,而在 setTimeout回调函数中的 setState被认为是另一个优先级,为 32,两个优先级不同,所以会触发两次调度更新,而存在相同优先级的 setState又会被放在同一批中(即复用同一个调度更新)处理,而且调度是异步执行的,这也就导致我们打印的值始终是这一次调度开始时候的值。 那有的同学可能会问:既然多个相同优先级的 setState会复用第一次的调度更新,那 React 是如何知道最后应该把值更新为多少呢? 其实前面在看 setState源码的时候有看到,setState主要做了以下几件事:

  1. 获取更新优先级
  2. 创建 update并保存在 Fiber 树中
  3. 尝试发起一次调度更新

调度的本质是发起一次render,render过程中会取出 Fiber上的update来计算最新的状态。 第二次 setState 的时候,发现已发起过一样优先级的调度,也就是说会在下一个宏任务/微任务触发render,那么就不发起调度了,但是 update 还是正常生成保存在组件对应fiber上的。 当第一个setState发起调度触发的render在下一轮任务执行的时候,也有第二个 setState 生成的 update,就实现一次render计算多个state了。

如果我不想批处理怎么办?

通常,自动批处理是安全的,但某些代码可能依赖于在状态更改后立即从 DOM 中读取某些内容。这种情况,可以选择 ReactDOM.flushSync() 退出批处理,立即执行调度更新: 我们将上面面试题的 handleClick修改下:

import { flushSync } from 'react-dom';

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

  // 此处执行强制刷新
  ReactDom.flushSync(() => {
    this.setState({ count: 2 });
  });
  console.log('ReactDom.flushSync 后的 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);
};

结果:

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

可以看到,强制刷新后,立即可以获取到变化后的值。

总结

本文主要讲解了 React 18 中的自动批处理和源码实现,并在最后提供了退出批处理立即刷新的方案。 希望我的文章对你理解 React 18 的自动批处理有帮助。

合集文章