作者并不是前端技术专家,也只是一名喜欢学习新东西的前端技术小白,想要学习源码只是为了应付急转直下的前端行情和找工作的需要,这篇专栏是作者学习的过程中自己的思考和体会,也有很多参考其他教程的部分,如果存在错误或者问题,欢迎向作者指出,作者保证内容 100% 正确,请不要将本专栏作为参考答案。
本专栏的阅读需要你具有一定的 React 基础、 JavaScript 基础和前端工程化的基础,作者并不会讲解很多基础的知识点,例如:babel 是什么,jsx 的语法是什么,需要时请自行查阅相关资料。
本专栏很多部分参考了大量其他教程,若有雷同,那是作者抄袭他们的,所以本教程完全开源,你可以当成作者对各类教程进行了整合、总结并加入了自己的理解。
在之前的章节中,我们主要分析了 React 怎么样从我们编写的 jsx 代码变成我们看得到的 DOM 元素,我们在讲解过程中为了能连贯的讲解,留下了很多的坑没填,一部分就是关于进程调度的,这一节我们就要填上这些坑。这节我们先讲React 中的 **Scheduler ** 系统是怎么样调度我们的渲染任务的,它怎么样计算任务的运行时间、调度任务的运行顺序以及怎么样恢复被中断的任务
现在让我们回到第一次出现进程调度相关的代码省略的位置,也就是 ensureRootIsScheduled
函数,它在我们教程的第四节:
https://blog.csdn.net/weixin_46463785/article/details/129740496
我们之前说到,它负责注册调度任务, 然后由 Scheduler 调度, 进行 Fiber 构造:
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
// 省略优先级部分....
let schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediateSchedulerPriority;
break;
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}
这里我们使用了一个 lanesToEventPriority
函数,它的作用是把我们的 lanes 优先级转化成我们调度的优先级,关于 lanes 的优先级我们之后会讲到,这里你只要知道这是 React 的一个优先级模型就行了。下面我们着重来看转化后的结构,它定义在 react/packages/scheduler/src/SchedulerPriorities.js
里面,分为 6 个等级,数字越小,优先级越高,0 表示没有优先级
// react/packages/scheduler/src/SchedulerPriorities.js
export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;
之后我们带着刚刚生成的优先级进入了 scheduleCallback
函数,当时我们直接讲解了 performConcurrentWorkOnRoot
深入的逻辑,它的作用是生成我们的 Fiber ,那么现在我们要来讲 scheduleCallback
内部的逻辑,我们先来看看这个函数,它在代码的这个位置:
/packages/scheduler/src/forks/Scheduler.js
,我们可以看看它的逻辑:
delay
,说明它是延时任务,那么开始时间就是当前的时间加上延期时间;否则开始时间就是当前时间taskQueue
(可执行任务) 和 timerQueue
(延时任务)两个队列中;在可执行队列中,过期时间越早的任务优先级越高,因为它需要尽快被执行,而延时任务中,开始时间越早的优先级越高,因为它会尽快开始var taskQueue = [];
var timerQueue = [];
function unstable_scheduleCallback(priorityLevel, callback, options) {
var currentTime = getCurrentTime();
//任务开始调度的时间,options 是一个可选项,其中有一个 delay 属性,表示这是一个延时任务,要多少毫秒后再安排执行。
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;
}
// timeout 跟优先级相互对应,表示这个任务能被拖延执行多久。
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;
}
// expirationTime 表示这个任务的过期时间,这个值越小,说明越快过期,任务越紧急,越要优先执行。
var expirationTime = startTime + timeout;
// 创建一个任务,其 sortIndex 越小,在排序中就会越靠前
var newTask = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
if (enableProfiling) {
newTask.isQueued = false;
}
//如果有设置 delay 时间,那么它就会被放入 timerQueue 中,表示延期执行的任务;否则放入 taskQueue 表示现在就要执行的任务。
if (startTime > currentTime) {
// 更新 sortIndex 为开始时间,这样越晚的任务开始的任务优先级越低
newTask.sortIndex = startTime;
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
if (isHostTimeoutScheduled) {
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// 调度
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
// 更新 sortIndex 为过期,这样越紧急的任务优先级越高
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
if (enableProfiling) {
markTaskStart(newTask, currentTime);
newTask.isQueued = true;
}
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
// 调度
requestHostCallback(flushWork);
}
}
return newTask;
}
之后我们首先来看可执行任务的调度,也就是 requestHostCallback
这个函数:
它调用了 schedulePerformWorkUntilDeadline
这个函数
而这个函数使用了 MessageChannel
,这个一个 JS 的 API 它有 port1 和 port2 两个属性,都是 MessagePort
对象,并且具有 onmessage
和 onmessageerror
两个回调方法,使用 MessagePort.postMessage 方法发送消息的时候,就会触发另一个端口的 onmessage ,当我们的调用 schedulePerformWorkUntilDeadline
的时候,我们会触发performWorkUntilDeadline
这个函数
performWorkUntilDeadline
这个函数,我们调用 scheduledHostCallback
来执行我们的任务,我们可以往回寻找我们的代码, scheduledHostCallback
其实就是我们的 flushWork
,这个函数里批量执行了一部分的任务,然后告诉我们是不是还有任务在队列中等待执行,然后还有任务,调用 schedulePerformWorkUntilDeadline
函数
这里要提到使用 schedulePerformWorkUntilDeadline
的另一个原因,它会创建一个宏任务(不清楚原理的的可以先去看看 宏任务和微任务的区别),因为宏任务是在下次事件循环中执行,因此我们调用 schedulePerformWorkUntilDeadline
会暂停 js 的执行,将主线程还给浏览器,让浏览器有机会执行更高级别的任务和页面渲染,完成后继续执行我们的performWorkUntilDeadline
逻辑
let isMessageLoopRunning = false;
function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
// 初始化了一个 MessageChannel
const channel = new MessageChannel();
const port = channel.port2;
// 当我们调用 schedulePerformWorkUntilDeadline 的时候会触发 performWorkUntilDeadline
channel.port1.onmessage = performWorkUntilDeadline;
let schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
let startTime = -1;
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
startTime = currentTime;
const hasTimeRemaining = true;
let hasMoreWork = true;
try {
// 处理任务,返回是否还有任务
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
// 还有任务,让出线程
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
//没有任务了
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
needsPaint = false;
};
这里补充一个个知识点,作者查找资料的时候看到了就顺便记在这里.我们要创建一个宏任务,为什么不使用 setTimeout(fn,0)
?
因为在 HTML Standard (whatwg.org) 里明确规定过的,如果 setTimeout 设置的 timeout 小于0,则设置为0,如果嵌套的层级超过了 5 层,并且 timeout 小于 4ms,则设置 timeout 为 4ms,所以如果我们嵌套了多层的 setTimeout,就会导致 4ms 的时间浪费,这是我们不能接受的。
好了我们言归正传,我们继续来看 flushWork
函数,省略一些逻辑,他大体上的进入了 workLoop
这个函数,我们详细来看这个函数:
timerQueue
队列的任务满足了执行时间,如果有的话,我们需要把他们放入到我们的 taskQueue
队列中等待调度shouldYieldToHost
返回 true ,这个函数我们稍后会说requestHostTimeout
函数重新开始我们的调用过程,这个函数就是我们延时任务的调度函数,我们马上就会讲到;如果找不到下一个任务,说明没有剩下的任务了,我们返回 false。function flushWork(hasTimeRemaining, initialTime) {
//....
isHostCallbackScheduled = false;
// 是不是需要清理延时任务的计时器,这个后面会讲
if (isHostTimeoutScheduled) {
isHostTimeoutScheduled = false;
cancelHostTimeout();
}
isPerformingWork = true;
const previousPriorityLevel = currentPriorityLevel;
try {
if (enableProfiling) {
try {
return workLoop(hasTimeRemaining, initialTime);
} catch (error) {
//....
throw error;
}
} else {
return workLoop(hasTimeRemaining, initialTime);
}
} finally {
//....
}
}
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
// 判断 timerQueue 的 startTime 是不是到了,如果到了将它插入我们的 taskQueue 中
advanceTimers(currentTime);
// 弹出第一个任务
currentTask = peek(taskQueue);
//不断执行任务列表里的任务
while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
// 判断是不是要退出本次任务执行
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
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') {
// 检查callback的执行结果返回的是不是函数,如果返回的是函数,则将这个函数作为当前任务新的回调。
currentTask.callback = continuationCallback;
if (enableProfiling) {
markTaskYield(currentTask, currentTime);
}
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
// 任务做完了,抛出这个任务
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
advanceTimers(currentTime);
} else {
pop(taskQueue);
}
// 下一个任务
currentTask = peek(taskQueue);
}
if (currentTask !== null) {
return true;
} else {
// 找到最近的延时任务
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}
我们现在来看看 shouldYieldToHost
这个函数的逻辑,它判断了我们要不要中断我们一批任务的执行,把进程还给我们的浏览器,它的逻辑是:执行时间如果小于帧间隔时间(frameInterval,通常为 5ms),不需要让出进程,否则让出。同时如果执行期间有用户输入的行为,我们需要进行特殊处理,因为 React 实现的 Fiber 结构,其目的就在于能够及时让出线程,让浏览器可以处理用户输入等,所以遇到这种情况我们经过一些判定后尽量让出进程给我们的浏览器,让它可以响应我们的用户输入。
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
// 判断这批任务执行了多久,frameInterval是写死的 5ms
if (timeElapsed < frameInterval) {
return false;
}
if (enableIsInputPending) {
// 这里的逻辑是判定是否有用户输入的,保证及时响应用户输入。
if (needsPaint) {
return true;
}
if (timeElapsed < continuousInputInterval) {
if (isInputPending !== null) {
return isInputPending();
}
} else if (timeElapsed < maxInterval) {
if (isInputPending !== null) {
return isInputPending(continuousOptions);
}
} else {
return true;
}
}
return true;
}
值得一提的是,我们可以会看一下第四篇教程,我们讲到同步任务和并发任务有一个明显的差别,就是在 workLoop
中,并发任务多了一个 !shouldYield()
的判定,而这个判定的逻辑和我们的 shouldYieldToHost
是一样的,现在我们可以理解他们的差异了:
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
之后我们来看延时任务,还是回到我们的延时任务,大部分的延时任务都在之前可执行的任务的运行过程中,从我们的延时任务队列中因为执行时间到转化成我们的可执行任务了,但是有一个特殊情况:
如果没有可执行的任务,并且我们传入的任务的第一个能执行的任务,我们需要对他进行调度,因为如果后续没有调度操作的话,我们不会去检测延时任务队列中的任务,它也不会主动变成可执行任务
if (startTime > currentTime) {
// 更新 sortIndex 为开始时间,这样越晚的任务开始的任务优先级越低
newTask.sortIndex = startTime;
push(timerQueue, newTask);
// 如果没有可执行的任务,并且我们传入的任务的第一个能执行的任务
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
if (isHostTimeoutScheduled) {
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// 调度
requestHostTimeout(handleTimeout, startTime - currentTime);
}
}
这里我们看看 requestHostTimeout
这个函数的操作:它其实就是调用了 setTimeout
创建了宏任务,在指定时间后执行我们的延时任务队列的第一个任务,这个时间就是它开始的时间和我们当前时间的差值,之前我们的 workLoop
函数中也用到这个方法
let taskTimeoutID = -1;
function requestHostTimeout(callback, ms) {
taskTimeoutID = setTimeout(() => {
callback(getCurrentTime());
}, ms);
}
那么 cancelHostTimeout
的操作就很简单了,如果此时已经有计时器在操作了,但是新加入的任务更快执行,那么我们需要清除老计时器,重新开一个新的计数器
function cancelHostTimeout() {
clearTimeout(taskTimeoutID);
taskTimeoutID = -1;
}
最后我们来看看这个 handleTimeout
也就是我们传入的 callback
函数的执行逻辑,它要做的其实很简单,就是把我们到期的延时任务转移到 taskQueue
中。但是这里可能出现任务对象 task 的 callback 函数置为 null 的情况,这类任务在转移的过程中会被清除,那么这时候我们需要开始一个新的计时器:
function handleTimeout(currentTime) {
isHostTimeoutScheduled = false;
// 转移任务到 taskQueue
advanceTimers(currentTime);
if (!isHostCallbackScheduled) {
// 判断转移后任务是不是可以运行
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true;
// 开始调度,里面会清理计时器
requestHostCallback(flushWork);
} else {
// 不能运行,重新开始延时任务调度
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}