> 文章列表 > React 的源码与原理解读(八):Scheduler

React 的源码与原理解读(八):Scheduler

React 的源码与原理解读(八):Scheduler

写在专栏开头(叠甲)

  1. 作者并不是前端技术专家,也只是一名喜欢学习新东西的前端技术小白,想要学习源码只是为了应付急转直下的前端行情和找工作的需要,这篇专栏是作者学习的过程中自己的思考和体会,也有很多参考其他教程的部分,如果存在错误或者问题,欢迎向作者指出,作者保证内容 100% 正确,请不要将本专栏作为参考答案。

  2. 本专栏的阅读需要你具有一定的 React 基础、 JavaScript 基础和前端工程化的基础,作者并不会讲解很多基础的知识点,例如:babel 是什么,jsx 的语法是什么,需要时请自行查阅相关资料。

  3. 本专栏很多部分参考了大量其他教程,若有雷同,那是作者抄袭他们的,所以本教程完全开源,你可以当成作者对各类教程进行了整合、总结并加入了自己的理解。

本一节的内容

在之前的章节中,我们主要分析了 React 怎么样从我们编写的 jsx 代码变成我们看得到的 DOM 元素,我们在讲解过程中为了能连贯的讲解,留下了很多的坑没填,一部分就是关于进程调度的,这一节我们就要填上这些坑。这节我们先讲React 中的 **Scheduler ** 系统是怎么样调度我们的渲染任务的,它怎么样计算任务的运行时间、调度任务的运行顺序以及怎么样恢复被中断的任务

从 ensureRootIsScheduled 说起

现在让我们回到第一次出现进程调度相关的代码省略的位置,也就是 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

之后我们首先来看可执行任务的调度,也就是 requestHostCallback 这个函数:

  • 它调用了 schedulePerformWorkUntilDeadline 这个函数

  • 而这个函数使用了 MessageChannel ,这个一个 JS 的 API 它有 port1 和 port2 两个属性,都是 MessagePort 对象,并且具有 onmessageonmessageerror 两个回调方法,使用 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 ,这个函数我们稍后会说
  • 之后我们做最后的处理,如果还有任务,我们返回 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是写死的 5msif (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);}
}

延时任务与requestHostTimeout

之后我们来看延时任务,还是回到我们的延时任务,大部分的延时任务都在之前可执行的任务的运行过程中,从我们的延时任务队列中因为执行时间到转化成我们的可执行任务了,但是有一个特殊情况:

如果没有可执行的任务,并且我们传入的任务的第一个能执行的任务,我们需要对他进行调度,因为如果后续没有调度操作的话,我们不会去检测延时任务队列中的任务,它也不会主动变成可执行任务

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;// 转移任务到 taskQueueadvanceTimers(currentTime);if (!isHostCallbackScheduled) {// 判断转移后任务是不是可以运行if (peek(taskQueue) !== null) {isHostCallbackScheduled = true;// 开始调度,里面会清理计时器requestHostCallback(flushWork);} else {// 不能运行,重新开始延时任务调度const firstTimer = peek(timerQueue);if (firstTimer !== null) {requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);}}}
}