react scheduler 再解析篇

顾名思义,scheduler 用于完成调度工作。

主流程

任务队列

scheduler 使用 Heap 堆存放任务队列。每个任务在进入 Heap 堆前,会根据 sortIndex、id 属性进行优先级排序。它首先比较 sortIndex 值,值越小,优先级越高;其次以相同的规则比较 id(即比较任务的创建顺序)。依循优先级的高低,scheduler 采用位运算取中位节点的方式交换任务在 Heap 堆中的位置。push 函数用于将任务添加到 Heap 堆的尾端,并重新按优先级调整 Heap 堆。peek、pop 用于取出 Heap 堆中的首个任务。其中,pop 会从 Heap 堆中移除首个任务,并重新按优先级调整 Heap 堆;peek 不会移除。在使用上,peek 主要拣选 Heap 堆中的首个任务;若任务已执行或已取消,则使用 pop 移除。详情可参考 SchedulerMinHeap.js

scheduler 使用 Heap 堆机制构造了两个队列:taskQueue、timerQueue。taskQueue 存放将被处理的任务;timerQueue 存放延期处理的任务。可以推想的处理流程是:首先使用 peek 函数取出 taskQueue 中的任务并执行,完成后使用 pop 函数移除;在执行期间,检查当前时间是否满足 timerQueue 中任务的延期标准,若满足,将该任务移入 taskQueue,并从 timerQueue 中移除。任务由 timerQueue 流向 taskQueue 的过程由 advanceTimers 函数 完成。

任务节点

每个任务节点抽象为以下属性的集合:

  • id:任务节点的序号,创建任务时自增 1
  • callback:任务函数执行内容
  • priorityLevel:任务的优先级。优先级按 ImmediatePriority、UserBlockingPriority、NormalPriority、LowPriority、IdlePriority 顺序依次越低
  • startTime:时间戳,任务预期执行时间,默认为当前时间,即同步任务。可通过 options.delay 设为异步延时任务
  • expirationTime:过期时间,scheduler 基于该值进行异步任务的调度。通过 options.timeout 设定或 priorityLevel 计算 timeout 值后,timeout 与 startTime 相加称为 expirationTime
  • sortIndex:默认值为 -1。对于异步延时任务,该值将赋为 expirationTime

在这里,有必要先比较以下 startTime、expirationTime:startTime 是用户侧设定的预期执行时间;scheduler 会对交互行为、计算行为分派不同的优先级,这就没法基于 startTime 进行调度,而是要基于优先级进行调度。UserBlockingPriority 交互行为优先级高,expirationTime 以及任务的 sortIndex 属性相应也低。在同一个队列中,对于 startTime 相同的任务,scheduler 会率先处理 expirationTime 较低的任务(即优先级较高的任务)。expirationTime 的另一个作用是,作为任务的最后执行期限,即如果当前时间未到达任务的最后执行期限,那么任务就可以不被执行。

任务运行流程

创建任务的唯一接口为 unstable_scheduleCallback(priorityLevel, callback, options) 函数。任务分为两种,同步任务或异步延时任务。同步任务在 unstable_scheduleCallback 调用期间就会添加到 taskQueue 队列,且通过封装宿主环境 api 的 requestHostCallback 函数立即执行;异步延时任务会添加到 timerQueue 队列,且通过封装宿主环境 api 的 requestHostTimeout 函数延后执行。我们先按下 requestHostCallback、requestHostTimeout 宿主环境的封装接口不表,先以图示剖析一下 unstable_scheduleCallback 的执行流程:

在上图中,以 workLoop 方式循环调度 taskQueue 队列或以 handleTimeout 递归调度 timerQueue 队列这两种方式,只有一个在激活状态,也即 requestHostCallback、requestHostTimeout 只有一个在调用周期中。因为,taskQueue 队列调度完毕,会调用 requestHostTimeout 处理 timerQueue 队列;timerQueue 队列有一个任务进入 taskQueue,又会调用 requestHostCallback 处理 taskQueue 队列。

另外,workLoop 循环也使任务具有可并发执行的特性。任务若 返回函数,这个函数将作为 currentTask.callback 执行内容,即在 workLoop 循环中保证任务的回调被立即执行。

任务的暂停、中止等

我们再来看看封装宿主环境 api 的 requestHostCallback、requestHostTimeout。在浏览器环境中,react 通过 MessageChannel 发送消息的方式触发任务的真正执行,详情可参看 requestHostCallback 函数 的实现,效果是在渲染后执行任务。至于 requestHostTimeout,react 则使用 setTimeout 方法实现。

任务的暂停与恢复

需要指出的是,在单次 workLoop 循环中,如果 taskQueue 队列未被清空(比如任务被暂停了),react 会基于 MessageChannel 再次 发送消息,以处理 taskQueue 队列。为此,scheduler 对外提供 unstable_pauseExecutionunstable_continueExecution 接口用于暂停、恢复任务,这就是 react fiber 搜罗整理篇 提到的任务暂停与恢复。

任务的中止

至于任务的中止,scheduler 提供 unstable_getFirstCallbackNode 函数 用于获取 taskQueue 的首个任务,然后就可以使用 unstable_cancelCallback 接口 销毁任务了。

避免任务长期工作

为了避免工作任务长期占用主线程,react 使用帧率计算任务的 yieldInterval 最大工作时长。scheduler 允许使用 forceFrameRate 设置任务的最大工作时长。在任务执行开始时,scheduler 会基于任务的开始工作时间加 yieldInterval 计算任务的 deadline 暂停时间,一旦 workLoop 执行到 deadline 时间后,scheduler 会让出主线程以执行其他任务。判断任务运行是否达到 deadline,基于 shouldYieldToHost 函数 实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;

while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())// 达到任务的最大运行时长,让出主线程
) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
}
// ...
}

scheduler 对外提供了 unstable_shouldYield 接口 用于判断是否用暂停当前任务的执行,其一在于新添加任务的优先级高于当前任务,其二在于当前任务已达到 deadline。届时就可以调用 unstable_pauseExecution 暂停 workloop 循环,执行高优先级任务或仅让出主线程。

更改优先级

unstable_next、unstable_runWithPriority 接口会改写 currentPriorityLevel,而 fiber 任务的优先级又是通过 currentPriorityLevel 实现的,这样就会使 unstable_scheduleCallback 执行期间的任务被置为特定的优先级。效果可参考 [译] React 中的调度