React源码之任务调度 + 简单实现
(免责声明:文章内容纯个人理解,本人只是学习并且总结,不是全部Scheduler,只是一些核心点)
最近正在研究的是,当一个任务到来时,react如何调度任务。
首先,react任务调度的核心在Scheduler包,可以认为它是一个js实现的任务调度器,没有和react强绑定的关系。
-
小顶堆
任务有很多,应当如何保存,以便调度器可以调度。在Scheduler中,我们使用了小顶堆的数据结构。由于是堆结构,任务调度时只需要关注堆顶的任务,优先级最高的任务就是堆顶任务,复杂度会低很多。这里手写一下小顶堆的结构。
export type Heap = Array<Node>
export type Node = {
id: number;
sortIndex: number
}
//堆顶元素
export function peek(heap: Heap): Node | null {
return heap.length === 0 ? null : heap[0];
}
export function push(heap: Heap, node: Node): void {
const index = heap.length;
heap.push(node);
siftUp(heap, node, index);
}
export function pop(heap: Heap): Node | null {
if (heap.length === 0) {
return null;
}
const first = heap[0];
const last = heap.pop();
if (last !== first) {
heap[0] = last!;
siftDown(heap, last!, 0);
}
return first;
}
function siftUp(heap: Heap, node: Node, i: number) {
let index = i;
while (index > 0) {
const parentIndex = (index - 1) >>> 1;
const parent = heap[parentIndex];
if (compare(parent, node) > 0) {
heap[parentIndex] = node;
heap[index] = parent;
index = parentIndex;
} else {
return;
}
}
}
function siftDown(heap: Heap, node: Node, i: number) {
let index = i;
const length = heap.length;
const halfLength = length >>> 1;
while (index < halfLength) {
const leftIndex = (index + 1) * 2 - 1;
const left = heap[leftIndex];
const rightIndex = leftIndex + 1;
const right = heap[rightIndex];
if (compare(left, node) < 0) {
if (rightIndex < length && compare(right, left) < 0) {
heap[index] = right;
heap[rightIndex] = node;
index = rightIndex;
} else {
heap[index] = left;
heap[leftIndex] = node;
index = leftIndex;
}
} else if (rightIndex < length && compare(right, node) < 0) {
heap[index] = right;
heap[rightIndex] = node;
index = rightIndex;
} else {
return;
}
}
}
function compare(a: Node, b: Node) {
const diff = a.sortIndex - b.sortIndex;
return diff !== 0 ? diff : a.id - b.id;
}
- 任务调度器
一个任务,是一个什么数据结构?任务进来肯定是一个函数,而我们需要封装成我们需要的结构。
export type Task = {
id: number; //任务ID
callback: Callback | null //任务回调函数
priorityLevel: PriorityLevel //任务优先级
startTime: number; //任务开始时间(进入调度器的时间)
expirationTime: number //任务过期时间
sortIndex: number; //任务排序索引
};
其次,任务有优先级,优先级高的应当先被执行,每个优先级有一个过期时间,先过期的任务应该先执行。
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;
const maxSigned31BitInt = 1073741823;
export const IMMEDIATE_PRIORITY_TIMEOUT = -1;
export const USER_BLOCKING_PRIORITY_TIMEOUT = 250;
export const NORMAL_PRIORITY_TIMEOUT = 5000;
export const LOW_PRIORITY_TIMEOUT = 10000;
export const IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;
export function getTimeoutByPriorityLevel(priorityLevel: PriorityLevel) {
let timeout: number;
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;
}
return timeout;
}
在探讨Scheduler时,先定义一些常量。具体作用看注释。
let taskIdCounter = 1; //任务ID计数器
let startTime = -1 //时间切片起始时间
let frameInterval = 5 //时间切片 = 5ms
let isPerformingWork = false //是否正在调度任务
let isHostCallbackScheduled = false //标记是否安排浏览器调度任务
let isMessageLoopRunning = false //是否启动消息循环
//任务池,最小堆
const taskQueue: Array<Task> = []
//当前任务
let currentTask: Task | null = null
//当前任务优先级
let currentPriority: PriorityLevel = NoPriority;
当一个任务到来时,通过scheduleCallback函数进入调度入口,并且这是唯一暴露给其他包的函数。 看看这个函数主要做什么?
- 创建任务
- 放入小顶堆
- 根据当前状态决定是否向浏览器注册任务
export function scheduleCallback(
priorityLevel:PriorityLevel,
callback:Callback,
){
//任务进入调度
const currentTime = getCurrentTime()
let startTime: number;
startTime = currentTime;
const timeout = getTimeoutByPriorityLevel(priorityLevel);
const expirationTime = startTime + timeout;
const newTask = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime, //任务开始调度理论时间
expirationTime, //过期时间
sortIndex: -1 //越小越优先调度
}
newTask.sortIndex = expirationTime
push(taskQueue,newTask)
if(!isHostCallbackScheduled && !isPerformingWork){ //即使是false也不用担心,浏览器已经在执行了
isHostCallbackScheduled = true
requestHostCallback() //给浏览器注册一个回调函数
}
}
在上述代码中,执行了requestHostCallback(),这个函数主要作用是通知浏览器执行任务。
function requestHostCallback(){
if(!isMessageLoopRunning){
isMessageLoopRunning = true
schedulePerformWorkUntilDeadline() //时间切片内执行,直到时间切片结束
}
}
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline
//时间切片内执行,直到时间切片结束
function schedulePerformWorkUntilDeadline() {
port.postMessage(null);
}
function performWorkUntilDeadline(){
if(isMessageLoopRunning){
//一个work的起始时间
const currentTime = getCurrentTime()
let hasOtherWork = false;
try{
hasOtherWork = flushWork(currentTime)
} finally{
if(hasOtherWork){
//如果还有其他任务需要执行,继续调度
schedulePerformWorkUntilDeadline();
}else{
isMessageLoopRunning = false;
}
}
}
}
这里有一个时间切片的概念。试想有一个时间很长的任务,如果一直执行,势必会消耗很长时间。如果再进入一个高优先级任务,那么这个任务就没办法及时调度,就会阻塞。所以引入了时间切片的概念。
React中,时间切片为5ms,在这个时间段里,会执行任务,如果任务无法完成则中断这个任务,等待后续调度。为什么可以中断任务,这是由React Fiber完成的。对于React Fiber,以后再说。
requestHostCallback 就像是 requestIdleCallback, 通知浏览器我要执行任务!
在React中自己实现了这样一个通知,使用的是MessageChannel(),他可以创建一个宏任务,达到批量处理一组任务的目的。
- 执行任务
之前是在调度任务, 到现在任务要执行了。看上面的代码,已经知道执行任务是通过flushWork(currentTime)执行的,这个函数有一个返回值,如果任务还没执行完就是true,执行完就是false。
function flushWork(initialTime: number) {
isHostCallbackScheduled = false
isPerformingWork = true
let previousPriorityLevel = currentPriority
try{
return workLoop(initialTime)
} finally {
currentPriority = previousPriorityLevel
currentTask = null
isPerformingWork = false
}
}
//todo
//控制权交还给主线程,当前时间 - 开始时间 >= 5ms
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime
return timeElapsed >= frameInterval
}
//很多task要执行,每个task有一个callback
//一个work就是一个时间切片内执行的一些task
//时间切片要循环,就在workLoop中实现
// 返回true表示还有任务未完成,需要继续执行
function workLoop(initialTime: number) {
let currentTime = initialTime
currentTask = peek(taskQueue) as Task
while(currentTask !== null) {
if(currentTask.expirationTime > currentTime && shouldYieldToHost()) {
break;
}
const callback = currentTask.callback
if(isFn(callback)) { //当前任务未被取消(存在),任务有效
currentTask.callback = null //将任务的回调函数置为null,表示任务已开始执行,防止任务重复执行
currentPriority = currentTask.priorityLevel
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime
const continuationCallback = callback(didUserCallbackTimeout) //执行任务回调函数
if(isFn(continuationCallback)) { //如果回调函数返回了一个新的任务,即当前任务没有执行完成
currentTask.callback = continuationCallback
return true
}else{
if(currentTask === peek(taskQueue)) { //如果当前任务仍然是堆顶任务
pop(taskQueue); //从任务池中删除
}
}
}else{ //任务无效
pop(taskQueue); //从任务池中删除
}
currentTask = peek(taskQueue) as Task; //获取下一个任务
}
if(currentTask !== null) {
return true
} else{
return false; //没有更多任务需要执行
}
}
任务执行的核心在workLoop,它的作用就是在时间切片内循环执行任务。代码注释写的详细无比,自己阅读一下。反正没人看,我写到自己理解就不想写了。
顺便贴一下用到的工具函数
export function getCurrentTime(): number {
return performance.now();
}
export function isArray(sth: any) {
return Array.isArray(sth);
}
export function isNum(sth: any) {
return typeof sth === "number";
}
export function isObject(sth: any) {
return typeof sth === "object";
}
export function isFn(sth: any) {
return typeof sth === "function";
}
export function isStr(sth: any) {
return typeof sth === "string";
}