我正在参加「掘金·启航计划」
大家好,我是三重堂堂主(公众号:咪仔和汤圆,欢迎关注~)
TIP:这一篇可能源码较多,也比较难理解~
上一篇(这里~)说了React scheduler
中实现时间分片靠的是Event Loop
中的Task
,通过messageChannel
、setImmediate
或者setTimeout
来实现。这里再解释下为什么是宏任务,而不是同步任务或者是微任务。首先微任务的执行机制是执行完当前微任务队列再进行下一个Event Loop
,所以不能用微任务;如果用同步任务,当前帧时间不够需要打断的时候,scheduler
需要让当前的调度停止并安排没执行完的任务继续执行,伪代码:
while(!shouldYield){
if(需要中断){
shouldYield = ture
doNext()
}
}
doNext
就是安排下一次Event Loop
的函数,如果是同步任务,当前Event Loop
就会马上开始继续调度,达不到时间分片的作用。
React中的scheduler包
React
中为了抽象出“通过优先级和时间分片调度任务”这种能力,将scheduler
单独发布了一个包,而不受框架影响,是一个纯的JS
包。
本篇主要就是讲scheduler的流程和源码:scheduler源码地址~
流程
第一步,在scheduler
最开始的时候,调用scheduler
包中的任务分发函数scheduleCallback
,以一定的优先级发送一个任务(也叫task
,后续同理),其中参数options
会有delay
属性。
第二步,在发送任务后,根据任务的优先级以及是否delay
,放入不同的队列中,然后才开始调度任务到任务执行这一过程。
第三步,放入队列后,进行调度任务,调度任务需要根据当前的执行情况来筛选出当前需要处理的task
;因为有延时任务(有delay
属性的就是延时任务)和立即执行任务之分,所以这里处理不同的task
有不同的流程。
第四步,到了任务执行阶段,拿到最高优先级的task
进行执行,以及处理中断或者执行完成的情况。
大致流程如下:
因此我们主要分四个部分来讲scheduler
的:
- 第一部分是存储任务以及重排任务(这里重排任务指的是处理任务到正确的队列上)相关;
- 第二部分是处理延时任务阶段;
- 第三部分是处理立即执行任务阶段;
- 第四部分是执行任务到下一次的整体循环。
存储和重排任务
通过schedulerCallback
来派发任务,其中options
如果有delay
,则是一个延时任务,没有则是一个立即执行任务。scheduer
中用TimerQueue
来存储延时任务,用TaskQueue
来存储立即执行任务,并且都是采用“小顶堆”的存储方式,也就是每次push
或者pop
后都会重新排列任务,将优先级最高的任务放在第一个。
TaskQueue
中的任务是以过期时间为优先级,过期时间最近的任务优先级最高,TimerQueue
是以开始时间为优先级,开始时间最近的任务优先级最高,请牢牢这里记住一点。
TaskQueue
中存放的是需要立即执行的task
,所以在TaskQueue
中的任务肯定都会比TimerQueue
中的优先。
派发任务的时候会通过任务优先级以及当前时间、是否delay
来生成一个task
:
// 生成一个调用的时刻
var startTime;
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
} else {
startTime = currentTime;
}
// 根据优先级计算超时时间
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;
// 根据调用 生成一个task 任务对象封装
var newTask = {
id: taskIdCounter++, // 一个全局的计数id
callback, // 回调
priorityLevel, // 优先级
startTime, //开始时刻
expirationTime, // 过期时刻
sortIndex: -1, // 排序优先级
};
生成这个任务后,则需要将其放入正确的queue
。如果是延迟任务,将task
的sortIndex
赋值为startTime
,放入TimerQueue
中;如果是立即执行任务,将task
的sortIndex
赋值为expirationTim
e,放入TaskQueue
中。
至此,存储和重排任务的上半段就完成了,之所以说是上半段是因为在执行任务后阶段,执行完一个task
后,都需要重新重排两个queue
,此为下半段。
// scheduleCallback
// 如果开始时刻在当前时刻之后 则是延迟的任务
if (startTime > currentTime) {
// This is a delayed task.
// timerQueue中的sortIndex为开始时间
// 将任务的sortIndex改为startTime并且放入延迟队列
newTask.sortIndex = startTime;
push(timerQueue, newTask);
// ...
} else {
// 如果任务已经需要开始了 则把sortIndex设置为过期时间并放入需要立即执行的队列
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
// ...
}
处理延时任务
假设我们在存储和重排的时候放入的是一个延时任务,放入TimerQueue
后我们需要进行延时任务的处理,大致有以下几种情况:
TaskQueue
中没有任务,TimerQueue
中放入的当前task
是TimerQueue
优先级最高的task
TaskQueue
中没有任务,TimerQueue
中有其他的task
比当前的task
优先级更高TaskQueue
中有需要执行的任务
首选说简单的第3种情况,也就是我们放入延时任务后,TaskQueue
中是有需要执行的task
的情况。这种情况我们就不需要去关心TimerQueue
中是否有任务,因为在当前时间下都是延迟的,肯定不会比TaskQueue
中的优先级高,因此这种情况处理延时任务的流程就结束了。TaskQueue
中的任务怎么执行,什么时候执行,不是“处理延时任务阶段”的工作。
然后我们说第1种情况,TaskQueue
中没有任务,当前task
在TimerQueue
是最高优先级。这个时候没有任务需要执行,并且需要等待当前task
到执行时间,因此很自然就会去调用一个setTimeout
来触发这个任务,触发时机就是task
到了startTime
,因此setTimeout
的间隔时间是startTime-currentTime
:
// 处理下延迟任务:处理已经取消的或者已经到开始时间的任务
// 将符合资格的延迟任务调度到taskQueue
function advanceTimers(currentTime) {
// Check for tasks that are no longer delayed and add them to the queue.
// 延迟队列中的第一个 timer
let timer = peek(timerQueue);
while (timer !== null) {
if (timer.callback === null) {
// Timer was cancelled.
// 如果当前任务被取消了 直接pop
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
// Timer fired. Transfer to the task queue.
// 如果任务的开始时间已经过了 将当前任务移入到task
pop(timerQueue);
// 还记得taskqueue的sortIndex是expirationTime吗?
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
} else {
// 如果timerQueue中当前第一个task都还没有开始时间,则结束处理timerQueue
// Remaining timers are pending.
return;
}
// 处理完以后读取下一个timer继续执行操作
timer = peek(timerQueue);
}
}
重排完两个queue
以后,则需要到处理立即任务阶段,或者轮询自身,即轮询处理延迟任务阶段,直到有立即执行任务的时候,马上进入处理立即执行任务阶段(优先处理立即执行任务阶段)。requestHostCallback
就代表到了处理立即执行阶段,标志是isHostCallbackScheduled=true
;而requestHostTimeout
就是代表到了处理延时任务阶段,标志是isHostTimeoutScheduled=true
// timeout到点时 触发函数
// 参数是触发那一刻的时间
function handleTimeout(currentTime) {
// 将是否有timeout被调度设置为false
// 因为到这已经被触发了 timeout已经没有被占用
isHostTimeoutScheduled = false;
// 在当前时间点下处理延迟任务队里
advanceTimers(currentTime);
// 如果当前没有回调正在进行
if (!isHostCallbackScheduled) {
// taskQueue中有任务
if (peek(taskQueue) !== null) {
// 执行taskQueue中的任务
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
} else {
// 如果没有需要立即执行的任务
const firstTimer = peek(timerQueue);
// 读取延迟任务 继续下一个轮询
if (firstTimer !== null) {
// 这里没有加isHostTimeoutScheduled = true
// 是因为程序以isHostCallbackScheduled优先
// 当isHostCallbackScheduled为true时可以不管isHostTimeoutScheduled
// 个人认为不是很严谨
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}
处理立即执行任务阶段
到处理立即执行任务阶段就需要结合时间分片了,当scheduler
拿到立即执行任务,则需要通过时间分片机制(messageChannel、setImmediate、setTimeout
)来发起任务需要执行的信号,这一段就是requestHostCallback
干的事情:
// 调度任务的callback
// 只在处理需要执行队列的时候调用 taskQueue阶段
function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
// 在deadline之前调度已经确认要执行任务
schedulePerformWorkUntilDeadline();
}
}
// 根据环境判断时间分片使用的机制
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
// There's a few reasons for why we prefer setImmediate.
//
// Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
// (Even though this is a DOM fork of the Scheduler, you could get here
// with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
// https://github.com/facebook/react/issues/20756
//
// But also, it runs earlier which is the semantic we want.
// If other browsers ever implement it, it's better to use it.
// Although both of these would be inferior to native scheduling.
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// DOM and Worker environments.
// We prefer MessageChannel because of the 4ms setTimeout clamping.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// We should only fallback here in non-browser environments.
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}
performWorkUntilDeadline
起到比较关键的作用,他调用flushWork
执行task
,当flushWork
被阻断后,他负责发起新的一轮schedulePerformWorkUntilDeadline
调用,即新的一轮时间分片:
// 在deadline之前执行任务
const performWorkUntilDeadline = () => {
//scheduledHostCallback 就是 flushWork
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
// Keep track of the start time so we can measure how long the main thread
// has been blocked.
// 设置当前的任务执行时间
startTime = currentTime;
// 是否有剩余时间 发起调用默认有时间
const hasTimeRemaining = true;
// If a scheduler task throws, exit the current browser task so the
// error can be observed.
//
// Intentionally not using a try-catch, since that makes some debugging
// techniques harder. Instead, if `scheduledHostCallback` errors, then
// `hasMoreWork` will remain true, and we'll continue the work loop.
let hasMoreWork = true;
try {
// flushWork(是否有剩余时间, 当前时间)
// scheduledHostCallback 就是flushWork
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
// hasMorkWork就会workLoop的返回:task中是否还有任务
if (hasMoreWork) {
// If there's more work, schedule the next message event at the end
// of the preceding one.
// 如果有更多的任务需要执行,继续发送
schedulePerformWorkUntilDeadline();
} else {
// 如果没有任务需要继续执行 则释放相关资源
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
// Yielding to the browser will give it a chance to paint, so we can
// reset this.
needsPaint = false;
};
执行任务到下一次循环
requestHostCallback
发出执行任务这个信号,像一个指挥官(像你领导),而真正执行任务的函数是flushWork
,是真正干活的(像你一样搬砖)。flushWork
需要做事情:结束isHostCallbackScheduled
,表明处理立即执行任务阶段已经结束,到了执行任务阶段;结束isHostTimeoutScheduled
,因为每一次的workLoop
执行完任务后都需要重新重排两个queue
中的任务,所以处理延时任务阶段也已经结束;然后打一个标志位isPerformingWork=true
,表明已经进入了执行任务阶段,然后通过workLoop
执行任务:
// taskQueue中的任务被调度的时候的处理函数
function flushWork(hasTimeRemaining, initialTime) {
if (enableProfiling) {
markSchedulerUnsuspended(initialTime);
}
// We'll need a host callback the next time work is scheduled.
// 结束从taskQueue到flushWork阶段
isHostCallbackScheduled = false;
// 释放timeout
// 因为flushWork阶段是去执行wookLoop 而workLoop是一次完整的循环会调用advanceTimer
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();
markTaskErrored(currentTask, currentTime);
currentTask.isQueued = false;
}
throw error;
}
} else {
// No catch in prod code path.
return workLoop(hasTimeRemaining, initialTime);
}
} finally {
// 执行完以后将当前task置为空 并恢复优先级
currentTask = null;
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
if (enableProfiling) {
const currentTime = getCurrentTime();
markSchedulerSuspended(currentTime);
}
}
}
到workLoop
,就很简单了,轮询当前的TaskQueue
,执行当前任务,执行完以后,又重新重排两个queue
的任务,然后继续执行。
有两种情况会退出循环:一种是被阻断,这个时候会返回是否还有任务需要执行(hasMoreWork=true
),performWorkUntilDeadline
拿到返回结果生成下一个时间分片调用;第二种是如果在当前帧里面workLoop
把task
执行完了,就去生成一个处理延迟任务的阶段,来衔接到我们在最开始说的重排任务的下半段。
// 一轮循环
// 总是拿到需要最高优先级执行的任务执行,不断轮询,直到被打断或者taskQueue中没有了任务
// 最高优先级指的是先整理timerQueue timerQueue是startTime最开始的在前面
// 可能存在一个任务startTime很早 expirationTime也很早,这个任务在timerQueue中是最高优先级 移入到taskQueue中也是最高优先级
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (
// 当前执行任务不为空 (后面条件不用管 是调试用的)
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
// 如果当前任务还未过期 但是schduler需要阻断(阻断条件:已经没有多余时间 或者shouldYield 跳出本次循环
// 如果当前时间已经超过了过期时间 就算没有空闲时间也会执行这次任务
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
// 如果当前时间已经超过了过期时间 或者 不需阻断
// 继续执行
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
// 是否已经超过过期时间
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
if (enableProfiling) {
markTaskRun(currentTask, currentTime);
}
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
//
if (typeof continuationCallback === 'function') {
// 如果执行以后还是一个函数 就不弹出当前task 继续下一个轮询执行这个返回的函数
currentTask.callback = continuationCallback;
if (enableProfiling) {
markTaskYield(currentTask, currentTime);
}
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
// 执行完当前task,整理下timerQueue
advanceTimers(currentTime);
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
// Return whether there's additional work
if (currentTask !== null) { // 被阻断了后 如果还有任务没执行完 return true
return true;
} else {
// 被阻断后 或者是 taskQueue轮询完后 ,去整理timerQueue 返回false
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}
总结
至此,从调用开始到执行完成以及重新进入轮询的整个过程都理清了。
含有注释的源码,里面包含小顶堆、优先级等相关的代码: github-awefeng-scheduler
最后,记得关注下公众号~
提供一张完整流程图: