likes
comments
collection

摸爬滚打系列——React 调度原理

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

调度是 React 框架的核心模块,理解了调度有利于我们更深入的了解 React 框架的其它原理,是理清 React 项目整个执行流程的必修课,也是面试中的常考点,但对于初次接触源码的人来说,自己独立分析调度流程有点困难,比如说笔者初学的时候就感觉有亿点痛苦😭,本文我将会用自己摸爬滚打出来的理解带着大家循序渐进的弄懂整个调度过程~

什么是调度?

简单来说,调度就是 React 用来控制传入回调函数的执行时机的,那肯定有人要来个一键三连了:为什么要这样做呢?这样做有什么好处呢?解决了什么问题呢?

首先我们得知道,旧版本的 React 有一个很严重的问题,由于 ReactVue 的响应式不一样,组件更新的时候 React 并不知道是哪里发生了变化,它只能自顶向下遍历 diff 一遍 fiber 树才能找到要更新的地方。对于大型项目而言,组件嵌套的层级非常深,遍历一次 fiber 树需要花费很长的时间,而且浏览器的 JS 执行引擎和渲染引擎是互斥的,一个在工作,另一个就必须等待。这样一来,每次有组件更新,遍历或者构建 fiber 树就很容易就会让 JS 引擎一次性工作较长时间导致渲染引擎没有工作机会,从而页面不能及时渲染出来,最终的结果就是页面出现卡顿甚至白屏,非常影响用户体验,于是 React 不得不想出一种优化方案——调度

针对上述问题,调度主要在两个方面上做了优化,解决了由于 JS 执行时间过长而导致的页面渲染问题:

  • 在浏览器每一帧的空闲阶段执行任务,不阻塞浏览器的渲染,被称为时间切片
  • 允许 fiber 树分批构建,下一次浏览器空闲的时候可以接着上次的成果继续构建,这一步叫做可中断渲染

有了上述两个大名鼎鼎的优化方案处理之后,现在的 React 项目运行起来非常的流畅,与用户交互也很少会再出现卡顿的情况,直接使得 React 项目的用户体验提升了一个档次

调度的工作有哪些?

说完了调度大概做的事情和针对相关问题提出的优化方案,下面我们来具体看一下调度的整个流程以及了解这些优化方案是如何实现的?

创建任务

我们既然需要调度任务,那么就必须先在调度中心注册任务,每一个任务都绑定着一个回调函数,这些任务被执行就相当于对应的回调函数被执行。每个任务也都对应着一个优先级,优先级决定了它们的过期时间以及在任务队列(其实并不是队列而是一个小顶堆数组)中的排列顺序,过期时间越靠前的任务会被放到任务队列的前面,被执行的时机也会越早

下面是创建任务所对应的源码,本篇文章为了让大家阅读起来轻松一点,后续的源码我都已经尽最大能力写上了注释,如果有不懂的地方评论区 cue 我~

// packages\scheduler\src\Scheduler.js
// 注册调度任务
function unstable_scheduleCallback(priorityLevel, callback, options) {
  // 1. 获取当前时间
  var currentTime = getCurrentTime();
  var startTime;
  if (typeof options === 'object' && options !== null) {
    // 从函数调用关系来看, 在v17.0.2中,所有调用 unstable_scheduleCallback 都未传入options
    // 所以省略延时任务相关的代码
  } else {
    // 任务对象中的 startTime 其实就是该任务被注册时的时间
    startTime = currentTime;
  }
  
  // 2. 根据传入的任务优先级, 查询对应的 timeout,优先级越高 timeout 越小
  var timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;
      break;
  }
 // 任务的过期时间决定了该任务在任务队列中的排列顺序
  // 优先级越高 -> timeout 越小 -> expirationTime 越小 -> 任务越紧急 -> 越早被执行
  var expirationTime = startTime + timeout;
  // 3. 创建新任务,任务其实也就是一个 js 对象,上面有描述这个任务信息的属性,包括对应的回调、优先级、过期时间等等   
  var newTask = {
    // 标识,一个自增编号     
    id: taskIdCounter++,
    // task 最核心的字段,任务对应的回调函数,一般指向 react-reconciler 包所提供的回调函数
    callback,
    // 优先级等级     
    priorityLevel,
    // 一个时间戳,代表 task 的开始时间,实际上并不是单纯的创建时间,而是创建时间 + 延时时间,但延时任务本文不考虑     
    startTime,
    // expirationTime: task的过期时间, 优先级越高 expirationTime = startTime + timeout 越小
    expirationTime,
    // 控制 task 在队列中的次序,非延时任务下,会将 expirationTime 赋值给该属性用来排序
    // 该值越小的在任务队列中越靠前,同时也应证了 expirationTime 越小、任务越紧急就越靠前     
    sortIndex: -1,
  };
  // 延时任务相关,本文不考虑
  if (startTime > currentTime) {
    // 省略无关代码 v17.0.2中不会使用
  } else {
    // 上文说到了会将 expirationTime 赋值给 sortIndex 的
    newTask.sortIndex = expirationTime;
    // 4. 放入任务队列,注意:这里的 push 并不是将任务压入到任务队列最后
    // 前面也有提过任务队列是一个小顶堆数组,这里的 push 方法其实是根据该任务的 sortIndex 属性放入小顶堆合适的位置
    push(taskQueue, newTask);
    // 5. 执行 requestHostCallback 请求调度,这里要记住 flushWork 函数,因为它就是等下调度中心会执行的回调
    // 当前如果正处在调度阶段,是不会重复请求调度的
    if (!isHostCallbackScheduled && !isPerformingWork) {
      // 将标志设为 true 表示调度已经开启
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }
  return newTask;
}

现在我们已经知道了在调度中心创建任务的流程了,主要有以下步骤:

  1. 创建任务:计算出任务对象的描述属性,比如绑定好该任务对应的回调函数、优先级、过期时间 expirationTime
  1. 将任务放到任务队列(小顶堆数组)中:将刚刚创建好的任务放入到任务队列 taskQueue 中,这里的 push 函数并不仅仅只是简单的入队,而是将该任务按照 sortIndex 放到小顶堆数组合适的位置
  1. 开始请求调度:通过调用 requestHostCallback 函数开始调度,该函数会在下文中详细介绍

请求或取消调度

刚刚我们探索了任务是如何被创建的,在源码中也看到了任务被放入调度中心的任务队列之后执行了 requestHostCallback 函数请求调度,下面我们就来看一下有关调度是如何开启的?能不能取消?调度开始了之后又进行了哪些操作等等一系列问题

  // packages\scheduler\src\forks\SchedulerHostConfig.default.js
  // 接收 MessageChannel 消息,执行对应的任务队列直到 ddl(建议先看后面的逻辑再回来看这个函数)  
  const performWorkUntilDeadline = () => {
    // 说明回调函数没有被置空,调度过程可以继续     
    if (scheduledHostCallback !== null) {
      // 1. 获取当前时间      
      const currentTime = getCurrentTime();       
      // 2. 更新 deadline,它代表了本次调度任务队列最多执行到什么时间戳,超过了这个时间就需要将控制权转交给浏览器
      // yieldInterval 默认为 5ms,从当前时间到 deadline 的这段时间被称为是一个时间片     
      deadline = currentTime + yieldInterval;
      const hasTimeRemaining = true;
      try {
        // 3. 执行回调, 也就是 flushWork 函数,其返回代表任务队列中是否还有剩余任务没有执行完         const hasMoreWork = scheduledHostCallback(
          hasTimeRemaining,
          currentTime,
        );
        // 没有剩余任务, 更新对应的标志,代表本次调度已经结束
        if (!hasMoreWork) {           isMessageLoopRunning = false;
          scheduledHostCallback = null;
        } else {           // 还有剩余任务, 发起新的调度,等到下次事件循环的时候再接着执行           port.postMessage(null);
        }
      } catch (error) {         // 如有异常, 重新发起调度         port.postMessage(null);
        throw error;
      }
    } else {
      // 回调函数被置空了,放弃本次调度,更新对应的标志
      isMessageLoopRunning = false; 
    }
  };

  // 创建了一个 MessageChannel 实例,其有 port1 和 port2 两个端口   const channel = new MessageChannel();
  // 取出 port2 端口进行操作
  const port = channel.port2;
  // 实例的 port1 端口监听着 port2 端口发送过来的信息,onmessage 事件对应的处理函数是一个宏任务   channel.port1.onmessage = performWorkUntilDeadline;

  // 这个就是在调度中心创建好任务后请求调度执行的函数
  // 它的主要作用是触发 onmessage 事件执行 performWorkUntilDeadline 函数   requestHostCallback = function(callback) {
    // 1. 利用 scheduledHostCallback 变量保存 callback,这里的 callback 就是上文中看到的 flushWork     scheduledHostCallback = callback;
    // 判断当前是否已经触发了 onmessage 事件
    if (!isMessageLoopRunning) {
      // 将 isMessageLoopRunning 标记为 true 表示 onmessage 事件已触发
      isMessageLoopRunning = true;
      // 2. 通过 port2 上的 postMessage方法发送消息触发 port1 的 onmessage 事件执行 performWorkUntilDeadline 函数       port.postMessage(null);
    }
  };

  // 建议看完 performWorkUntilDeadline 函数之后再来看这个函数
  // 取消及时回调,逻辑非常简单,因为代码中是通过 scheduledHostCallback 执行回调函数的
  // 如果将该变量设置为空,那么对应的回调函数就不能执行,本次调度就没有任何意义,相当于取消了调度一样   cancelHostCallback = function() {
    scheduledHostCallback = null;
  };
}

这一段源码中我们可以从 requestHostCallback 着手,因为其是在创建任务之后执行的函数,不难想到该函数肯定承接了后面的操作,所以我们研究的时候可以从该函数进行发散。上文中也提到过该函数具有请求调度的目的,在该函数执行之前,我们会发现在当前文件中早已创建了一个 MessageChannel 实例,通过该实例上的端口可以监听信息以及触发事件。将上述两个突破点练习起来之后,有关请求调度的流程就愈发清晰了,主要做了以下事情:

  1. 创建了 MessageChannel 实例,可以通过该实例的 port2 端口发送消息,port1 端口监听到之后就会触发 onmessage 事件执行 performWorkUntilDeadline 函数,最重要的是 onmessage 对应的事件处理函数是一个宏任务,会在浏览器下一次事件循环中执行,这是我们下文探索时间切片原理的关键
  1. 其次是 requestHostCallback 函数执行,它会先将回调函数的值赋值给全局变量 requestHostCallback,对应着上文中传入该函数的参数 flushWork,该函数是我们研究任务执行的关键,下文中有详细分析,现在只需要知道 scheduledHostCallback 变量存储着 flushWork 回调即可
  1. performWorkUntilDeadline 函数在下一次事件循环的时候被执行,该函数是用来执行在调度中心所注册的任务的,主要做了以下事情
  • 首先判断本次调度有没有被取消,因为源码中有一个能够取消调度的函数 cancelHostCallback,具体它是如何做的等下再说。如果该函数被执行了,那么就意味着本次调度被取消,performWorkUntilDeadline 函数直接返回即可
  • 其次,根据当前时间和 yieldInterval(规定的任务队列执行时间片,一般为 5ms)计算出任务队列执行的超时时间戳 deadline,表示任务队列最多只能执行处理到 deadline 标记的时间戳,如果超过了该时间则需要将控制权移交给浏览器

    • 这其实就是时间切片思想,以时间片的形式约束任务队列执行的最长时间,无论任务队列是否被清空,到了一定的时间之后就必须将控制权转交给浏览器,这样浏览器就可以根据需要进行页面渲染
    • 不过当前流程还并没有用到 deadline 变量,等到具体研究“执行任务”的时候大家对时间分片的理解会更加清晰
  • 最核心的操作就是内部调用了 scheduledHostCallback 函数,不过该变量引用的其实是我们上文中提到了多次的 flushWork,该函数就是负责清空任务队列的,它的返回值代表了本次调度有没有在分配的时间片内清空任务队列。简单来讲,时间片就是留给任务队列执行的时间,不一定可以做到在该时间内将队列清空完毕,针对该结果分成了下面两种情况:

    • 队列清空完毕,没有剩余任务,更新相关的变量代表本次调度完成
    • 如果依然有任务没有被执行,则再次利用 MessageChannel 实例的端口触发 onmessage 事件,等到下一次事件循环的时候再执行 performWorkUntilDeadline 函数在分配的时间片内执行剩下的任务

补充

  1. 上文中说到还有一个取消调度的函数 cancelHostCallback,那么它是如何实现在调度任务开启后又取消的呢?

实现逻辑很简单,因为上文中有说过回调函数是存储在 scheduledHostCallback 中的,performWorkUntilDeadline 函数的首要工作就是判断该变量是否存在,如果不存在performWorkUntilDeadline 就不会涉及到核心的操作,那么只需要将 scheduledHostCallback 变量在 performWorkUntilDeadline 执行之前置空就可以避免任务队列被执行,从而达到取消调度的目的

  1. 为什么 React 需要使用一个异步执行的宏任务来进行调度?

因为调度本身就是为了解决 JS 执行时间过长而阻塞浏览器渲染问题的,自然不能够使用同步任务来执行任务队列,一定是要寻找一种可以不阻塞浏览器渲染的方式来执行 JS 代码,宏任务就是一种很好的解决方案,因为其是在事件循环中被执行的,事件循环又一定是在浏览器空闲的时候才进行,这就刚好满足调度的需求

  1. 为什么 React 要自己实现一个宏任务而不是直接用 setTimeout 呢?

众所周知,setTimeout 也是一个宏任务,其和它自己用 MessageChannel 触发的宏任务 onmessage 有什么区别吗?从 performWorkUntilDeadline 中可以看到,如果一次时间片内任务队列没有清空完会进行下一次调度,相当于会递归触发 onmessage 事件,如果换成 setTimeout 来作为这里的宏任务异步执行,经过测试会发现当嵌套层级多了之后前后两次回调函数的执行的时间会达到 4ms 左右,这个时间相对来说有点太长了,React 觉得这就有一点铺张浪费,所以就自己实现了一种性能比较优异的宏任务

时间切片

其实上文中已经讲到了时间切片的概念,下面我们来具体看下调度过程是如何依靠时间切片的思想将控制权转交给浏览器手上、让出主线程的,下面的函数要重点理解,因为跟后续任务的执行以及可中断渲染密不可分

// packages\scheduler\src\forks\SchedulerHostConfig.default.js
// getCurrentTime 返回一个表示从性能测量时刻开始经过的毫秒数,可以初步理解为当前时间
const localPerformance = performance;
getCurrentTime = () => localPerformance.now();

// 时间切片周期, 默认是 5ms(如果前面所有 task 运行总时超过了该周期, 那么在下一个 task 执行之前, 会把控制权归还浏览器)
let yieldInterval = 5;

let deadline = 0;
// 最大的时间片,只有在初始化的时候才会用到,换句话说,第一次构建 fiber 的时候分配的时间片比较充裕
// 因为如果 fiber 都没有构建完的话浏览器绘制也没有什么作用,设置较长一点的事件其实是为了加快首屏的渲染速度 
const maxYieldInterval = 300;
let needsPaint = false;
// 该对象上有用于判断是否有输入事件(包括: input 框输入事件, 点击事件等)的 api
const scheduling = navigator.scheduling;

// 判断时间片是否用完,是否需要转交控制权、让出主线程给浏览器
shouldYieldToHost = function() {
  // 获取当前时间
  const currentTime = getCurrentTime();
  // 如果当前时间大于 deadline 一般情况下都是需要让出主线程的,不过在初始化的情况还需要做额外判断
  if (currentTime >= deadline) {
    // 如果页面需要绘制或者有输入事件,则让出主线程
    if (needsPaint || scheduling.isInputPending()) {
      return true;
    }
    // 在持续运行的react应用中, currentTime 肯定大于 300ms,返回 true,这个判断只在初始化过程中才有可能返回 false
    return currentTime >= maxYieldInterval; 
  } else {
    // 当前时间还没有达到 deadline,说明分配的时间片还没有用完,仍可以继续执行任务队列
    return false;
  }
};

// 请求绘制: 设置 needsPaint = true
requestPaint = function() {
  needsPaint = true;
};

// 设置时间切片的周期,这个函数虽然存在, 但是从源码来看, 几乎没有用到  // 强制设置 yieldInterval (让出主线程的周期) forceFrameRate = function(fps) {
  if (fps < 0 || fps > 125) {
    return;
  }
  if (fps > 0) {
    // 设置时间切片的周期是多少,默认为 5ms     yieldInterval = Math.floor(1000 / fps);
  } else {
    // 重置变量     yieldInterval = 5;
  }
};

总结一下,一共有如下几种情况会被判定为需要让出主线程,主要核心逻辑在 shouldYieldToHost 中,如果当前时间大于 deadline(一般为任务队列执行时的时间 + 5ms)

  • 有挂载的渲染或输入事件
  • 初始化时当前时间大于 300ms
  • 非初始化时基本上一定会让出主线程

以上三种情况都会将控制权转交为浏览器,顺带补充一下,forceFrameRate 函数是可以根据屏幕的刷新率来强制设置 yieldInterval 的值的,但该函数并没有在源码中使用,算是一个保留函数

执行任务

上面已经介绍完了创建任务、请求调度和时间切片等细节,先来回顾下刚刚讲过的所有流程:

  1. 通过 unstable_scheduleCallback 函数创建任务,将要执行的回调函数绑定到创建好的任务对象中并按照 sortIndex 放入到任务队列里,最后调用 requestHostCallback 函数将 flushWork 作为参数传递进去
  1. requestHostCallback 函数内部主要就是触发了 MessageChannel 实例的 onmessage 事件,这是一个宏任务,事件处理函数是 performWorkUntilDeadline 函数,其内部执行了提前用变脸记录好的回调函数,也就是上面提供的 flushWork

所以想要知道任务是怎么执行的?如何执行的?就必须要了解 flushWork 函数内部的执行过程,下面看一下它的源码:

// packages\scheduler\src\Scheduler.js
// 执行任务队列的回调函数,将会在下一个事件循环中执行
function flushWork(hasTimeRemaining, initialTime) {
  // 1. 做好全局标记, 表示现在已经进入调度阶段
  isHostCallbackScheduled = false;
  isPerformingWork = true;
  const previousPriorityLevel = currentPriorityLevel;
  try {
    // 2. 循环消费队列
    return workLoop(hasTimeRemaining, initialTime);
  } finally {
    // 3. 还原全局标记
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
  }
}

发现核心代码其实是在 workLoop 中,于是我们继续进行探索

// packages\scheduler\src\Scheduler.js
// 循环消费队列
function workLoop(hasTimeRemaining, initialTime) {
  // 保存当前时间, 用于判断任务是否过期
  let currentTime = initialTime; 
  // 获取队列中的第一个任务
  currentTask = peek(taskQueue); 
  while (currentTask !== null) {
    // 比较现在的时间是否超过了当前任务的过期时间,如果没有超过
    // 则通过 hasTimeRemaining 查看是否还有剩余的时间,没有就直接退出
    // 如果有剩余时间,还需要判断当前任务队列有没有执行超时
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // 虽然currentTask没有过期, 但是任务队列时间超过了限制,停止继续执行任务, 让出主线程       // (毕竟一个时间片只有5ms, shouldYieldToHost()返回true,也就是任务队列执行用时超过了 5ms)
      break;
    }
    // 注意:如果当前任务的 expirationTime 小于当前时间,说明任务已经被延期,需要快点执行,就不用再判断时间片有没有用完
    // 取出当前任务的回调函数,通过判断其是不是函数来判断该任务有没有被取消
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      // 清空 callback 代表该函数将要被执行以及取出优先级等级
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      // 判断该任务执行时间是否延时
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      // 执行当前任务对应的回调
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      // 回调完成, 判断是否还有连续(派生)回调
      // 回调的返回值如果还是一个函数,那就说明此任务进行的可能是 fiber 构建过程
      // 且 fiber 在本次时间片中没有构建完全,需要在下次事件循环接着构建
      if (typeof continuationCallback === 'function') {
        // 产生了连续回调(如fiber树太大,一次时间片构建不完,出现了中断渲染)
        // 注意:如果产生了中断渲染,则会更新当前任务的回调函数,但并不会移除该任务
        currentTask.callback = continuationCallback;
      } else {
        // 如果没有出现中断渲染,即当前任务执行完毕,就要把 currentTask 移出队列(小顶堆操作)
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
    } else {
      // 如果任务被取消(即 currentTask.callback = null), 将其移出队列(小顶堆操作)
      pop(taskQueue);
    }
    // 取出小顶堆中的顶层任务,更新 currentTask,继续进行下一次循环,执行任务队列的下一个的任务
    currentTask = peek(taskQueue);
  }
  // 退出循环有三种情况,但无论是否有额外的工作都需要返回
  // 1. 任务队列执行的总时间过长,执行下一个任务之前发现时间片已经用完
  // 2. 任务执行过程中发现时间片用完(比如 fiber 构建等耗时较长的任务),渲染被中断
  // 3. 在一个时间片内,任务队列已经被清空了
  if (currentTask !== null) {
    // 如果 task 队列没有被清空, 返回 true. 等待调度中心下一次回调
    return true;
  } else {
    // task 队列已经清空, 返回 false
    return false; 
  }
}

wookLoop 的返回值最终会传递给 performWorkUntilDeadline 中的 hasMoreWork 变量用于判断是否需要触发下一次 onmessage 事件,进行下一次调度,感兴趣的人可以回到上文查看,渐渐你就可以将这些函数串联起来了

前面只是从理论上讲述了时间切片思想,但是在 workLoop 函数中才真正的通过调用 shouldYieldToHost 函数将该思想应用了起来,有没有感觉真的很巧妙,并且我们还在该函数中看到了可中断渲染的操作,虽然还没看到任务内部是如何进行中断的,但已经清楚在一次时间片没有执行完的任务并不会从任务队列中移除,而是保留了下来等到下一次调度的时候继续执行

下面是从请求调度到最终消费任务队列的一整个过程,如果前面的知识你都已经理解了的话,那么下面这张图就会特别清晰

摸爬滚打系列——React 调度原理

可中断渲染

上文中已经提到了可中断思想,本质上就是将一次调度没有执行完的任务留到下一次调度的时候继续执行,从而不占用浏览器控制权太久导致浏览器迟迟不能进行渲染。不过可中断渲染还有一部分没有讲完,我们现在知道了单个任务执行之前都需要判断当前的时间片有没有用完,如果用完了一般就不继续执行后面的任务了,直接结束本次调度,那对于单个复杂的任务里面要不要判断时间片是否用完了呢?

答案是肯定的,比如 fiber 树的构建过程,这个任务是需要耗费相对来说较多的时间的,如果等到它执行完了之后才判断是否需要结束调度那肯定已经来不及了,因为早已超过了时间片的时间,所以我们必须要在 fiber 树的构建过程中检测任务是否超时,下面来看下对应源码,注意下面的 workLoopConcurrent 是调和阶段的函数,千万不要与调度阶段的 workLoop 混在一起了,两者没有必然的联系

// packages\react-reconciler\src\ReactFiberWorkLoop.new.js
// 开始遍历生成 fiber 树 
function workLoopConcurrent() {   
// 一直构建 fiber 树直到调度中心提醒我们要退出   
// 每一个 fiber 对象都是一个单元,每次构造完一个单元之后都会检测自己是否超时   
// 如果超时就退出 fiber 树构造并返回一个新的函数等待下一次调度   
    while (workInProgress !== null && !shouldYield()) {
    // 里面有两个阶段 beginWork 和 completeWork 构建 fiber 对象
        performUnitOfWork(workInProgress);
    }
}

原理还是很有意思的,React 将单个 fiber 对象当成是一个单元,整个 fiber 树的构造其实是通过循环构建一个个 fiber 节点,然后再将它们联系起来,而构建一个 fiber 节点是很快的,只要在每个单元构建完成之后通过 shouldYieldToHost 函数检查一下时间片是否用完就可以达到在任务执行过程中检查时间片的效果,这也是实现可中断渲染的关键——必须要知道何时才能进行中断

注意:可中断渲染代表的并不是浏览器渲染过程的中断,而是 fiber 树构建过程的中断,下面有一张整个调和、调度过程的流程图:

摸爬滚打系列——React 调度原理

总结

这一章搞明白之后我相信大家会感觉到收获很大,因为调度是 React 的核心,可想而知它的重要程度,文章基本将有关调度的流程走了一遍,从任务是怎么被创建的到最后任务是怎么被执行的,在本文中均有涉及,重点讲了 React 调度中最核心的两个优化手段:时间切片与可中断渲染,最后再来简单概述一下

  1. 时间切片:通过切分时间片结合浏览器的事件循环来约束调度过程中任务队列的最长执行时间,超过了该时间就需要停止任务队列的执行并等待下一次调度
  1. 可中断渲染:该思想主要针对于 fiber 树的构建过程,同时也依赖于时间切片,React 将单个 fiber 对象看成是一个单元,其允许在每一个单元构建之后检测时间片是否用完,如果用完则中断当前 fiber 树的构建,等到下一次调度的时候再继续构建上次未完成的 fiber 树。这样就完美解决了由于一次性遍历构造整颗 fiber 树,导致浏览器没有时间执行一些渲染任务、页面卡顿的问题

参考

由于 React 源码自己看真的很吃力,笔者也是选择跟读一些了一些优质的源码系列来进行学习,本篇文章是笔者看了下面两个系列对应的章节之后根据自身的理解整理出来的,不过文章中的代码片段和图片大部分还是来自下面的文章,希望作为一个普通前端能够帮助到大家更快速的理解源码~🐱🏍

7kms.github.io/react-illus…

juejin.cn/book/694599…