深入浅出React(Scheduler调度器的源码分析)

·  阅读 1206
深入浅出React(Scheduler调度器的源码分析)

本文正在参加「金石计划 . 瓜分6万现金大奖」 大家好,我是六六。这是深入浅出react的第五篇,主要讲述react中的调度器,本章从以下3个问题开始:

  • Scheduler是什么,作用是什么?
  • 什么是任务管理队列,什么是时间切片?
  • 从调度入口函数开始一步一步分析源码。

Scheduler是什么,作用是什么?

Scheduler是一个任务调度器,它根据任务的优先级对任务进行调度执行。在React运行时,作为中枢控制着整个流程。Scheduler是一个独立的包,不仅仅在React中可以使用。

作用:为了提升用户体验,避免低优先级任务长时间占用调用栈而导致高优先级用户操作的任务无法被及时响应。

什么是任务管理队列,什么是时间切片?

任务管理队列

在Scheduler内部,维护着两个任务队列,如下:

  • timerQueue:存储未过期的任务。
  • taskQueue:存储已过期的任务。

如何区分任务是否过期呢?

在每一个任务当中,都有一个过期时间expirationTime,只需要判断expirationTime和当前时间currentTime作比较就好。当过期时间大于当前时间,说明任务未过期,放到timerQueue,当过期时间小于当前时间,说明任务已过期,放到taskQueue。

expirationTime的依据是什么?

 var expirationTime = startTime + timeout;
复制代码
  • startTime:任务开始时间
  • timeout:任务过期时间,这个时间是根据任务优先级决定的,看下面代码:
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

var maxSigned31BitInt = 1073741823;
复制代码

分为5个等级,最高级的过期时间为-1,最低则为1073741823

时间切片

通过名字我们就能知道这是把时间切分成若干片,再每个时间片中去执行任务。那么为什么需要去切分,不能一次执行吗?主要是为了性能问题,当js执行时间过长时会导致用户的操作无法及时得到反馈。为了解决这种问题,就把js执行时的任务进行分片执行了。

MessageChannel

MessageChannel是一种web的通信方式,是一种通道通信。具体用法如下:

var channel = new MessageChannel();
channel.port1.onmessage = (e) => {console.log(e)}
channel.port2.postMessage('Hello World')
复制代码

我们可以通过port2进行发送消息,port1进行监听port2发送的消息。而MessageChannel属于宏任务,和setTimeout一样。所以onmessage触发的时机就是eventLoop中的下一个宏任务。

onmessage函数每次执行都是一个时间切片,在react规定了5ms的执行时间,当react当中的任务执行超过5s时,就会调用postMessage发送消息,同时停止下个任务的执行,剩下的react任务只能再下次宏任务中执行了。

核心流程

借鉴一下这个系统示意图。

f89de1d3fd6680420b784f93dbcdd48.jpg 通过这个图,我们可以把整个系统分成三部分:

  • React:产生各种任务
  • SchedulerWithReactIntegration:React的优先级转换成Scheduler优先级
  • Scheduler:任务调度器

优先级可以参考我之前的文章,我们本次重点看Scheduler。

调度入口

从上面图我们可得知调度的入口是unstable_scheduleCallback函数,把它分为3块:

  • 1.计算过期时间
  • 2.创建调度任务
  • 3.放入任务管理队列。

计算过期时间

function unstable_scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: Callback,
  options?: {delay: number},
): Task {
  var currentTime = getCurrentTime();  // 获取当前时间

  var startTime; // 开始时间
  if (typeof options === 'object' && options !== null) { // 从options中尝试获取delay,也就是推迟时间
    var delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay; // 如果有delay,那么任务开始时间就是当前时间加上delay
    } else {
      startTime = currentTime; // 没有delay,任务开始时间就是当前时间,也就是任务需要立刻开始
    }
  } else {
    startTime = currentTime;
  }

  var timeout;                                // 根据优先级计算timeout
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;   // -1
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT; // 250
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;  // 1073741823
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;  //  10000
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;  //  5000
      break;
  }
  var expirationTime = startTime + timeout; // 得到过期时间
复制代码

这一部分主要就是根据当前时间和优先级的timeout算出过期时间

创建调度任务

var newTask = {
    id: taskIdCounter++,
    // 任务函数
    callback,
    // 任务优先级
    priorityLevel,
    // 任务开始的时间
    startTime,
    // 任务的过期时间
    expirationTime,
    // 在小顶堆队列中排序的依据
    sortIndex: -1,
  };
复制代码

主要说明一下,callback是真的任务函数,用于构建fiber树的任务函数。sortIndex再区分好任务类型(过期任务和未过期任务)后,在任务队列排序的依据。

放入任务队列

if (startTime > currentTime) {   // 任务未过期
    // This is a delayed task.
    newTask.sortIndex = startTime; // 用开始时间作为timerQueue排序依据
    push(timerQueue, newTask);
    //  如果现在taskQueue中没有任务,并且当前的任务是timerQueue中排名最靠前的那一个
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) { 
      if (isHostTimeoutScheduled) { // 需要检查timerQueue中有没有需要放到taskQueue中的任务
        cancelHostTimeout(); // 有就取消之前的调度任务
      } else {
        isHostTimeoutScheduled = true;
      }
      requestHostTimeout(handleTimeout, startTime - currentTime);  // 调用requestHostTimeout开始调度
    }
  } else { // 任务已经过期,以过期时间作为taskQueue排序的依据
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
    if (enableProfiling) {
      markTaskStart(newTask, currentTime);
      newTask.isQueued = true;
    }
   // 开始执行任务
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }
复制代码

首先会去判断任务是否过期:

  • 任务未过期时,用startTime作为timerQueue排序依据,将任务推入timerQueue.
  • 判断taskQueue此时是否有任务且判断当前的任务是timerQueue中排名最靠前的那一个
  • 再通过isHostTimeoutScheduled字段检查timerQueue中有没有需要放到taskQueue中的任务
  • 有的话就取消这个调度任务
  • 调用requestHostTimeout开始调度
  • 任务已过期时,以过期时间作为taskQueue排序的依据
  • 开始执行任务 requestHostCallback,使用flushWork去执行taskQueue

上面就是整个unstable_scheduleCallback调度入口的函数逻辑。关于调度的这几个函数(requestHostTimeout,requestHostCallback)我们下面来讲。

requestHostCallback

上面我们知道,我们通过调用requestHostCallback(flushWork);让任务进入调度流程。传入的形参是flushWork,我们先看看requestHostCallback内部逻辑,再来看看flushWork到底是什么?

// 请求回调
requestHostCallback = function(callback) {
  // 1. 保存callback
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    // 2. 通过 MessageChannel 发送消息
    port.postMessage(null);
  }
};
复制代码

requestHostCallback函数主要做了两件事:

    1. 保存callback函数到scheduledHostCallback
    1. 通过 MessageChannel 发送消息

我们再看看MessageChannel是从哪里接收信息的

channel.port1.onmessage = performWorkUntilDeadline;
  
const performWorkUntilDeadline = () => {
   // 判断任务函数
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime(); // 获取当前时间
    
    startTime = currentTime;
    const hasTimeRemaining = true; // 表示任务是否还有剩余时间

  
    let hasMoreWork = true;
    try {
     
        // scheduledHostCallback去执行任务的函数,
        // 当任务因为时间片被打断时,它会返回true,表示
        // 还有任务,所以会再让调度者调度一个执行者
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      if (hasMoreWork) {
        // 此函数内部执行port.postMessage(null);通过 MessageChannel 发送消息
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
 
  needsPaint = false;
};
复制代码

performWorkUntilDeadline就是我们真正执行任务的地方,内部调用了scheduledHostCallback,它在前面已经被赋值了为flushWork,所以我们再看看flushWork具体做了什么?

function flushWork(hasTimeRemaining: boolean, initialTime: number) {
  if (enableProfiling) {
    markSchedulerUnsuspended(initialTime);
  }
  isHostCallbackScheduled = false;
  if (isHostTimeoutScheduled) {
    // We scheduled a timeout but it's no longer needed. Cancel it.
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }

  isPerformingWork = true;
  const previousPriorityLevel = currentPriorityLevel;
  try {
    if (enableProfiling) {
      try {
        return workLoop(hasTimeRemaining, initialTime);
      } catch (error) {
        if (currentTask !== null) {
          const currentTime = getCurrentTime();
          // $FlowFixMe[incompatible-call] found when upgrading Flow
          markTaskErrored(currentTask, currentTime);
          // $FlowFixMe[incompatible-use] found when upgrading Flow
          currentTask.isQueued = false;
        }
        throw error;
      }
    } else {
      // No catch in prod code path.
      return workLoop(hasTimeRemaining, initialTime);
    }
  } finally {
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
    if (enableProfiling) {
      const currentTime = getCurrentTime();
      markSchedulerSuspended(currentTime);
    }
  }
}
复制代码

重点进到这句代码,return workLoop(hasTimeRemaining, initialTime),通过命名我们就知道这是任务循环的地方,也是任务执行的核心

function workLoop(hasTimeRemaining, initialTime) {

  // 获取taskQueue中排在最前面的任务
  currentTask = peek(taskQueue);
  while (currentTask !== null) {

    if (currentTask.expirationTime > currentTime &&
     (!hasTimeRemaining || shouldYieldToHost())) {
       // break掉while循环
       break
    }

    ...
    // 执行任务
    ...

    // 任务执行完毕,从队列中删除
    pop(taskQueue);

    // 获取下一个任务,继续循环
    currentTask = peek(taskQueue);
  }


  if (currentTask !== null) {
    // 如果currentTask不为空,说明是时间片的限制导致了任务中断
    // return 一个 true告诉外部,此时任务还未执行完,还有任务,
    return true;
  } else {
    // 如果currentTask为空,说明taskQueue队列中的任务已经都
    // 执行完了,然后从timerQueue中找任务,调用requestHostTimeout
    // 去把task放到taskQueue中,到时会再次发起调度,但是这次,
    // 会先return false,告诉外部当前的taskQueue已经清空,
    // 先停止执行任务,也就是终止任务调度

    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }

    return false;
  }
}
复制代码

workLoop工作流程:

  • 获取taskQueue最前面的任务
  • 判断任务是否终止
  • 执行任务
  • 任务执行完毕从队列中删除
  • 获取下次任务,继续循环

任务中断则是整个工作流程最重要的部分。

中断任务

我们知道currentTask就是当前正在执行的任务,判断它的终止条件有以下几点:

  • 任务未过期currentTask.expirationTime > currentTime
  • 没有剩余时间了!hasTimeRemaining
  • 时间片限制,让出控制权shouldYieldToHost()

当我们中断任务之后,就会退出whihe循环,继续执行workLoop剩下逻辑.当currentTask不为空,就会return一个ture,告诉外面任务没有执行完毕,任务被时间片中断了,需要将控制权交给浏览器。当返回false时,则说明本次的调度任务全部执行完毕,让外部终止本次调度。回顾之前的代码片段:

if (hasMoreWork) {
        // 此函数内部执行port.postMessage(null);通过 MessageChannel 发送消息
        schedulePerformWorkUntilDeadline();
      } else {
       // 如果没有任务了,停止调度
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
复制代码

取消调度

根据上一篇优先级的文章我们知道,当一个任务在执行,突然有优先级更高的任务进来时,我们需要取消之前的调度。根据上面workLoop的函数我们知道,真正核心的函数是currentTask.callback,所以我们只要将callback设为空就好了。

function workLoop(hasTimeRemaining, initialTime) {
  ...

  // 获取taskQueue中最紧急的任务
  currentTask = peek(taskQueue);
  while (currentTask !== null) {
    ...
    const callback = currentTask.callback;

    if (typeof callback === 'function') { // 低优先级callback置为null
      // 执行任务
    } else {
      // 如果callback为null,将任务出队(低优先级任务移除队列)
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
  }
  ...
}
复制代码

回到上面的workLoop函数,当callback为空之后,我们就会将这个任务移除队列,此时while循环不会结束。通过peek函数获取到currentTask就是我们高优先级的任务,然后执行任务。调度完美实现了高优先级中断低优先级任务。那么此时就会有一个问题被中断的任务去哪了?这个看过我上篇优先级管理的文章就会知道,我们会去重启ensureRootIsScheduled,发起新的一次调度,重启那些被中断的任务。

站在巨人的肩膀上

总结

本篇主要介绍调度器scheduler的原理,讲述scheduler是如何通过优先级来实现多任务管理。从scheduler入口函数unstable_scheduleCallback一步一步讲到调度器的核心-调度任务循环workLoop。讲述了任务是如何循环执行以及是如何中断任务来执行高优先级任务。

收藏成功!
已添加到「」, 点击更改