react中的调度和时间切片原理
一、前言
我们知道老的react的架构主要包含了Reconciler
和Commit
.并且整个Reconclier中整个过程是同步不可中断的。在这样的前提下如果遇到大量dom更新的情况,便会出现页面的卡顿的情况。为了解决这个问题,react引入了Fiber架构,使整个Reconclier
过程实现了可中断。
在react18
的Concurrent
特性下,结合Fiber架构,react可以将大任务分解为多个小任务,分配到多个浏览器每帧下的空余时间执行,这就是我们常说的时间切片。这样就不会阻塞浏览器的绘制任务,造成页面卡顿。在这个过程中,react
是如何实现任务的调度,并且如何实现时间切片的呢。带着这两个问题,让我们一起探索。
二、Scheduler
没错实现任务调度和时间切片的主要模块就是Scheduler
。
一、时间切片
浏览器如何控制react更新的呢。我们知道浏览器在绘制一帧的时候会处理很多事物,包括事件处理,js执行,requestAnimationFrame
回调,布局,绘制页面等等。同时谷歌浏览器提供了requestIdleCallback
API。这个api可以在“浏览器重排/重绘”后如果当前帧还有空余时间时被调用的。听起来这是个完美实现时间切片的api,但由于兼容性的问题,react并没有使用requestIdleCallback
,而是模拟实现了requestIdleCallback
,这就是Scheduler
。
二、模拟requestIdleCallback
为了能模拟出requestIdleCallback
,必须要做到以下两点。
- 可以主动让出线程,让浏览器执行其他任务。
- 在每帧下只执行一次,然后在下一帧中继续请求时间片。
能满足以上两种情况的便只有宏任务,而在宏任务中首选便是setTimeout
。但是由于setTimeout
会有4ms的时差,react放弃使用了setTimeout
,改用了MessageChannel
。(在不兼容Messagechannel
的情况下依然使用setTimeout
实现)
三、Messagechannel
MessageChannel
有两个属性,
- MessageChannel.port1(只读返回channel的port1)
- MessageChannel.port2(只读返回channel的port2)
让我们来看一个使用例子
let scheduledHostCallback = null
/* 建立一个消息通道 */
var channel = new MessageChannel();
/* 建立一个port发送消息 */
var port = channel.port2;
channel.port1.onmessage = function(){
/* 执行任务 */
scheduledHostCallback()
/* 执行完毕,清空任务 */
scheduledHostCallback = null
};
/* 向浏览器请求执行更新任务 */
requestHostCallback = function (callback) {
scheduledHostCallback = callback;
port.postMessage(null);
};
const task = function() {
console.log('这是浏览器任务')
}
requestHostCallback(task)
例子中首先实例化了 MessageChannel
得到channel
。prot1
通过onmessage
来监听port2
传过来的消息。并执行scheduledHostCallback
函数。
三、调度原理
上一节我们说到,Scheduler
本质上是使用MessageChannel
实现的,那它又是如何实现任务调度,区分任务优先级的呢。事实上Scheduler
,划分了多个优先级参数作为任务等级:
Immediate
-1 需要立刻执行。UserBlocking
250ms 超时时间250ms,一般指的是用户交互。Normal
5000ms 超时时间5s,不需要直观立即变化的任务,比如网络请求。Low
10000ms 超时时间10s,肯定要执行的任务,但是可以放在最后处理。Idle
一些没有必要的任务,可能不会执行。
一般任务我们使用Normal,点击事件等任务会使用UserBlocking,以此区分优先级。为了探究Scheduler,让我们从根出发,在react-dom中是如何使用Scheduler的。
我们知道对于同步任务,react
会进入performSyncWorkOnRoot
函数,而对于异步更新的任务react
会走pperformConcurrentWorkOnRoot
逻辑,对于这两个函数都会走进不同的“workLoop”中。
performSyncWorkOnRoot
-> renderRootSync
-> workLoopSync
performConcurrentWorkOnRoot
-> renderRootConcurrent
-> workLoopConcurrent
workLoopSync
和 workLoopConcurrent
就是在执行最小任务单元,也就是我们的fiber节点
在 workLoopConcurrent
会通过 shouldYield
来判断当前浏览器是否还有空余时间。而在 workLoopSync
则没有shouldYield的判断,意味着会不间断的递归执行fiber单元。
function shouldYieldToHost() {
var timeElapsed = exports.unstable_now() - startTime;
if (timeElapsed < frameInterval) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
} // The main thread has been blocked for a non-negligible amount of time. We
return true;
}
而无论是在 performSyncWorkOnRoot
或是在 performConcurrentWorkOnRoot
都会通过调用 ensureRootIsScheduled
来发起一次任务调度。让我们来看一看
var schedulerPriorityLevel
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediatePriority
break
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingPriority
break
case DefaultEventPriority:
schedulerPriorityLevel = NormalPriority
break
case IdleEventPriority:
schedulerPriorityLevel = IdlePriority
break
default:
schedulerPriorityLevel = NormalPriority
break
}
newCallbackNode = scheduleCallback$2(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root)
)
在 ensureRootIsScheduled
中我们会通过将lan转换为 schedulerPriorityLevel
,然后调用 scheduleCallback$2
, 并且(此处注意)会将 performConcurrentWorkOnRoot
作为回调函数传入第二个参数。让我们接着往下看,其实 scheduleCallback$2
调用的就是 scheduleCallback
。在Scheduler中对应到的函数就是 unstable_scheduleCallback
。
在 unstable_scheduleCallback
中的主体逻辑是这样子的
function unstable_scheduleCallback(priorityLevel, callback, options) {
// 计算出过期时间
var expirationTime = startTime + timeout;
// 创建一个调度任务
var newTask = {
id: taskIdCounter++,
callback: callback,
priorityLevel: priorityLevel,
startTime: startTime,
expirationTime: expirationTime,
sortIndex: -1
};
// 如果任务开始时间大于当前时间,说明任务没有过期
if (startTime > currentTime) {
// This is a delayed task.
// 以startTime作为排序标准
newTask.sortIndex = startTime;
// 将任务放入到timerQueue队列中
push(timerQueue, newTask);
requestHostTimeout(handleTimeout, startTime - currentTime);
} else {
// 以expirationTime作为排序标准
newTask.sortIndex = expirationTime;
// 将过期任务放入到taskQueue中
push(taskQueue, newTask);
// wait until the next time we yield.
requestHostCallback(flushWork);
}
return newTask;
}
在讲解 unstable_scheduleCallback
之前我们先来关注两个变量 taskQueue
、timerQueue
。
taskQueue
会以任务过期时间为排序标准,有序存放过期任务。timerQueue
会以任务开始时间为排序标准,有序存放未过期的任务
unstable_scheduleCallback
的整体逻辑就是,先通过任务开始事件+任务延迟时间,计算出任务过期时间。- 新建一个调度任务
- 判断任务是否过期,未过期任务存放入
timerQueue
中,过期任务存放在taskQueue
中。 如果任务未过期会通过requestHostTimeout
延迟调用(requestHostTimeout
的本质就是调用setTimeout
使任务延迟 ),如果任务过期了便会调用requestHostCallback
。(此时传给requestHostCallback
的回调函数是flushWork
)
我们先来看看 requestHostCallback
function requestHostCallback(callback) {
// 将flushWork赋值给scheduledHostCallback
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
schedulePerformWorkUntilDeadline = function () {
port.postMessage(null);
};
我们可以看到,调用 requestHostCallback
本质上就是发起了一次 MessageChannel
的调用,产生了一个宏任务。最终会通过
channel.port1.onmessage = performWorkUntilDeadline;
在 performWorkUntilDeadline
中执行。
var performWorkUntilDeadline = function () {
try {
// 此时还行的scheduledHostCallback就是flushWork
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
schedulePerformWorkUntilDeadline();
}
};
以上代码省略了部分逻辑
此时执行的 scheduledHostCallback
就是 flushWork
,最终会调用 workLoop
,
重点关注 workLoop
这个函数。
function workLoop(){
var currentTime = initialTime;
advanceTimers(currentTime);
/* 获取任务列表中的第一个 */
currentTask = peek();
while (currentTask !== null){
/* 真正的更新函数 callback */
var callback = currentTask.callback;
if(callback !== null ){
/* 执行更新 */
// 执行真正的回调函数后返回了一个函数,这个函数其实也是performConcurrentWorkOnRoot
var continuationCallback = callback(didUserCallbackTimeout);
// 将返回的函数赋值给任务的callback
currentTask.callback = continuationCallback;
/* 先看一下 timeQueue 中有没有 过期任务。 */
advanceTimers(currentTime);
}
/* 再一次获取任务,循环执行 */
currentTask = peek(taskQueue);
}
}
在 workLoop
中会先检测一遍是否有任务过期,然后取出最先过期的任务执行。到目前为止Scheduler
的整个调度流程就结束了。
时间切片实现原理
那有些同学会问,react
到底是如何利用 Scheduler
来实现事件切片的呢。
我们来看一下。
- 在
workLoop
中执行callback
其实就是执行performConcurrentWorkOnRoot
,最终会执行workLoopConcurrent
。当浏览器没有空闲时间的时候,执行结束,又返回了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)
}
- 此时我们又请求一次时间片,拿到的依然是上一次未执行完的任务,且执行的依然是上一次的
performConcurrentWorkOnRoot
,那么react
便会接着上一次的调度任务继续执行。
react
设置时间切片的时间默认是5ms。
四、总结
最后盗用“我不是外星人”的流程图。相信大家对流程会有更好的理解
转载自:https://juejin.cn/post/7159768909392904222