React源码揭秘 | scheduler 并发更新原理

发布于:2025-02-13 ⋅ 阅读:(11) ⋅ 点赞:(0)

React 18增加了并发更新特性,开发者可以通过useTransition等hooks延迟执行优先级较低的更新任务,以达到页面平滑切换,不阻塞用户时间的目的。其实现正是依靠scheduler库。

scheduler是一个依赖时间片分片的任务调度器,React团队将其单独发包,你可以通过以下指令来单独使用。

npm install scheduler 

scheduler - npm

在CPU密集操作中(如大量echarts数据补点等)可以借助scheduler将大的任务拆分成子任务运行,并且不阻塞渲染引擎以及用户事件。

为了了解scheduler 我自己实现了一个my-scheduler库,精简了scheduler的实现并且增加了详细的注释,下面的讲解将依赖  my-sample-scheduler - npm 

项目地址: https://github.com/Gravity2333/my-scheduler

基本使用

scheduler库的入口是scheduleCallback,这个函数的定义如下

@param priority — 用户回调函数优先级
@param callback — 用户回调函数
@param delay — 可选,配置延迟任务 单位毫秒

scheduleCallback(
priorityLevel?: PriorityLevel, 
callback?: UserCallback, 
delay?: number): UserCallbackTask

其中,PriorityLevel为scheduler定义的优先级,其包含以下五种,优先级从高到低

IMMEDIATE_PRIORITY:立即执行任务,具有最高优先级。适用于需要立即执行的任务。

USER_BLOCKING_PRIORITY:用户阻塞任务,适用于需要交互的任务。NORMAL_PRIORITY:普通任务,默认优先级。

LOW_PRIORITY:低优先级任务,适用于不急需执行的任务。

IDLE_PRIORITY:空闲任务,只有在没有其他任务时才会执行。

callback为需要执行的回调,接受一个didTimeout: boolean参数,表示当前执行的任务是否超时,开发者需要决定是否继续执行任务,还是拆分成更小的任务下次执行。 

delay为延迟时间,传入之后表示当前任务为延迟任务,会在延迟时间结束之后接受调度并运行。

以下为简单使用DEMO:

import scheduler,{ PriorityLevel } from "my-sample-scheduler";

// 简单的任务调度
scheduler.scheduleCallback(
  PriorityLevel.NORMAL_PRIORITY,
  (didUserCallbackTimeout) => {
    console.log("任务开始执行", didUserCallbackTimeout);
    return; // 任务执行完毕
  }
);
// 次任务会立刻调度

// 延迟任务调度
scheduler.scheduleCallback(
  PriorityLevel.LOW_PRIORITY,
  (didUserCallbackTimeout) => {
    console.log('低优先级任务执行', didUserCallbackTimeout);
    return; // 任务执行完毕
  },
  1000 // 延迟1秒
);

// 此任务会在1s后被调度执行

再看一个拆分大任务的例子

// 生成随机的时间和数据
const generateData = (cnt: number) => {
  const data = [];
  for (let i = 0; i < cnt; i++) { // 每次生成1个数据点
    const value = Math.random() * 10; // 随机生成一个值
    data.push(value);
  }
  return data;
};


let generatedCnt = 0  

const generateHugeData: UserCallback = (didUserCallbackTimeout) => {
      if(generatedCnt > 10000) return // 超过10000 直接return 表示任务结束
      
      // 本次任务需要生成的点,如果没超时一次生成1000个 如果超时,则一口气生成剩下的所有点
      const currentGenerateCnt = didUserCallbackTimeout? 10000-generatedCnt : 1000
      
      // 生成点 耗时任务
      const newData = generateData(currentGenerateCnt);

      // 设置点
      setHugeData((prev) => [...prev, ...newData]);

      // 设置总共生成的点数
      generatedCnt += currentGenerateCnt

      // 返回completePoint 表示任务还没执行完 有剩余任务
      return completePoint;
};

// 注册scheduler事件
scheduler.scheduleCallback(PriorityLevel.IDLE_PRIORITY, generateHugeData);

比如当前我要随机生成10000个点, generateData是耗时任务,如果一次性同步生成10000个点,会导致浏览器卡住,阻塞浏览器渲染,但是通过scheduler,我们可以将其拆分成10次任务,每生成1000个点之后,就将剩下生成点的任务封装成子任务返回,并且让出主线程,这样就不会阻塞页面渲染。

实现原理

我们知道,浏览器只提供一个主线程给Javascript解析引擎和渲染引擎,这两个引擎通过事件循环交替工作。scheduler的实现就是参考了操作系统多任务中的 时间片分片策略。

一般浏览器渲染帧率为60HZ 也就是1s内,渲染引擎需要工作60次,算下来大概16.7MS 就要保证渲染引擎工作一次,否则就会造成渲染延迟,用户页面卡顿。

由于js解析引擎和渲染引擎的互斥性,并且一旦js引擎解析运行同步代码,就不能被打断,所以过多的CPU密集运算(如上面的补10000个点)就会造成js解析引擎过多占用主线程,导致渲染引擎无法工作,也无法响应用户事件!

scheduler的作用就是对任务进行拆分,并且把每个小任务通过宏任务运行(你可以先理解为放到settimeout中)当js解析引擎在一个循环中处理完同步代码,微任务代码后,会检查是否还有时间来处理这些在宏任务中的子任务。如果有则运行,如果没有就把主线程让给渲染引擎,在下一轮循环中,在找时机运行!

我们运行上面生成数据的例子(你可以下载my-scheduler项目,使用yarn start查看DEMO),通过performance可以观察到,每个任务都被分配给大概6ms左右的时间片来运行,当一个任务运行结束后,如果当前帧还有执行剩余任务的时间,就继续执行下一个任务,如果没有就让出主线程!

 执行单元&大小任务

上面我所用的任务可能不严谨,其实应该是成为执行单元,scheduler中定义了一个变量

const frameMS = 5

这个变量定义了每个执行单元所占用时间片的时间,一个执行单元开始运行的时候,会重置一个全局的startTime,表示本执行单元运行的开始时间,一般使用performance.now() 获取高精度时间戳。

一个执行单元可能运行多个任务,运行任务的数量取决于当前任务是 "大任务" 还是 “小任务”

大或是小,不由任务运行时常来决定,因为调度器在任务运行结束之前,是无法预测到任务的规模,所以scheduler将任务大小的划分,交给开发者决定

当传入scheduleCallback的回调函数返回一个函数时,scheduler默认其还有剩下没执行完的子任务,不管当前执行单元有没有用完当前5ms的时间切片,都结束当前的执行单元运行,在下一次执行单元被调度时继续运行。

当返回值不是个函数,那么默认其为小任务,一个执行单元只能运行一个大任务,但是可以运行多个小任务。这样设计的原因是,如果任务过小,那么和每个执行单元的调度,创建,运行的上下文代码所产生的开销相比起来,就不那么划算,合并到一个执行单元中运行能提升效率。简易时间轴如下:

以下为源码中workLoop函数中的部分,可以看到上述实现

// workLoop
    
  /** 还有时间 执行callback */
      const callback = currentTask.callback;

      if (typeof callback === "function") {
        // callback置空
        currentTask.callback = null;

        // 运行callback 获得返回值 
        const continuationCallback = callback(isUserCallbackTimeout);、
        if (typeof continuationCallback === "function") {
          // 如果返回了剩余任务,表示当前执行的是大任务,重新给task的callback赋值,结束workloop
          currentTask.callback = continuationCallback;
          // 表示还有任务
          return true;
        } else {
          // 当前任务执行完了,小任务,继续while执行
           ...
        }
      } else {
        // 如果callback为空 或者不是函数,说明当前任务不可执行 也可能是当前任务已经报错了,直接弹出
        this.taskQueue.pop();
      }

      // 此时 继续循环执行小任务 取下一个任务
      currentTask = this.taskQueue.peek();

 如何实现优先级调度

如果你只需要实现大任务拆分小任务,使用settimeout也可以简单实现,scheduler的核心在于实现了一套基于过期时间的优先级算法。

scheduler支持五种优先级,优先级大小从高到低,但是这五种优先级是如何落实到调度上的呢?

scheduler采用了过期时间来调度任务,其核心理念是,每次都找到最快过期的任务并且运行,而五种优先级,对应的就是五种timeout

/** 调度优先级 */
export enum PriorityLevel {
  /** 立即执行优先级 优先级最高 */
  "IMMEDIATE_PRIORITY" = "IMMEDIATE_PRIORITY",
  /** 用户阻塞优先级 此之 */
  "USER_BLOCKING_PRIORITY" = "USER_BLOCKING_PRIORITY",
  /** 正常默认优先级  */
  "NORMAL_PRIORITY" = "NORMAL_PRIORITY",
  /** 低优先级 */
  "LOW_PRIORITY" = "LOW_PRIORITY",
  /** IDLE 优先级 优先级最低 等待时间无限长 */
  "IDLE_PRIORITY" = "IDLE_PRIORITY",
}

/** 优先级到超时时间的映射  */
const PRIORITY_LEVEL_TO_TIMEOUT_MAP: Record<`${PriorityLevel}`, number> = {
  [PriorityLevel.IMMEDIATE_PRIORITY]: -1,
  [PriorityLevel.USER_BLOCKING_PRIORITY]: 250,
  [PriorityLevel.NORMAL_PRIORITY]: 500,
  [PriorityLevel.LOW_PRIORITY]: 1000,
  [PriorityLevel.IDLE_PRIORITY]: Number.MAX_SAFE_INTEGER,
};

可以看到,优先级越高,其对应的过期时间越短。也就是说,越急迫的任务就需要被更快的调度,每当scheduleCallback一个任务时,其内部就会通过PRIORITY_LEVEL_TO_TIMEOUT_MAP 把优先级转换成timeout。

如何避免低优先级任务"饿死"

考虑一种情况,如果当前scheduler中存在两个优先级任务,一个优先级很高,一个优先级很低,那么如果高优先级任务的代码运行中,一直往scheduler中注册高优先级任务,那么低优先级任务就一直得不到运行,导致任务 “饿死”

解决的办法就是引入startTime,并且计算出一个最终的expreationTime 其计算方法如下:

expirationTime = startTime + timeout

对于低优先级任务,虽然其expirationTime很大,但是对于后续加入进来的任务,由于startTime是递增的,所以长时间等待的任务优先级就会越来越高,expreationTime也相对越来越小,这就解决了任务饿死的问题。其在scheduleCallback中的实现如下:

 public scheduleCallback(
    priorityLevel: PriorityLevel = PriorityLevel.NORMAL_PRIORITY,
    callback: UserCallback = () => {},
    delay = 0
 ) {
 /** 获取当前高精度时间 */
    const currentTime = performance.now();
    /** 任务开始时间 */
    const startTime = currentTime + delay;

    /** 根据优先级,计算timeout
     *  默认为NORMAL 即 500
     */
    const timeout =
      PRIORITY_LEVEL_TO_TIMEOUT_MAP[priorityLevel] ||
      PRIORITY_LEVEL_TO_TIMEOUT_MAP[PriorityLevel.NORMAL_PRIORITY];

    /** 过期时间
     *  对于普通任务 currentTime + timeout
     *  对于延迟任务 currentTime + delay + timeout
     */
    const expirationTime = startTime + timeout;

    /** 把callback封装成UserCallbackTask  */
    const userCallbackTask: UserCallbackTask = {
      id: this.userTaskCnt++,
      priorityLevel,
      startTime,
      expirationTime,
      callback,
      sortIndex: -1,
    };

      /** 普通任务使用expirationTime 作为sortIndex调度 sortIndex可以理解为expreTime*/
      userCallbackTask.sortIndex = expirationTime;

      /** 加入taskQueue */
      this.taskQueue.push(userCallbackTask);
     
    // 调度任务

    return userCallbackTask;
  }
优先级排序&小顶堆

scheduler每次都会选择过期时间最小的任务运行,那么如何找到最小任务呢,最简单的做法是每次调度时都对任务根据优先级排序,但是排序算法的复杂度较高,一般为O(n^2)级别。并且,我们每次只需要找到最小的那个值,把其余的值都进行排序很浪费时间。

所以,scheduler维护了一棵小顶堆来获取最快过期的任务,小顶堆的好处在于,其插入,删除的复杂度为O(logn) 取得最小值的操作复杂度为O(1) 由于最开始没有任何任务,所以不需要建堆的过程,只需要每次插入,删除数据时,对其进行上下移来维护最小值即可。

以小顶堆为基础的任务队列在libs/mini-heap.ts中实现,其中主要方法是

MiniHeap.pop 删除节点

MiniHeap.push 插入节点

MiniHeap.peak 获取顶峰 优先级最高的节点

我们在scheduleCallback时,会将回调函数封装成任务,存入MiniHeap,并且根据过期时间维护最早过期任务。

scheduler中维护了两个任务队列 taskQueue和timerQueue 分别负责记录同步任务和延迟任务

/** 比较函数 比较两个任务的优先级 */
const compare = (a: UserCallbackTask, b: UserCallbackTask) => {
  const diff = a.expirationTime - b.expirationTime;
  return diff !== 0 ? diff : a.id - b.id;
};

/** 声明任务队列 */
private taskQueue: MiniHeap<UserCallbackTask> = new MiniHeap(compare);

/** 声明延迟队列 */
private timerQueue: MiniHeap<UserCallbackTask> = new MiniHeap(compare);

当我们scheduleCallback一个任务的时候,会根据delay参数是否传递,判断其是否为延迟任务,delay参数会被加入到StartTime中。 

    /** 获取当前高精度时间 */
    const currentTime = performance.now();
    /** 任务开始时间
     *  如果非延迟 就是currentTime
     *  如果配置了delay 则startTime = currentTime + delay
     */
    const startTime = currentTime + delay;

    /** 根据优先级,计算timeout
     *  默认为NORMAL 即 500
     */
    const timeout =
      PRIORITY_LEVEL_TO_TIMEOUT_MAP[priorityLevel] ||
      PRIORITY_LEVEL_TO_TIMEOUT_MAP[PriorityLevel.NORMAL_PRIORITY];

    /** 过期时间
     *  对于普通任务 currentTime + timeout
     *  对于延迟任务 currentTime + delay + timeout
     */
    const expirationTime = startTime + timeout;


    if (startTime > currentTime) {
      /** 如果是延迟任务, 用startTime来用作sortIndex排序调度 当达到开始时间后,转移到taskQueue */
userCallbackTask.sortIndex = startTime;
      /** 加入延迟队列 */
      this.timerQueue.push(userCallbackTask);
      
    } else {
      /** 如果是普通任务 普通任务使用expirationTime 作为sortIndex调度 */
      userCallbackTask.sortIndex = expirationTime;
      /** 加入taskQueue */
      this.taskQueue.push(userCallbackTask);
     
    }

如果是延迟任务,其startTime > currentTime 此时task.sortIndex = startTime 也就是,当到达delay时间之后,才开始同步调度,并且加入到timerQueue中

如果不是,则把expirationTime作为sortIndex 并且加入到taskQueue中。

scheduleCallback主要流程如下:

 开启调度&MessageChannel

 我们知道,scheduler的任务是作为宏任务运行的,如何创建宏任务?

你也许知道,requestIdleCallback 其会在浏览器空闲时间运行宏任务,但是这个API的兼容性不好

你也许会说setTimeout(callback,0) 没错,这样确实可以创建宏任务,但是setTimeout的问题在于,其delay参数即使不填,也会有4ms左右的延迟时间,即setTimeout(callback,4) 这段时间就会被浪费掉。

scheduler选了一种兼容性相对好,并且没有4ms延迟时间的创建宏任务方式,即MessageChannel

MessageChannel是一个构造方法,可以创建一个消息通道,其实例包含两个端口 port1 port2

在其中一段onmessage帮定事件,在另一端postMessage,其回调函数会在同步代码 微任务代码执行之后 立刻执行! 其使用方式如下:

const messageChannel = new MessageChannel()
messageChannel.port2.onmessage = ()=>{
    console.log('立刻执行')
}
messageChannel.port1.postmessage(null)

scheduler中,实现该逻辑的为 performWorkUntilDeadline 函数,为了兼容没有MessageChannel的环境,其用setTimeout做了兜底

  /** 调度任务 使用messageChannel
   *  messageChannel的好处是
   *  1. 可以创建宏任务 不阻塞主线程
   *  2. 相比于settimeout 延迟更小
   *  3. 在没有messageChannel的情况下,使用settimeout兜底
   */
  private schedulePerformWorkUntilDeadline() {
    if (typeof MessageChannel === "function") {
      const messageChannel = new MessageChannel();
      messageChannel.port2.onmessage = this.performWorkUntilDeadline.bind(this);
      /** 发送消息 */
      messageChannel.port1.postMessage(null);
    } else {
      /* 没有MessageChannel 用settimeout兜底*/
      setTimeout(() => {
        this.performWorkUntilDeadline();
      });
    }
  }

schedulePerformWorkUntilDeadline由requestHostCallback调用,这个命名应该是参考了requestIdleCallback

  /** 开启任务循环 */
  private requestHostCallback() {
    /** 在这里开启循环,并且上锁,保证只有一个performWorkUntilDeadline在运行 */
    if (!this.isMessageLoopRunning) {
      this.isMessageLoopRunning = true;
      this.schedulePerformWorkUntilDeadline();
    }
  }

requestHostCallback的作用是,检查isMessageLoopRuning锁是否可进入,如果可以则上锁并且开启调度,isMessageLoopRunning 表示消息循环是否在运行。如果当前消息循环在运行, 加入到任务队列中的任务一定能得到运行,就不必重复调度了。

消息循环MessageLoop

消息循环由requestHostCallback开启,直到taskQueue中没有任何任务停止,其标识为isMessageLoopRunning 示意图如下:

其中包含了 schdulePerformWorkUntilDeadline performWorkUntilDeadline flushWork workLoop

performWorkUntilDeadline

 performWorkUntilDeadline 就是执行一个任务单元,其时间片长度为frameMS=5ms 其实现如下:

  /** 持续循环运行任务
   * 开启一个时间切片的任务,时间切片的宽度为frameYieldMs 默认5ms
   * 每次时间切片运行结束后,如果还有任务,重复调用performWorkUntilDeadline继续运行
   * 没有任务了,则释放isMessageLoopRunning锁,循环停止运行
   */
  private performWorkUntilDeadline() {
    if (this.isMessageLoopRunning) {
      /** 获得每次循环的开始时间 */
      const workStartTime = performance.now();
      this.startTime = workStartTime;
      /**
       * 解释一下,这里为什么用try..finally
       * try中调用flushWork 执行任务,每次执行任务时,会从taskQueue中peek一个任务运行
       * peek出来之后,会先把task.callback保存到一个临时变量callback中,并且给 task.callback 赋 null
       * 判断这个临时的callback 如果是function 则运行,运行之后如果还有没运行完的任务 再给task.callback = remainingTaskFunc
       * 如果callback 不存在 或者不是函数 不可运行 则直接弹出这个任务
       *
       * 如果callback执行内部报错,那么此时 task.callback = null 并且跳出flushWork 这里的做法是,如果有错误则忽略掉,通过finally继续开启下一个performWorkUntilDeadline
       * 当下一个performWorkUntilDeadline开启后,由于task.callback = null 会直接pop出taskQueue 做到了忽略错误继续运行loop
       */
      let hasMorkWork = true;
      try {
        hasMorkWork = this.flushWork(workStartTime);
      } finally {
        if (hasMorkWork) {
          /** 还有任务 继续运行 */
          this.schedulePerformWorkUntilDeadline();
        } else {
          /** 没有任务了 关闭loop */
          this.isMessageLoopRunning = false;
        }
      }
    }
  }

其作用就是调用flushWork,开始一个任务单元,并且在flushWork执行结束后,判断是否还有剩余任务,如果有则再次通过schedulePerformWorkUntilDeadline重新调度任务单元执行。

这里需要注意,hasMorework默认值为true,为了是保证当flushWork中出现异常时,能继续调度运行任务单元,尝试“跳过”错误,后面会详细说

如果没有剩余任务 则关闭MessageLoop循环

任务开始之前需要重置全局的startTime,用来后面的shouldYiledToHost计算

flushWork

flushWork的主要作用是,给isPerformingWork上锁,表示任务单元正在运行,保留上下文优先级,执行workLoop,并且在workLoop执行结束之后,恢复isPerformingwork和优先级

  /**
   * flushWork 运行任务 一个5ms的时间 并且返回是否还有任务
   * @param workStartTime
   * @returns
   */
  private flushWork(workStartTime: number): boolean {
    /** flushWork 的作用是
     * 1. 调用workloop 并且保证workloop是临界资源,对其加锁
     * 2. 如果有延迟任务在运行,则取消掉,因为延迟任务没意义了
     * (延迟任务就是为了在延迟到达的时候把任务放到taskQueue 并且开启loop 在当前任务执行完之前 延迟任务即使到达了 也只能等着,在每次workLoop的小循环运行结束和workLoop运行结束 都会advaned)
     * 3.任务开始了 代表 hostCallbackSchedule的调度过程(loop触发过程)已经结束 释放锁
     * 4. 使用try..finally 忽略错误,在finally中释放isPerformWork
     *
     */
    this.isHostCallbackScheduled = false;
    // 定时任务没必要和messageLoop一起运行,这里取消掉定时器
    this.cancelHostTimeout();

    // 加锁
    this.isPerformingWork = true;
    const previousPriorityLeve = this.currentPriorityLevel;
    try {
      return this.workLoop(workStartTime);
    } finally {
      /** 注意 这里finally一定会在最后执行 即便上面有return (return只是标记了返回点) */
      this.isPerformingWork = false;
      /** 恢复优先级 */
      this.currentPriorityLevel = previousPriorityLeve;
    }
  }

 workLoop

workLoop是整个messageLoop的核心

第一步,获取当前的时间,并且优先通过advanceTimers 检查timerQueue,把延迟已经结束的任务放到taskQueue中去

获取当前taskQueue中优先级最高的任务

let workCurrentTime = workStartTime;

// 先检查一下有没有延迟任务需要加入到taskQueue

this.advanceTimers();

// 取得优先级最高的任务

let currentTask = this.taskQueue.peek();

第二步,取得任务,开始循环

首先需要判断 当前取出来的任务是否超时,也就是判断当前的时间 是不是在任务的过期时间之后,这个参数后面会传递给callback,由开发者决定到底要继续拆分任务,还是一口气执行完

如果没超时,需要判断此时是否超过了当前任务单元的时间片,即5ms,判断的逻辑由shouldYieldToHost提供 (注意,前面performWorkUntilDeadline中设置的全局startTime就是这里用的,判断是否超出时间片时间)

  /** 是否应当让出主线程 */
  public shouldYieldToHost(): boolean {
    const timeElapsed = performance.now() - this.startTime;
    if (timeElapsed < frameYieldMs) {
      // 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;
    }
    // Yield now.
    return true;
  }

后面就是执行callback了,根据返回值判断是否为大小任务,前面说过 不赘述了 

    let isUserCallbackTimeout = false;
    while (currentTask) {
      // 更新判断是否超时
      isUserCallbackTimeout = currentTask.expirationTime < workCurrentTime;

      if (!isUserCallbackTimeout && this.shouldYieldToHost()) {
        // 让出主线程
        break;
      }

      /** 还有时间 执行callback */
      const callback = currentTask.callback;

      if (typeof callback === "function") {
        // callback置空
        currentTask.callback = null;
        // 更新优先级
        this.currentPriorityLevel = currentTask.priorityLevel;
        // 保证callback可调用
        const continuationCallback = callback(isUserCallbackTimeout);
        if (typeof continuationCallback === "function") {
          // 如果返回了剩余任务,表示当前执行的是大任务,重新给task的callback赋值,结束workloop
          currentTask.callback = continuationCallback;
          // 看一下是否有可以加入到taskQueue的延迟任务
          this.advanceTimers();
          // 表示还有任务
          return true;
        } else {
          // 当前任务执行完了,小任务,继续while执行
          if (currentTask === this.taskQueue.peek()) {
            this.taskQueue.pop(); // 弹出当前执行完的任务
          }
          // 看一下是否有可以加入到taskQueue的延迟任务
          this.advanceTimers();
        }
      } else {
        // 如果callback为空 或者不是函数,说明当前任务不可执行 也可能是当前任务已经报错了,直接弹出
        this.taskQueue.pop();
      }

      // 此时 继续循环执行小任务 取下一个任务
      currentTask = this.taskQueue.peek();
    }

这里面有个操作是,当使用taskQueue.peak() 拿到优先级最高的任务后,保存callback并且把任务的callback置为null ,如果当前任务还有子任务,就把continuationCallback再赋回去,这样做的好处是,如果callback抛出异常,那么此时workloop,flushwork都会停止运行,但是由于perfromWorkUntilDeadline中的hasMoreWork默认值为true,保证在抛出错误后还能继续运行MessageLoop,那么在下次运行到workLoop时,由于callback为空,会直接弹出,保证了在出现错误时还能正常运行其他任务!

执行单元运行结束后,需要判断后面还有没有要执行的任务,如果没有需要再次启动计时器,保证延迟任务被正确调度


    // 执行到这里,1.可能是是currentTask没超时,但是没有时间片了,推出workLoop,返回true表示还是任务 2.可能是没任务了
    if (currentTask) {
      return true;
    } else {
      // taskQueue没有任务了,此时返回false,此时flushwork结束,messageLoop结束,需要开启延迟任务,以确保在延迟到达时,能启动messageLoop
      const timerTask = this.timerQueue.peek();
      if (timerTask) {
        // 检查timerQueue 有任务则开启
        const firstTimer = this.timerQueue.peek();
        if (firstTimer) {
          this.requestHostTimeout(
            this.handleTimeout,
            firstTimer.startTime - performance.now()
          );
        }
      }
      return false;
    }

MessageLoop的主要流程如下: 

 

延迟任务的处理

延迟任务的处理主要就是开启定时器,在定时器结束之后,调用advanedTimers,把延迟任务加入到taskQueue中调度,其实现如下:

  /** 这个函数的作用是,检查延迟队列,如果有已经完成延迟的 则加入任务队列 */
  private advanceTimers() {
    let currentTimerTask = this.timerQueue.peek();
    while (currentTimerTask) {
      // 判断,任务的callback是否为函数 如果不是 无法执行 直接弹出
      if (typeof currentTimerTask.callback !== "function") {
        this.taskQueue.pop();
      } else {
        // 检查延迟是否结束 判断条件是 task.expireTime < currentTime
        const currentTime = performance.now();
        if (currentTimerTask.expirationTime < currentTime) {
          // 弹出当前任务
          this.timerQueue.pop();
          // 设置sortIndex
          currentTimerTask.sortIndex = currentTimerTask.expirationTime;
          // 加入taskQueue
          this.taskQueue.push(currentTimerTask);
        } else {
          // 如果没有超时 由于peek拿到的是最快过期的任务,所以后面的不用检查了 直接return
          return;
        }
      }

      // 处理下一个timerTask
      currentTimerTask = this.timerQueue.peek();
    }
  }

如何结合React

我们知道,React和scheduler有不同的优先级系统。在React中,通常需要把lane优先级转换成scheuler优先级并且调用scheduleCallback 这就需要React内部的lanesToSchedulerPriority

同时,当我们发起更新时,也需要获取scheduler内部正在运行的优先级,这就需要scheduler提供的getCurrentPriorityLevel方法

/** 获取当前的优先级 */
  getCurrentPriorityLevel() {
    return this.currentPriorityLevel;
  }

React中fiberLane.ts下的requestUpdateLane就是调用这个方法获取scheduler优先级,并且通过schedulerPriorityToLane转换成lane优先级

事件处理

React的不同事件对应不同的优先级,其内部维护了一个事件优先级到scheduler优先级的转换函数来完成这个转换过程。

function eventTypeToSchedulerPriority(eventType: string) {
  switch (eventType) {
    case "click":
    ...
      return PriorityLevel.IMMEDIATE_PRIORITY;
    case "scroll":
    case "touchend":
    ...
      return PriorityLevel.USER_BLOCKING_PRIORITY;

    case "input":
    ...
      return PriorityLevel.NORMAL_PRIORITY;

    case "abort":
    case "load":
    ...
      return PriorityLevel.LOW_PRIORITY;

    default:
      return PriorityLevel.IDLE_PRIORITY;
  }
}

同时,为了保证事件内的hooks setter 如setXXX 能正确获得当前的优先级,其调用scheduler中的runWithPriority,其作用就是修改当前全局优先级,并且运行事件处理代码

  /** 以某优先级运行 */
  runWithPriority(priorityLevel: PriorityLevel, callback: any) {
    const priviouseLevel = this.currentPriorityLevel;
    this.currentPriorityLevel = priorityLevel;
    try {
      callback();
    } catch (e) {
      console.warn(e);
    } finally {
      this.currentPriorityLevel = priviouseLevel;
    }
  }

在callback内部,如果存在setXXX hooks setter,其内部的dispatchSetState ,requestUpdateLane就会获取到当前的全局优先级!

以上就是scheduler库的简单介绍和总结