剖析React系列十二-调度器的实现
本系列是讲述从0开始实现一个react18的基本版本。通过实现一个基本版本,让大家深入了解React
内部机制。
由于React
源码通过Mono-repo 管理仓库,我们也是用pnpm
提供的workspaces
来管理我们的代码仓库,打包我们使用rollup
进行打包。
系列文章:
- React实现系列一 - jsx
- 剖析React系列二-reconciler
- 剖析React系列三-打标记
- 剖析React系列四-commit
- 剖析React系列五-update流程
- 剖析React系列六-dispatch update流程
- 剖析React系列七-事件系统
- 剖析React系列八-同级节点diff
- 剖析React系列九-Fragment的部分实现逻辑
- 剖析React系列十- 调度<合并更新、优先级>
- 剖析React系列十一- useEffect的实现原理
我们从之前的章节中晓得,React
每次更新都是从根节点开始,相比于Vue
模板组件维度的更新,是有一点庞大的,当遇到一个复杂的应用如果没有合适的手段,就是长时间占据js主线程,而浏览器在执行js的时候,会阻塞浏览器渲染和绘制,所以会出现卡顿。
react通过调度器和浏览器之间进行”君子交易”,大体是,浏览器一帧执行大概16.6ms
, 调度器使用其中的5ms
。
本节主要是通过以下几个方面讲解:
scheduler
调度器的实现- react异步调度原理
- 时间分片
- react的调度执行流程
scheduler
调度器的实现
react
将调度器scheduler
单独抽出一个包,和主流程分开,我们来看看scheduler
中的逻辑。
一些核心方法
*scheduleCallback
: 以某一优先级调度callback
shouldYield
: 提示Time Slice 时间是否用尽cancelCallback
: 移除task
优先级和任务
在scheduler
预置了5个优先级,用于代表事情的紧急度。每一种的优先级对应任务过期时间也不一样。
ImmediatePriority = 1
: 最高优先级,同步执行UserBlockingPriority = 2
NormalPriority = 3
LowPriority
IdlePriority
不同的优先级,对应不同的超时时间timeout
,这个在下面会用到。
IMMEDIATE_PRIORITY_TIMEOUT = -1
USER_BLOCKING_PRIORITY_TIMEOUT = 250
NORMAL_PRIORITY_TIMEOUT = 5000
LOW_PRIORITY_TIMEOUT = 10000
scheduler
对外暴露的函数scheduleCallback
,它接收一个优先级和一个回调函数Fn
, 根据优先级调度Fn
的执行。
// 以NormalPriority优先级调度回调函数fn
scheduleCallback(NormalPriority, Fn)
我们来看看源码中scheduleCallback
具体是逻辑,主要是用来创建task
。
scheduleCallback
逻辑
scheduleCallback
主要是接受优先级和回调函数,然后生成一个task
用于内部的调用。它包含三个参数
priorityLevel
是调度任务的优先级;callback
是需要执行的更新任务;optoins
里面可以通过指定 delay 延迟执行任务 或 timeout 定义任务的超时时间。
任务的开始时间和过期时间
- 任务的开始时间
startTime
: 决定了之后放入到taskQueue
还是timerQueue
- 任务的过期时间
expirationTime
: 决定了任务在执行的队列中的优先级,值越小优先级越高。高优先级的task会优先执行(task.callback
)
// main.js 执行的入口文件,传递不同的优先级
export function unstable_scheduleCallback(priorityLevel, callback, options) {
// 获取当前的时间(ms)
var currentTime = unstable_now(); // performance.now();
var startTime;
// 这里主要是查看是否设置了delay, 默认的开始时间是当前时间
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
的数据结构
一个任务包含以下字段:
function unstable_scheduleCallback(priorityLevel, callback, options) {
//...
var newTask = {
id: taskIdCounter++, // 任务id
callback, // 执行回调
priorityLevel, // 任务优先级
startTime, // 任务开始时间
expirationTime, // 任务结束时间
sortIndex: -1, // 用于小顶堆的重建排序
};
// ...
}
加入任务队列taskQueue、timeQueue
从scheduleCallback
中我们看到了如下的2种执行情况:
/* 把任务放在timerQueue中 */
push(timerQueue, newTask);
/* 将任务放入taskQueue中 */
push(taskQueue, newTask);
那么它们之间有什么区别呢? 在scheduleCallback
中,根据任务如果标识了delay
,会将任务放入timerQueue
中,否则就放入taskQueue
中。
每一个任务,可以理解为触发了一次React更新
大体的转换如上图所示:
timeQueue
中的task以currentTime + delay
排序taskQueue
中的task以expirationTime
为排序依据- 当
timerQueue
中第一个task延迟的时间到期后,执行advaceTimers
将“到期的task"从timerQueue中移动到taskQueue
中。
export function unstable_scheduleCallback(priorityLevel, callback, options) {
// ....
if (startTime > currentTime) {
/* 通过开始时间排序 */
newTask.sortIndex = startTime;
/* 把任务放在timerQueue中 */
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// All tasks are delayed, and this is the task with the earliest delay.
if (isHostTimeoutScheduled) {
// Cancel an existing timeout.
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
} // Schedule a timeout.
/* 执行setTimeout , */
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
/*没有处于调度中的任务, 然后向浏览器请求一帧,浏览器空闲执行 flushWork */
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
}
}
return newTask;
}
小顶堆
在scheduler
中是通过小顶堆的形式来存放task
。 它的特点:任意节点都小于等于其左右节点值。并且是一个完全二叉树,这样就可以很方便的通过数组保存。
主要包含三个方法:
push
: 向堆中推入数据pop
:从堆顶取出数据peek
:获取”排序依据最小的值“对应的节点
宏任务调度requestHostCallback
当scheduleCallback
处理完成taskQueue
之后,通过 requestHostCallback(flushWork)
时间切片,异步宏任务的调度flushWork
/* 向浏览器请求执行更新任务 */
function requestHostCallback(callback) {
scheduledHostCallback = callback; // flushWork
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
其实requestHostCallback
中只是把flushWork
赋值给scheduledHostCallback
, 等待之后调用。
然后执行schedulePerformWorkUntilDeadline
, 这个方法就是一个宏任务,在不同的环境使用不同的方法。
通过执行宏任务调用performWorkUntilDeadline
,它里面就会去执行真正的flushWork
var performWorkUntilDeadline = function () {
if (scheduledHostCallback !== null) {
// flushWork
var currentTime = unstable_now(); // Keep track of the start time so we can measure how long the main thread
// has been blocked.
startTime = currentTime;
var hasTimeRemaining = true;
var hasMoreWork = true;
try {
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime); // flushWork
} finally {
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;
}
}
}
// ...
};
这里需要注意2点:
- 之后执行
flushWork
的时候,传入的(true, 当前时间)
- 如果
flushWork
的返回值为true
的话,代表还有任务未执行,就需要再次执行schedulePerformWorkUntilDeadline
, 调用宏任务重新开始调度。
flushWork 执行 taskQueue
flushWork
的核心就是通过workLoop
同步循环的执行taskQueue
中的task.callback.
function flushWork(hasTimeRemaining, initialTime) {
isHostCallbackScheduled = false;
/* 因为有taskQueue任务来了,如果有延时任务,那么先暂定延时任务*/
if (isHostTimeoutScheduled) {
isHostTimeoutScheduled = false;
cancelHostTimeout();
}
isPerformingWork = true;
// 将正在执行的优先级修改成旧的优先级
var previousPriorityLevel = currentPriorityLevel;
try {
/* 执行 workLoop 里面会真正调度我们的事件 */
return workLoop(hasTimeRemaining, initialTime); // true currentTime
} finally {
currentTask = null;
// 执行完成后回复优先级
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
}
}
workLoop同步调度任务
workLoop
接受2个参数,第一个参数一直是true
,我们可以删掉它的部分。第二个参数是当前的时间。
function workLoop(hasTimeRemaining, initialTime) {
var currentTime = initialTime;
// 查看延时队列中是否有过期的任务,将其加入到taskQueue中
advanceTimers(currentTime);
/* 获取任务列表中的第一个 */
currentTask = peek(taskQueue);
/* workLoop 会依次更新过期任务队列中的任务 */
while (currentTask !== null) {
// 如果当前任务未到期,但是时间切片用完了,中断循环,将执行权交给浏览器,等待下一次执行。
if (
currentTask.expirationTime > currentTime && shouldYieldToHost()
) {
break;
}
/* 真正的更新函数 callback */
var callback = currentTask.callback;
if (typeof callback === "function") {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
// 是否超时
var didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
/* 执行callback(传入是否超时) */
var continuationCallback = callback(didUserCallbackTimeout);
currentTime = unstable_now();
// 如果当让出主线程的时候任务还没有执行完成,就返回函数,等待下次调用
if (typeof continuationCallback === "function") {
currentTask.callback = continuationCallback;
} else {
// 如果任务完成,就返回null
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
// 再次梳理timerQueue
advanceTimers(currentTime);
} else {
// 如果当前任务不存在callback执行,就删除任务
pop(taskQueue);
}
// 取下一个最高优先级任务
currentTask = peek(taskQueue);
} // Return whether there's additional work
// 返回taskQueue队列的执行状态:是否有剩余任务工作
if (currentTask !== null) {
return true; // 对应hasMoreWork
} else {
// 如果taskQueue中执行完了,就开始通过setTimeout延时执行timerQueue的第一个。
var firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}
这里的流程主要如下:
workloop
通过while
不断的同步从taskQueue中取出第一个要执行的任务。- 当时间切片用完
shouldYieldToHost() === true
的时候,中断循环,让出执行权给浏览器。分2种情况:- 当前任务还没有执行完: 需要保存当前的任务进度。等待下一次宏任务调度
continuationCallback=callback(didUserCallbackTimeout); currentTask.callback = continuationCallback`
- 当前任务执行完,但还有其他任务:
workLoop
返回true执行完毕。flushWork
也是返回true
。所以会再次执行schedulePerformWorkUntilDeadline();
重新执行performWorkUntilDeadline
调度。try { hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime); // flushWork返回值 } finally { if (hasMoreWork) { schedulePerformWorkUntilDeadline(); } }
- 当前任务还没有执行完: 需要保存当前的任务进度。等待下一次宏任务调度
- 执行 task.callback,梳理 timerQueue;
- 如果当前任务已经过期,会立刻执行完成,并不会让出执行权。直到过期任务执行完成。
- 如果
taskQueue
中执行完成,就延时开始执行timerQueue
中的延时任务。
workLoop
每次执行完一个taskQueue task
后,会调用 advanceTimers
方法来看 timerQueue
中是否有任务需要移交到 taskQueue 队列中执行,具体实现如下
function advanceTimers(currentTime) {
// 获取第一个timer task
var timer = peek(timerQueue);
while (timer !== null) {
if (timer.callback === null) {
// Timer was cancelled.
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
/* 如果任务已经过期,那么将 timerQueue 中的过期任务,放入taskQueue */
pop(timerQueue);
// 在加入 tastQueue 前,将任务的过期时间赋值给 sortIndex,用于任务时间的排序
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
} else {
// Remaining timers are pending.
return;
}
timer = peek(timerQueue);
}
}
requestHostTimeout处理timerQueue
从workLoop
中,我们得知,如果当taskQueue
中不存在任务后。会执行如下代码:
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
requestHostTimeout
相当于是setTimeout
。所以就等于延时去执行handleTimeout
。
function handleTimeout(currentTime) {
isHostTimeoutScheduled = false;
/* 将 timeQueue 中过期的任务,放在 taskQueue 中 。 */
advanceTimers(currentTime);
/* 如果没有处于调度中 */
if (!isHostCallbackScheduled) {
// 是否有调度任务在执行中
/* advanceTimers 梳理后如果有新任务 */
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true;
/* 开启调度任务 */
requestHostCallback(flushWork);
} else {
var firstTimer = peek(timerQueue);
// 如果 taskQueue 仍然为空,就开始递归的调用该方法
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}
所以handleTimeout
会不断的梳理延时任务,当任务被移交到taskQueue
后,调用requestHostCallback
开始新的调度任务。
例子
下面我们通过一个实际例子来讲解scheduler
的运用。
我们有4个优先级的按钮,当低优先级的任务正在执行的时候,点击高级优先级的按钮的话,低优先级的任务会中断。然后执行高优先级任务。
const workList= []; // 代表我们执行任务队列
// 代表这一次的更新有100个子组件的fiberNode调和
workList.unshift({
count: 100,
priority: priority,
});
初始化创建4个优先级按钮
[LowPriority, NormalPriority, UserBlockingPriority, ImmediatePriority].forEach(
(priority) => {
const btn = document.createElement("button");
root?.appendChild(btn);
btn.innerText = [
"",
`ImmediatePriority-${priority}`,
`UserBlockingPriority-${priority}`,
`NormalPriority-${priority}`,
`LowPriority-${priority}`,
][priority];
btn.onclick = () => {
console.log("-hcc---priority--priority", priority);
workList.unshift({
count: 100,
priority: priority,
});
schedule();
};
}
);
每一次点击按钮的时候,向任务队列中塞入任务,然后调用schedule
函数开始调度。我们来看看schedule
中的逻辑。
schedule
执行
它主要是分为几点:
- 类比
react
更新,我们需要获取到最高优先级的任务执行。 - 如果执行了调度,但是当前没有任务执行了,就需要正在执行的调度。
- 如果前后2次优先级相同的话,直接退出。
- 如果前后2次优先级不同, 高优先级的打断了低优先级的任务,就取消之前正在调度的任务,重新开始新一轮的调度。
function schedule() {
const cbNode = getFirstCallbackNode();
// 获取优先级最高的work
const curWork = workList.sort((w1, w2) => w1.priority - w2.priority)[0];
// console.log("-hcc--curWork--", curWork, prevPriority);
// 策略逻辑
if (!curWork) {
curCallback = null;
cbNode && cancelCallback(cbNode);
return;
}
const { priority: currentPriority } = curWork;
if (currentPriority === prevPriority) {
return;
}
// 更高优先级的work <取消之前的调度>
cbNode && cancelCallback(cbNode);
curCallback = scheduleCallback(currentPriority, perform.bind(null, curWork));
}
这里用到scheduler
提供的几个方法。
getFirstCallbackNode方法
: 源码中就是查看taskQueue
的头部第一个任务。cancelCallback方法
: 将传入的task
的callback
赋值为null
scheduleCallback方法
: 传入优先级和任务,放入taskQueue
中,开启调度。
这里我们传入的callback
为perform.bind(null, curWork)
,我们来看看perform
执行的内容。
perform
的内容
从上面的scheduleCallback
讲解中,我们晓得会通过一个宏任务去执行taskQueue
。而perform
保存在taskQueue
中。
function perform(work , didTimeout ) {
/**
* 中断情况
* 1. work.priority <高优先级的优先执行>
* 2. 饥饿问题<一个work一直得不到执行,优先级就会越来越高> didTimeout
* 3. 时间切片 <当前执行的时间是否已经完成shouldYield>
*/
const needSync = work.priority === ImmediatePriority || didTimeout;
let whileState = performance.now()
while ((needSync || !shouldYield()) && work.count) {
console.log('--shouldYield---', shouldYield());
work.count--;
insertSpan(work.priority + "");
}
console.log('----while---end----', performance.now() - whileState);
// 中断执行 || 执行完
prevPriority = work.priority;
if (!work.count) {
const workIndex = workList.indexOf(work);
workList.splice(workIndex, 1);
prevPriority = IdlePriority;
}
const prevCallback = curCallback;
schedule();
const newCallback = curCallback;
if (newCallback && prevCallback === newCallback) {
return perform.bind(null, work);
}
}
perform
内部主要是分为几个步骤:
- 判断任务的优先级或者任务是否过期,是否是同步执行。如果是同步执行,就通过
while
循环直接执行完成。 - 如果不是同步优先级,如果当前的时间已经执行完,也中断执行。
- 如果任务没有执行完被中断了,那么再次执行调度
schedule
函数。这里有一个优化,如果是同一个优先级中断,前后curCallback
相同,只需要返回一个函数,scheduler
就会记录当前任务为这个函数继续调度。
/* 执行更新 */
var continuationCallback = callback(didUserCallbackTimeout); // 传进来的perform的返回值
currentTime = unstable_now();
if (typeof continuationCallback === "function") {
currentTask.callback = continuationCallback;
}
下一节我们结合react
源码,通过几个例子来分析调度器是如何和react
结合的。
参考文献:
转载自:https://juejin.cn/post/7209547043936010299