Scheduler 的原理与实现
在一节我们介绍了Scheduler
,他包含两个功能:
时间切片
优先级调度
本节我们学习这个两个功能是如何在Scheduler
中实现的。
时间切片原理
时间切片
的本质是模拟实现requestIdleCallback。
除去“浏览器重排/重绘”,下图是浏览器一帧中可以用于执行JS
的时机。
一个task(宏任务) -- 队列中全部job(微任务) -- requestAnimationFrame -- 浏览器重排/重绘 -- requestIdleCallback
requestIdleCallback
是在“浏览器重排/重绘”后如果当前帧还有空余时间时被调用的。
浏览器并没有提供其他API
能够在同样的时机(浏览器重排/重绘后)调用以模拟其实现。
唯一能精准控制调用时机的API
是requestAnimationFrame
,他能让我们在“浏览器重排/重绘”之前执行JS
。
这也是为什么我们通常用这个API
实现JS
动画 —— 这是浏览器渲染前的最后时机,所以动画能快速被渲染。
所以,退而求其次,Scheduler
的时间切片
功能是通过task
(宏任务)实现的。
最常见的task
当属setTimeout
了。但是有个task
比setTimeout
执行时机更靠前,那就是MessageChannel。
所以Scheduler
将需要被执行的回调函数作为MessageChannel
的回调执行。如果当前宿主环境不支持MessageChannel
,则使用setTimeout
。
在React
的render
阶段,开启Concurrent Mode
时,每次遍历前,都会通过Scheduler
提供的shouldYield
方法判断是否需要中断遍历,使浏览器有时间渲染:
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
是否中断的依据,最重要的一点便是每个任务的剩余时间是否用完。
在Schdeduler
中,为任务分配的初始剩余时间为5ms
。
你可以从这里看到初始剩余时间的定义
随着应用运行,会通过fps
动态调整分配给任务的可执行时间。
你可以从这里看到动态分配任务时间
这也解释了为什么一节启用Concurrent Mode
后每个任务的执行时间大体都是多于5ms的一小段时间 —— 每个时间切片被设定为5ms,任务本身再执行一小段时间,所以整体时间是多于5ms的时间
那么当shouldYield
为true
,以至于performUnitOfWork
被中断后是如何重新启动的呢?我们会在介绍完"优先级调度"后解答。
优先级调度
首先我们来了解优先级
的来源。需要明确的一点是,Scheduler
是独立于React
的包,所以他的优先级
也是独立于React
的优先级
的。
Scheduler
对外暴露了一个方法unstable_runWithPriority。
这个方法接受一个优先级
与一个回调函数
,在回调函数
内部调用获取优先级
的方法都会取得第一个参数对应的优先级
:
function unstable_runWithPriority(priorityLevel, eventHandler) {
switch (priorityLevel) {
case ImmediatePriority:
case UserBlockingPriority:
case NormalPriority:
case LowPriority:
case IdlePriority:
break;
default:
priorityLevel = NormalPriority;
}
var previousPriorityLevel = currentPriorityLevel;
currentPriorityLevel = priorityLevel;
try {
return eventHandler();
} finally {
currentPriorityLevel = previousPriorityLevel;
}
}
可以看到,Scheduler
内部存在5种优先级。
在React
内部凡是涉及到优先级
调度的地方,都会使用unstable_runWithPriority
。
比如,我们知道commit
阶段是同步执行的。可以看到,commit
阶段的起点commitRoot
方法的优先级为ImmediateSchedulerPriority
。
ImmediateSchedulerPriority
即ImmediatePriority
的别名,为最高优先级,会立即执行。
function commitRoot(root) {
const renderPriorityLevel = getCurrentPriorityLevel();
runWithPriority(
ImmediateSchedulerPriority,
commitRootImpl.bind(null, root, renderPriorityLevel),
);
return null;
}
优先级的意义
Scheduler
对外暴露最重要的方法便是unstable_scheduleCallback。该方法用于以某个优先级
注册回调函数。
比如在React
中,之前讲过在commit
阶段的beforeMutation
阶段会调度useEffect
的回调:
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
这里的回调便是通过scheduleCallback
调度的,优先级为NormalSchedulerPriority
,即NormalPriority
。
不同优先级
意味着什么?不同优先级
意味着不同时长的任务过期时间:
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;
}
var expirationTime = startTime + timeout;
其中:
// Times out immediately
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;
可以看到,如果一个任务的优先级
是ImmediatePriority
,对应IMMEDIATE_PRIORITY_TIMEOUT
为-1
,那么
var expirationTime = startTime - 1;
则该任务的过期时间比当前时间还短,表示他已经过期了,需要立即被执行。
不同优先级任务的排序
我们已经知道优先级
意味着任务的过期时间。设想一个大型React
项目,在某一刻,存在很多不同优先级
的任务
,对应不同的过期时间。
我们可以将这些任务按是否过期分为:
已过期任务
未过期任务
所以,Scheduler
存在两个队列:
timerQueue:保存未过期任务
taskQueue:保存已过期任务
每当有新的未过期任务被注册,我们将其插入timerQueue
并重新排列timerQueue
中任务的顺序。
当timerQueue
中有任务过期,我们将其取出并加入taskQueue
。
取出taskQueue
中最早过期的任务并执行他。
为了能在O(1)复杂度找到两个队列中时间最早的那个任务,Scheduler
使用小顶堆实现了优先级队列
。
你可以在这里看到
优先级队列
的实现
至此,我们了解了Scheduler
的实现。现在可以回答介绍时间切片
时提到的问题:
那么当shouldYield为true,以至于performUnitOfWork被中断后是如何重新启动的呢?
在“取出taskQueue
中最早过期的任务并执行他”这一步中有如下关键步骤:
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// continuationCallback是函数
currentTask.callback = continuationCallback;
markTaskYield(currentTask, currentTime);
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
// 将当前任务清除
pop(taskQueue);
}
}
advanceTimers(currentTime);
当注册的回调函数执行后的返回值continuationCallback
为function
,会将continuationCallback
作为当前任务的回调函数。
如果返回值不是function
,则将当前被执行的任务清除出taskQueue
。
render
阶段被调度的函数为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);
}
可以看到,在满足一定条件时,该函数会将自己作为返回值。
你可以在这里看到这段代码
总结
刚才我们讲到,Scheduler
与React
是两套优先级
机制。那么React
中的优先级
是如何运转的?我们会在下一节介绍。