likes
comments
collection
share

react中的调度和时间切片原理

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

一、前言

我们知道老的react的架构主要包含了ReconcilerCommit.并且整个Reconclier中整个过程是同步不可中断的。在这样的前提下如果遇到大量dom更新的情况,便会出现页面的卡顿的情况。为了解决这个问题,react引入了Fiber架构,使整个Reconclier过程实现了可中断。

react18Concurrent特性下,结合Fiber架构,react可以将大任务分解为多个小任务,分配到多个浏览器每帧下的空余时间执行,这就是我们常说的时间切片。这样就不会阻塞浏览器的绘制任务,造成页面卡顿。在这个过程中,react是如何实现任务的调度,并且如何实现时间切片的呢。带着这两个问题,让我们一起探索。

二、Scheduler

没错实现任务调度和时间切片的主要模块就是Scheduler

一、时间切片

浏览器如何控制react更新的呢。我们知道浏览器在绘制一帧的时候会处理很多事物,包括事件处理,js执行,requestAnimationFrame回调,布局,绘制页面等等。同时谷歌浏览器提供了requestIdleCallback API。这个api可以在“浏览器重排/重绘”后如果当前帧还有空余时间时被调用的。听起来这是个完美实现时间切片的api,但由于兼容性的问题,react并没有使用requestIdleCallback,而是模拟实现了requestIdleCallback,这就是Scheduler

二、模拟requestIdleCallback

为了能模拟出requestIdleCallback,必须要做到以下两点。

  1. 可以主动让出线程,让浏览器执行其他任务。
  2. 在每帧下只执行一次,然后在下一帧中继续请求时间片。

能满足以上两种情况的便只有宏任务,而在宏任务中首选便是setTimeout。但是由于setTimeout会有4ms的时差,react放弃使用了setTimeout,改用了MessageChannel。(在不兼容Messagechannel的情况下依然使用setTimeout实现)

三、Messagechannel

MessageChannel有两个属性,

  • MessageChannel.port1(只读返回channel的port1)
  • MessageChannel.port2(只读返回channel的port2)

让我们来看一个使用例子

    let scheduledHostCallback = null 
    
    /* 建立一个消息通道 */ 
    var channel = new MessageChannel();
    
    /* 建立一个port发送消息 */ 
    var port = channel.port2; 
    
    channel.port1.onmessage = function(){ 
        /* 执行任务 */ 
        scheduledHostCallback() 
        /* 执行完毕,清空任务 */ 
        scheduledHostCallback = null 
    };
    
    /* 向浏览器请求执行更新任务 */ 
    requestHostCallback = function (callback) { 
        scheduledHostCallback = callback; 
        port.postMessage(null); 
    };
    
    const task = function() {
        console.log('这是浏览器任务')
    }
    
    requestHostCallback(task)

例子中首先实例化了 MessageChannel得到channelprot1通过onmessage来监听port2传过来的消息。并执行scheduledHostCallback函数。

三、调度原理

上一节我们说到,Scheduler本质上是使用MessageChannel实现的,那它又是如何实现任务调度,区分任务优先级的呢。事实上Scheduler,划分了多个优先级参数作为任务等级:

  • Immediate -1 需要立刻执行。
  • UserBlocking 250ms 超时时间250ms,一般指的是用户交互。
  • Normal 5000ms 超时时间5s,不需要直观立即变化的任务,比如网络请求。
  • Low 10000ms 超时时间10s,肯定要执行的任务,但是可以放在最后处理。
  • Idle 一些没有必要的任务,可能不会执行。

一般任务我们使用Normal,点击事件等任务会使用UserBlocking,以此区分优先级。为了探究Scheduler,让我们从根出发,在react-dom中是如何使用Scheduler的。

我们知道对于同步任务,react会进入performSyncWorkOnRoot函数,而对于异步更新的任务react会走pperformConcurrentWorkOnRoot逻辑,对于这两个函数都会走进不同的“workLoop”中。

performSyncWorkOnRoot -> renderRootSync -> workLoopSync

performConcurrentWorkOnRoot -> renderRootConcurrent -> workLoopConcurrent

workLoopSyncworkLoopConcurrent就是在执行最小任务单元,也就是我们的fiber节点

workLoopConcurrent会通过 shouldYield 来判断当前浏览器是否还有空余时间。而在 workLoopSync则没有shouldYield的判断,意味着会不间断的递归执行fiber单元。

function shouldYieldToHost() {
  var timeElapsed = exports.unstable_now() - startTime;

  if (timeElapsed < frameInterval) {
    // The main thread has only been blocked for a really short amount of time;
    // smaller than a single frame. Don't yield yet.
    return false;
  } // The main thread has been blocked for a non-negligible amount of time. We

  return true;
}

而无论是在 performSyncWorkOnRoot 或是在 performConcurrentWorkOnRoot 都会通过调用 ensureRootIsScheduled来发起一次任务调度。让我们来看一看

var schedulerPriorityLevel

switch (lanesToEventPriority(nextLanes)) {
  case DiscreteEventPriority:
    schedulerPriorityLevel = ImmediatePriority
    break

  case ContinuousEventPriority:
    schedulerPriorityLevel = UserBlockingPriority
    break

  case DefaultEventPriority:
    schedulerPriorityLevel = NormalPriority
    break

  case IdleEventPriority:
    schedulerPriorityLevel = IdlePriority
    break

  default:
    schedulerPriorityLevel = NormalPriority
    break
}

newCallbackNode = scheduleCallback$2(
  schedulerPriorityLevel,
  performConcurrentWorkOnRoot.bind(null, root)
)

ensureRootIsScheduled 中我们会通过将lan转换为 schedulerPriorityLevel ,然后调用 scheduleCallback$2, 并且(此处注意)会将 performConcurrentWorkOnRoot作为回调函数传入第二个参数。让我们接着往下看,其实 scheduleCallback$2 调用的就是 scheduleCallback。在Scheduler中对应到的函数就是 unstable_scheduleCallback

unstable_scheduleCallback 中的主体逻辑是这样子的

function unstable_scheduleCallback(priorityLevel, callback, options) {
  // 计算出过期时间
  var expirationTime = startTime + timeout;
  // 创建一个调度任务
  var newTask = {
    id: taskIdCounter++,
    callback: callback, 
    priorityLevel: priorityLevel,
    startTime: startTime,
    expirationTime: expirationTime,
    sortIndex: -1
  };
    
  // 如果任务开始时间大于当前时间,说明任务没有过期
  if (startTime > currentTime) {
    // This is a delayed task.
    // 以startTime作为排序标准
    newTask.sortIndex = startTime;
    // 将任务放入到timerQueue队列中
    push(timerQueue, newTask);
    
    requestHostTimeout(handleTimeout, startTime - currentTime); 
  } else {
    // 以expirationTime作为排序标准
    newTask.sortIndex = expirationTime;
    // 将过期任务放入到taskQueue中
    push(taskQueue, newTask);
    // wait until the next time we yield.
    
    requestHostCallback(flushWork);
  }

  return newTask;
}

在讲解 unstable_scheduleCallback 之前我们先来关注两个变量 taskQueuetimerQueue

  • taskQueue会以任务过期时间为排序标准,有序存放过期任务。
  • timerQueue会以任务开始时间为排序标准,有序存放未过期的任务
  1. unstable_scheduleCallback的整体逻辑就是,先通过任务开始事件+任务延迟时间,计算出任务过期时间。
  2. 新建一个调度任务
  3. 判断任务是否过期,未过期任务存放入 timerQueue 中,过期任务存放在 taskQueue中。 如果任务未过期会通过 requestHostTimeout延迟调用( requestHostTimeout的本质就是调用 setTimeout使任务延迟 ),如果任务过期了便会调用 requestHostCallback。(此时传给 requestHostCallback 的回调函数是 flushWork

我们先来看看 requestHostCallback

function requestHostCallback(callback) {
  // 将flushWork赋值给scheduledHostCallback
  scheduledHostCallback = callback;

  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

schedulePerformWorkUntilDeadline = function () {
    port.postMessage(null);
};

我们可以看到,调用 requestHostCallback本质上就是发起了一次 MessageChannel 的调用,产生了一个宏任务。最终会通过

channel.port1.onmessage = performWorkUntilDeadline;

performWorkUntilDeadline中执行。

var performWorkUntilDeadline = function () {

    try {
       // 此时还行的scheduledHostCallback就是flushWork
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      schedulePerformWorkUntilDeadline();
    }
};

以上代码省略了部分逻辑

此时执行的 scheduledHostCallback 就是 flushWork,最终会调用 workLoop

重点关注 workLoop这个函数。

function workLoop(){
  var currentTime = initialTime;
  advanceTimers(currentTime);
  /* 获取任务列表中的第一个 */
  currentTask = peek();
  while (currentTask !== null){
      /* 真正的更新函数 callback */
      var callback = currentTask.callback;
      
      if(callback !== null ){
         /* 执行更新 */
         // 执行真正的回调函数后返回了一个函数,这个函数其实也是performConcurrentWorkOnRoot
         var continuationCallback = callback(didUserCallbackTimeout);
         // 将返回的函数赋值给任务的callback
         currentTask.callback = continuationCallback;
        /* 先看一下 timeQueue 中有没有 过期任务。 */
        advanceTimers(currentTime);
      }
      /* 再一次获取任务,循环执行 */ 
      currentTask = peek(taskQueue);
  }
}

workLoop 中会先检测一遍是否有任务过期,然后取出最先过期的任务执行。到目前为止Scheduler 的整个调度流程就结束了。

时间切片实现原理

那有些同学会问,react 到底是如何利用 Scheduler 来实现事件切片的呢。

我们来看一下。

  1. workLoop 中执行 callback 其实就是执行 performConcurrentWorkOnRoot,最终会执行 workLoopConcurrent。当浏览器没有空闲时间的时候,执行结束,又返回了performConcurrentWorkOnRoot
 if (root.callbackNode === originalCallbackNode) {
    // The task node scheduled for this root is the same one that's
    // currently executed. Need to return a continuation.
    return performConcurrentWorkOnRoot.bind(null, root)
  }
  1. 此时我们又请求一次时间片,拿到的依然是上一次未执行完的任务,且执行的依然是上一次的 performConcurrentWorkOnRoot,那么 react 便会接着上一次的调度任务继续执行。

react设置时间切片的时间默认是5ms。

四、总结

最后盗用“我不是外星人”的流程图。相信大家对流程会有更好的理解

react中的调度和时间切片原理

转载自:https://juejin.cn/post/7159768909392904222
评论
请登录