前言
本文内容基于 React 16.8.6 版本,仅作为记录一些个人阅读源码的分享与体会
React 的任务调度机制其实是对 requestIdleCallback 的实现,至于 requestIdleCallback 是什么,可以自行查阅资料或者阅读本文后面会进行一些粗略的介绍。
React 自行实现任务调度而不直接使用 requestIdleCallback 的原因是:存在兼容性问题,如下图:
基本上很多游览器或者版本都无法支持。
还有一点缺点就是:requestIdleCallback 只能一秒回调 20 次。
requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work.
为此 React 自己实现了一套任务调度机制,那任务调度需要满足什么要求呢?
核心就在于:如何多次在浏览器空闲时且是渲染后才调用回调方法?
-
首先能够 多次 回调,一般来说想到的就是定时器了,而在多种定时器中,
requestAnimationFrame是能够在游览器一帧实行渲染前执行的,在这时候处理任务有利于一帧渲染后空闲时间执行任务。 -
第二,如何在 渲染后空闲时间才执行任务,那就是使用
MessageChannel,那为什么是MessageChannel,这里涉及到一个知识点:“宏任务”和“微任务”,而MessageChannel就是一种宏任务,宏任务会在主栈任务执行完成后去 Event loop 出栈一个宏任务进行处理的机制,该知识点的详细介绍可以查看本人之前的文章(链接)
下面先简单看看上诉的几个知识点 ~
调度核心知识点
requestAnimationFrame
requestAnimationFrame 在每一帧的渲染开始之前阶段执行,一般用来进行复杂动画的绘制。该函数接受一个接收 DOMHighResTimeStamp 参数的 callback 函数作为参数,返回一个 requestId 供 cancelAnimationFrame 以取消。
上图为游览器每一帧做的事情,我们可以看到 requestAnimationFrame 是在 rAF 阶段被调用。
现在可以先看看 requestAnimationFrame 实现一个简易的时间分片调度实现:
// create 1000 tasks
const tasks = Array.from({ length: 1000 }, () => () => { console.log('task run'); })
const doTasks = (fromIndex = 0) => {
const start = Date.now();
let i = fromIndex;
let end;
do {
tasks[i++](); // do task
end = Date.now();
} while(i < tasks.length && end - start < 20); // 就算有时间也只给单次执行 20 毫秒
console.log('tasks remain: ', 1000 - i);
// if remaining tasks exsis when timeout. Run at next frame
if (i < tasks.length) {
requestAnimationFrame(doTasks.bind(null, i));
}
}
// start tasks scheduler
requestAnimationFrame(doTasks.bind(null, 0))
/**
output:
168 task run
tasks remain: 832
178 task run
asks remain: 654
162 task run
tasks remain: 492
119 task run
tasks remain: 373
158 task run
tasks remain: 215
87 task run
tasks remain: 128
125 task run
tasks remain: 3
3 task run
tasks remain: 0
*/
从上诉例子中可以看到,使用 requestAnimationFrame 进行了简单的在每一帧中的 20ms内处理任务。这就做到了“多次”循环定时器调用的作用,现在有一个问题,这里的 20ms 是如何定义的,实际上在 react 的调度任务中,是不会定义死这 20ms 的,它会根据游览器空闲时间和不同设备游览器的刷新率,也就是一帧多少时长好计算的。
requestIdleCallback
与每帧执行的 requestAnimationFrame 相比,requestIdleCallback 是一个低优先级调度,当且仅当浏览器空闲时才会执行任务的调度。
requestIdleCallback Api
window.requestIdleCallback(
callback: (dealine: IdleDeadline) => void,
option?: {timeout: number} // 超时时间,防止一直不执行 callback 的一种优先级设置
)
如果游览器有可执行时间分配的时候,就会调用 callback 方法,并传入 IdleDeadline 对象。
IdleDeadline 的接口如下:
interface IdleDealine {
didTimeout: boolean // 表示任务执行是否超过约定时间
timeRemaining(): DOMHighResTimeStamp // 任务可供执行的剩余时间
}
requestIdleCallback 的意思是让浏览器在'有空'的时候就执行我们的回调,这个回调会传入一个期限,表示浏览器有多少时间供我们执行,在这个时间范围内执行。
下面看个例子:
const tasks = Array.from({ length: 1000 }, () => () => { console.log('task run'); })
const doTasks = (fromIndex = 0, idleDeadline) => {
let i = fromIndex;
let end;
console.log('time remains: ', idleDeadline.timeRemaining());
do {
tasks[i++](); // do task
} while(i < tasks.length && idleDeadline.timeRemaining() > 0); // Do tasks in 20ms
console.log('tasks remain: ', 1000 - i);
// if remaining tasks exsis when timeout. Run at next frame
if (i < tasks.length) {
requestIdleCallback(doTasks.bind(null, i));
}
}
// start tasks scheduler
requestIdleCallback(doTasks.bind(null, 0))
/**
output:
time remains: 49.970000000000006
360 task run
tasks remain: 640
time remains: 49.77
395 task run
tasks remain: 245
time remains: 29.255000000000003
215 task run
tasks remain: 30
time remains: 49.96000000000001
30 task run
tasks remain: 0
*/
这会有一个问题,那就是如果一直未能分配时间给我们呢?这会导致任务被饿死,拿不到资源,可以设置一个超时时间,就是 requestIdleCallback 的第二个参数 option?: {timeout: number} 可以设置。
对于该 API 在 React Fiber 中也是用了该思想,不过的是,React 为了兼容性,它利用 MessageChannel 模拟将回调延迟到'绘制操作'之后执行。
MessageChannel
介绍 MessageChannel 之前需要先了解 JS 的 macroTask 和 microTask:
macroTask 与 microTask 指的就是宏任务和微任务,在异步队列中,把异步任务分成了宏任务和微任务两种;
macroTask 与 microTask 任务都是异步任务,执行的时候都会被入栈到异步任务队列中,并且等待某个时机被主线程入栈执行;
macroTask
macroTask(宏任务) 在浏览器端,其可以理解为该任务执行完后,在下一个 macroTask 执行开始前,浏览器可以进行页面渲染。
触发 macroTask 任务的操作包括:
-
script(整体代码)
-
setTimeout、setInterval、setImmediate(浏览器完成其他操作(例如事件和显示更新)后立即运行回调函数-链接)
-
I/O、UI交互事件
-
postMessage、MessageChannel
microTask
microTask(微任务)可以理解为在 macroTask 任务执行后,页面渲染前立即执行的任务。
触发 microTask 任务的操作包括:
-
Promise.then
-
MutationObserver(提供了监视对DOM树所做的更改的功能-链接)
-
process.nextTick(Node环境)
使用案例(链接):
var channel = new MessageChannel();
var para = document.querySelector('p');
var ifr = document.querySelector('iframe');
var otherWindow = ifr.contentWindow;
ifr.addEventListener("load", iframeLoaded, false);
function iframeLoaded() {
otherWindow.postMessage('Hello from the main page!', '*', [channel.port2]);
}
channel.port1.onmessage = handleMessage;
function handleMessage(e) {
para.innerHTML = e.data;
}
从这里可以看出 MessageChannel 属于 macroTask,这样利用 MessageChannel 模拟可将回调延迟到'绘制操作'之后执行了。
Scheduler
阅读了上面关于 React 任务调度的几个核心知识点后,接下来就可以着手阅读了解一下源码具体是如何实现的了。
React 的调度算法的源码位置为:packages/scheduler/src/Scheduler.js
任务优先级
优先级
var ImmediatePriority = 1;
var UserBlockingPriority = 2;
var NormalPriority = 3;
var LowPriority = 4;
var IdlePriority = 5;
时间戳
/**
* Max 31 bit integer. The max integer size in V8 for 32-bit systems.
* Math.pow(2, 30) - 1
* 0b111111111111111111111111111111
*/
var maxSigned31BitInt = 1073741823;
/** Times out immediately */
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
/** Eventually times out */
var USER_BLOCKING_PRIORITY = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
/** Never times out */
var IDLE_PRIORITY = maxSigned31BitInt;
React 在调度逻辑中,首先定义了 5 个优先级,根据不同的等级匹配上对应的时间戳来区别每一个任务的执行顺序。
也就是说,假设当前时间为 5000 并且分别有两个优先级不同的任务要执行。前者属于 ImmediatePriority,后者属于 UserBlockingPriority,那么两个任务计算出来的时间分别为 4999 和 5250。通过这个时间可以比对大小得出谁的优先级高。
任务优先级排序
调度开始,首先开始对任务进行排序 unstable_scheduleCallback:
function unstable_scheduleCallback(callback, deprecated_options) {
// 获取当前时间 getCurrentTime() 内部使用的是 Performance.now()
var startTime =
currentEventStartTime !== -1 ? currentEventStartTime : getCurrentTime();
// 任务到期时间,超过这个时间的话,就要执行了,未超过就是还可以缓一缓的意思
var expirationTime;
if (
typeof deprecated_options === 'object' &&
deprecated_options !== null &&
typeof deprecated_options.timeout === 'number'
) {
// FIXME: Remove this branch once we lift expiration times out of React.
expirationTime = startTime + deprecated_options.timeout;
} else {
// 根据任务等级进行排序,计算到期时间
switch (currentPriorityLevel) {
case ImmediatePriority:
expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
expirationTime = startTime + USER_BLOCKING_PRIORITY;
break;
case IdlePriority:
expirationTime = startTime + IDLE_PRIORITY;
break;
case LowPriority:
expirationTime = startTime + LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
expirationTime = startTime + NORMAL_PRIORITY_TIMEOUT;
}
}
// 构建任务节点,后续初始化节点时能够设置对应的上一个节点和下一个节点
var newNode = {
callback,
priorityLevel: currentPriorityLevel,
expirationTime,
next: null,
previous: null,
};
// 将新的节点插入列表中,先按到期时间排序
// 这是一个双向循环队列
if (firstCallbackNode === null) {
// 如果第一个节点为 null 表示当前队列中没有节点,也就是只有 newNode
// 因此自己循环
firstCallbackNode = newNode.next = newNode.previous = newNode;
// 发起调度准备
ensureHostCallbackIsScheduled();
} else {
// 意思为:排序的时候,next 存储着 newNode 的下一个节点是谁
var next = null;
// node 保存的是当前正在循环查找的节点
var node = firstCallbackNode;
do {
if (node.expirationTime > expirationTime) {
// 当前循环对比节点 node 的到期时间比当前新节点 newNode 的到期时间长,
// 所以 next 存放 node,表示这个节点将在 newNode 之后
next = node;
break;
}
// 循环下一个对比节点
node = node.next;
} while (node !== firstCallbackNode); // 直到循环对比结束
if (next === null) {
// 如果 next 还是为 null,表示找不到优先级比 newNode 低的,那 next 存放第一个,因为是循环队列,后续就会把 newNode 下一个节点指向 next
// 注意的是 expirationTime 越大,优先级越低
next = firstCallbackNode;
} else if (next === firstCallbackNode) {
// 这种情况就是 newNode 是优先级最高的,也就是 expirationTime 最小,next 存放的是 firstCallbackNode 也就是队列第一个节点
// 之后 next 就作为 newNode 的下一个节点了,也就变成第二个节点
// firstCallbackNode 设置为 newNode 为第一个节点
firstCallbackNode = newNode;
ensureHostCallbackIsScheduled(); // 触发调度准备
}
// 对双向队列进行 newNode 插入
var previous = next.previous;
previous.next = next.previous = newNode;
newNode.next = next;
newNode.previous = previous;
}
return newNode;
}
上述代码就是源代码的 unstable_scheduleCallback 方法,这里已经做上注释,可能很好的理解,这一个函数作用就是对任务进行优先级评估和排序的功能
调度前准备
经过 unstable_scheduleCallback 的排序,接下来进行一个是否能够进行调度的判断
ensureHostCallbackIsScheduled
function ensureHostCallbackIsScheduled() {
// 有调度任务在执行中,直接退出,以防止重新进入
if (isExecutingCallback) {
// Don't schedule work yet; wait until the next time we yield.
return;
}
// Schedule the host callback using the earliest expiration in the list.
var expirationTime = firstCallbackNode.expirationTime;
// 判断是否有正在调度,但是却还没轮到执行的任务
if (!isHostCallbackScheduled) {
// 如果没有,则设置为,true,当前任务进行调度
isHostCallbackScheduled = true;
} else {
// Cancel the existing host callback.
// 如果有,则中断处理,并且让新进来的 newNode 任务进行调度,
// 因为 newNode 优先级更高
cancelHostCallback();
}
requestHostCallback(flushWork, expirationTime);
}
这里会先判断 isExecutingCallback 这个变量会在任务执行函数 flushWork 中进行变更。
isHostCallbackScheduled 在任务准备进入调度的时候会进行设置为 ture,在 flushWork 中当 firstCallbackNode 为 null 的时候,也就是执行完当前调度任务的时候,会设置为 false
cancelHostCallback
取消现有的或者待处理的任务回调
cancelHostCallback = function() {
scheduledHostCallback = null; // 取消当前任务调度
isMessageEventScheduled = false; // 标识可以进行 postMessage ,也就是未有任务正在执行
timeoutTime = -1; // 初始化
};
requestHostCallback
在经过 ensureHostCallbackIsScheduled 调度的判断,在 requestHostCallback 还会进行以下判断:
- 对当前任务的时间
timeoutTime(是否到时间了) 和任务的isFlushingHostCallback(是否已经完成上一个调度正在执行任务了)。 - 对任务未超时且还没有开启一个调度任务
isAnimationFrameScheduled进行判断
经过 1 判断,将不要等待下一帧。在新事件中,请继续尽快工作。
经过 2 判断,触发 requestAnimationFrameWithTimeout 开启定时器调度
requestHostCallback = function(callback, absoluteTimeout) {
scheduledHostCallback = callback;
timeoutTime = absoluteTimeout; // 这个就是任务的到期时间
if (isFlushingHostCallback || absoluteTimeout < 0) {
// absoluteTimeout 过期时间已经小于零,isFlushingHostCallback已经完成调度正在执行任务了
// Don't wait for the next frame. Continue working ASAP, in a new event.
port.postMessage(undefined);
} else if (!isAnimationFrameScheduled) {
// If rAF didn't already schedule one, we need to schedule a frame.
// TODO: If this rAF doesn't materialize because the browser throttles, we
// might want to still have setTimeout trigger rIC as a backup to ensure
// that we keep performing work.
// isAnimationFrameScheduled 为 false 该任务未超时且没开启一个调度任务
isAnimationFrameScheduled = true;
requestAnimationFrameWithTimeout(animationTick);
}
};
定时器
上面我们说到,要模拟实现 requestIdleCallback 机制,那就需要一个定时器,这里的 requestAnimationFrameWithTimeout 就是进行定时器的安排;
还有就是,在执行 requestAnimationFrameWithTimeout 方法的时候,会传入一个 animationTick,这个是调度算法的核心,后面一节会解释到;
接下来直接来看代码
var localSetTimeout = typeof setTimeout === 'function' ? setTimeout : undefined;
var localClearTimeout =
typeof clearTimeout === 'function' ? clearTimeout : undefined;
var localRequestAnimationFrame =
typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: undefined;
var localCancelAnimationFrame =
typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : undefined;
var requestAnimationFrameWithTimeout = function(callback) {
// schedule rAF and also a setTimeout
rAFID = localRequestAnimationFrame(function(timestamp) {
// cancel the setTimeout
localClearTimeout(rAFTimeoutID);
callback(timestamp);
});
rAFTimeoutID = localSetTimeout(function() {
// cancel the requestAnimationFrame
localCancelAnimationFrame(rAFID);
callback(getCurrentTime());
}, ANIMATION_FRAME_TIMEOUT);
};
从源码的这个 requestAnimationFrameWithTimeout 方法中我们可以看到,在定时器的处理上,使用了两种方案并用的方式,包括了 requestAnimationFrame 和 setTimeout,那问题来了,为什么这里会要用到 setTimeout 呢?
这是因为 requestAnimationFrame 在系统进入后台的时候,会失去作用,这个使用就采用了 setTimeout 进行一个弥补的措施。
在 requestAnimationFrameWithTimeout 定时器的作用下,会进行多次的 animationTick 回调,该回调进行下一帧时长(nextFrameTime)与下一帧结束时间(frameDeadline)的计算,并判断是否进行任务安排进入
调度核心算法
根据设备刷新率计算下一帧时长(nextFrameTime)与下一帧结束时间(frameDeadline)
var animationTick = function(rafTime) {
// 调度程序队列在帧末尾不为空,则它
// 将继续在该回调内部刷新。
// 如果队列为空,将立即退出。
if (scheduledHostCallback !== null) {
// Eagerly schedule the next animation callback at the beginning of the
// frame. If the scheduler queue is not empty at the end of the frame, it
// will continue flushing inside that callback. If the queue *is* empty,
// then it will exit immediately. Posting the callback at the start of the
// frame ensures it's fired within the earliest possible frame. If we
// waited until the end of the frame to post the callback, we risk the
// browser skipping a frame and not firing the callback until the frame
// after that.
requestAnimationFrameWithTimeout(animationTick);
} else {
// No pending work. Exit.
isAnimationFrameScheduled = false;
return;
}
/**
* 这个 nextFrameTime 有两个含义,
* 一开始的时候,nextFrameTime = rafTime(调用时间) - frameDeadline(计算出来的下一帧调用该方法的时间,一开始为0) + activeFrameTime(一帧的时长默认为:33),
* 这个时候 nextFrameTime == frameDeadline == previousFrameTime,nextFrameTime 就是估算得到的下一次执行时间
* 第二次调用的时候,不管 rafTime 会在 frameDeadline 之前或者之后调用,
* 通过计算公式 rafTime - frameDeadline + activeFrameTime,就能够计算出来 nextFrameTime(下一帧的时长)
* 这时候 previousFrameTime 的意义也变成了,上一帧的时长了,
* 如果前后两帧的时长都小于 activeFrameTime(当前保存的帧时长)的话,就进行更新 activeFrameTime,
* 更新 activeFrameTime 的时长是取 previousFrameTime 和 nextFrameTime 两个较长的那个
*/
var nextFrameTime = rafTime - frameDeadline + activeFrameTime;
if (
nextFrameTime < activeFrameTime &&
previousFrameTime < activeFrameTime
) {
if (nextFrameTime < 8) {
// Defensive coding. We don't support higher frame rates than 120hz.
// If the calculated frame time gets lower than 8, it is probably a bug.
nextFrameTime = 8;
}
// If one frame goes long, then the next one can be short to catch up.
// If two frames are short in a row, then that's an indication that we
// actually have a higher frame rate than what we're currently optimizing.
// We adjust our heuristic dynamically accordingly. For example, if we're
// running on 120hz display or 90hz VR display.
// Take the max of the two in case one of them was an anomaly due to
// missed frame deadlines.
activeFrameTime =
nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
} else {
previousFrameTime = nextFrameTime;
}
frameDeadline = rafTime + activeFrameTime;
if (!isMessageEventScheduled) {
isMessageEventScheduled = true;
port.postMessage(undefined);
}
};
在通过一些变量计算后,最后会触发 port.postMessage 宏任务,这个宏任务就保证了,在游览器每一帧的渲染结束后,再去执行这个宏任务的机制。
MessageChannel 安排任务入队列
通过 animationTick 计算出 frameDeadline 后,会在 postMessage 中去使用,
channel.port1.onmessage = function(event) {
isMessageEventScheduled = false; // 接触 postMessage 消息发送控制
// 将当前调度任务保存
var prevScheduledCallback = scheduledHostCallback;
var prevTimeoutTime = timeoutTime;
scheduledHostCallback = null;
timeoutTime = -1;
var currentTime = getCurrentTime();
var didTimeout = false;
// 当前队列中 onmessage 宏任务执行的时候,在下一帧调用时间之后了
// 说明上一帧游览器渲染后,没有空闲剩余时间去执行
if (frameDeadline - currentTime <= 0) {
// There's no time left in this idle period. Check if the callback has
// a timeout and whether it's been exceeded.
if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) {
// 当前一个任务的过期时间存在,并且当前时间已经大于这个过期时间了
// Exceeded the timeout. Invoke the callback even though there's no
// time left.
didTimeout = true;
} else {
// No timeout.
if (!isAnimationFrameScheduled) {
// 没时间且没任务未超时,重新调度
// Schedule another animation callback so we retry later.
isAnimationFrameScheduled = true;
requestAnimationFrameWithTimeout(animationTick);
}
// 退出本次调度而不调用回调。
// Exit without invoking the callback.
scheduledHostCallback = prevScheduledCallback;
timeoutTime = prevTimeoutTime;
return;
}
}
if (prevScheduledCallback !== null) {
isFlushingHostCallback = true;
try {
// 执行队列任务,传入 didTimeout 判断是否超时
prevScheduledCallback(didTimeout);
} finally {
isFlushingHostCallback = false;
}
}
};
上面的 onmessage 执行的逻辑,都进行了相关的注释说明了,最后的步骤我们看到,函数中调用了 prevScheduledCallback,那这里的 prevScheduledCallback 到底是什么来的?
其实这里的 prevScheduledCallback 就是接下来要解释的 flushWork 函数,并且执行该函数的时候,传入了 didTimeout 来进行判断是否超时了。
执行任务函数 flushWork
最后,React 通过上面的逻辑就实现了时间调度,对游览器每一帧上的空闲时间进行调度,接下来就是通过每次的调度后在空闲时间上执行 flushWork 来执行队列任务了。
shouldYieldToHost = function() {
return frameDeadline <= getCurrentTime();
};
function flushWork(didTimeout) {
// Exit right away if we're currently paused
if (enableSchedulerDebugging && isSchedulerPaused) {
return;
}
isExecutingCallback = true; // 开始真正调用 callback
const previousDidTimeout = currentDidTimeout;
currentDidTimeout = didTimeout;
try {
// didTimeout 是一个 boolean 值,用于判断调度任务是否超时了
// 如果是,则需要执行该任务,否者该任务就要饿死了
if (didTimeout) {
// Flush all the expired callbacks without yielding.
while (
firstCallbackNode !== null &&
!(enableSchedulerDebugging && isSchedulerPaused) // 链表循环判断节点或者是否暂停了
);
) {
// TODO Wrap in feature flag
// Read the current time. Flush all the callbacks that expire at or
// earlier than that time. Then read the current time again and repeat.
// This optimizes for as few performance.now calls as possible.
var currentTime = getCurrentTime();
// 循环执行已经超时的所有任务
if (firstCallbackNode.expirationTime <= currentTime) {
do {
flushFirstCallback();
} while (
firstCallbackNode !== null &&
firstCallbackNode.expirationTime <= currentTime &&
!(enableSchedulerDebugging && isSchedulerPaused)
);
continue;
}
break;
}
} else {
// Keep flushing callbacks until we run out of time in the frame.
if (firstCallbackNode !== null) {
do {
if (enableSchedulerDebugging && isSchedulerPaused) {
break;
}
flushFirstCallback();
// shouldYieldToHost 用于判断是否还有剩余时间执行任务
} while (firstCallbackNode !== null && !shouldYieldToHost());
}
}
} finally {
isExecutingCallback = false;
currentDidTimeout = previousDidTimeout;
if (firstCallbackNode !== null) {
// There's still work remaining. Request another callback.
ensureHostCallbackIsScheduled();
} else {
isHostCallbackScheduled = false;
}
// Before exiting, flush all the immediate work that was scheduled.
flushImmediateWork();
}
}
进入函数通过判断 didTimeout 如果为 true,表示 调度任务已经超时,说明这个任务已经等太久了,再不让执行就要饿死了!因此,便获得了在不打断的情况下执行所有已超时的任务的权限。
若当前调度尚未超时,则在规定的时效内(shouldYieldToHost当前时间小于 frameDeadline 下一帧之前 ),尽可能多的执行任务。当该次调度执行完毕(不管是任务执行完或者因为中断暂停执行),在任务队列还有任务需要执行的时候重新执行 ensureHostCallbackIsScheduled 为下一次的任务调度做准备。
总结
通过该文章对原来的解析,总算是过了一遍 React Scheduler 的业务逻辑了,现在来看看一张流程图,更清晰的看看