手摸手教你实现react Scheduler(二)

868 阅读6分钟

在上一篇文章《react Scheduler(一)》中,我们利用MessageChannel实现了一个简单的任务调度功能。但是这个功能目前还比较简陋,现在我们来进行完善,为任务加上优先级和过期时间。

定义任务优先级:

const NoPriority = 0; // 没有优先级
const ImmediatePriority = 1; // 优先级最高
const UserBlockingPriority = 2;
const NormalPriority = 3;
const LowPriority = 4;
const IdlePriority = 5; // 优先级最低

首先要明确一点,在任务队列taskQueue中,任务的排序不是直接根据这个优先级来排的,而是根据过期时间排序。越早过期的任务在队列中的任务越靠前。所以我们还需要定义任务延迟时间:

const maxSigned31BitInt = 1073741823; // 最大的31位整数
const IMMEDIATE_PRIORITY_TIMEOUT = -1; // 立即过期,对应最高优先级的任务
const USER_BLOCKING_PRIORITY_TIMEOUT = 250;
const NORMAL_PRIORITY_TIMEOUT = 5000;
const LOW_PRIORITY_TIMEOUT = 10000;
const IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; // 永远不会过期

任务开始时间 + 延迟时间 = 任务过期时间。在执行任务的时候,只有任务过期了,我们才去执行任务,否则就要交出主线程控制权。

既然已经有了任务优先级,那么直接根据任务优先级来排序不就可以了吗,为什么还需要搞个过期时间呢?假设这样一种场景:我们把一个优先级为NormalPriority的任务A(切片为A1, A2, A3)加入到队列中,在调度A1的时候,我们在队列中加入优先级为USER_BLOCKING_PRIORITY_TIMEOUT的任务B,然后在任务B结束后又继续加入B任务,这样一直不停地插队。如果仅仅按照优先级来决定先执行哪个任务,那A任务一直都得不到执行。但是现在我们使用过期时间expirationTime来排序,一开始有B任务来插队,因为你任务B过期时间才250,所以expirationTime比较小,ok没问题,你可以插队先执行。随着你插队次数越来越多,你后面插进来的任务,expirationTime一定会比A任务的大,这时候就应该等A任务先执行了。这就是expirationTime的作用:避免低优先级任务一直得不到执行。

现在的任务已经不能只是一个简单的函数了,我们使用函数createTask来创建任务:

let taskIdCounter = 1; // 全局变量,自增的任务id
function createTask(priorityLevel, callback) {
    const currentTime = performance.now();
    const startTime = currentTime;
    let timeout = 0;
    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;
    }
    const expirationTime = startTime + timeout; // 计算出不同优先级任务对应的过期时间
    const newTask = {
        id: taskIdCounter++,
        callback, // 需要执行的任务
        priorityLevel,
        startTime,
        expirationTime,
        sortIndex: expirationTime, // 使用expirationTime排序
    }
    return newTask;
}

任务已经创建好了,我们需要有一个优先队列来存放任务。优先任务队列是一个最小堆数据结构,这里我们简单介绍一下最小堆,不熟悉的读者可以自行了解。

堆是一种完全二叉树,它的的每个节点都小于等于它的子节点。在js中可以用数组来实现最小堆。对于数组索引为index的节点,它的父节点索引是Math.floor(index - 1) / 2, 左右子节点的索引分别是2 * index + 12 * index + 2。最小堆的堆顶元素是最小的值,所以获取最小值的时间复杂度是O(1)。对应到我们的任务队列中,堆顶就是过期时间最小(需要最早执行)的任务。

最小堆的js实现:

class SchedulerMinHeap {
    constructor() {
        this.heap = [];
    }
    push(node){
        this.heap.push(node);
        this.siftUp(node, this.heap.length - 1);
    }
    peek(){
        return heap.length ? heap[0] : null;
    }
    pop(){
        if(this.heap.length === 0) return null;
        if(this.heap.length === 1) return this.heap.pop();
        const first = heap[0];
        const last = this.heap.pop();
        heap[0] = last;// 最后一个节点放到堆顶
        this.siftDown(heap[0], 0); // 开始下移
        return first; // 返回堆顶节点
    }
    // 上移节点,直到到达堆顶或者父节点比自己小
    siftUp(node, idx){
        let index = idx;
        while(index > 0) {
            const parentIndex = Math.floor(index - 1);
            const parent = this.heap[parentIndex];
            if(this.compare(node, parent) < 0) {
                // 比父节点小,交换位置
                head[parentIndex] = node;
                head[index] = parent;
                index = parentIndex;
            } else {
                return;
            }
        }
    }
    // 下移节点,直到到达堆底或者子节点比自己大。
    // 调用场景:删除堆顶节点后,要把堆底元素放到堆顶的位置,然后下移
    siftDown(node, idx){
        let index = i;
        const length = this.heap.length;
        const half = Math.floor(length / 2);// react源码中使用移位操作:index >>> 1
        while(index < half) {
            // 还没到达二叉树的最底层
            const leftIndex = (index + 1) * 2 - 1;
            const rightIndex = leftIndex + 1;
            // 左子节点一定存在,如果不存在,说明到了二叉树最底层,不会进这个循环
            const left = this.heap[leftIndex];
            // 右子节点不一定存在
            const right = this.heap[rightIndex];
            if(this.compare(left, node) < 0) {
                // 左子节点比当前节点小,需要下移,交换左子节点还是右子节点?
                if(rightIndex < length && compare(right, left) < 0) {
                    // 存在右子节点且更小,交换右子节点
                    this.heap[index] = right;
                    this.heap[rightIndex] = node;
                    index = rightIndex;
                }else{
                    // 左子节点更小,交换左子节点
                    this.heap[index] = left;
                    this.heap[leftIndex] = node;
                    index = leftIndex;
                }
            } else if(rightIndex < length && compare(right, node) < 0) {
                // 左子节点比较大,看看右子节点的情况: 右子节点存在且更小,交换
                this.heap[index] = right;
                this.heap[rightIndex] = node;
                index = rightIndex;
            } else {
                // 左右子节点都比node大
                return;
            }
        }
    }
    // a小于b,则返回负数;
    compare(a, b){
        // 优先使用sortIndex比较,一样就用id比较
        const diff = a.sortIndex - b.sortIndex;
        return diff !== 0 ? diff : a.id - b.id;
    }
}

实例化一个全局的任务队列:

const taskQueue = new SchedulerMinHeap();

任务和任务队列已经实现好了,后面要做的事就是创建任务,push进任务队列,然后postMessage开启任务调度。

function scheduleCallback(priorityLevel, callback) {
    const task = createTask(priorityLevel, callback);// 创建任务
    taskQueue.push(task); // 加入任务队列
    requestHostCallback(); // 开始调度任务
}

接下来是requestHostCallback的实现,要注意scheduleCallback是可能被调用很多次的,不能每调用一次就postMessage一次。所以我们会用一个全局变量来控制一下:

const channel = new MessageChannel(); 
const port2 = channel.port2; 
const port1 = channel.port1; 
port1.onmessage = performWorkUntilDeadline;

let isMessageLoopRunning = false; // 主动调度任务时,如果这个值为true就不能postMessage
function requestHostCallback() {
    if(!isMessageLoopRunning) {
        port2.postMessage(null);
    }
}

function performWorkUntilDeadline() {
    let hasMoreWork = true;
    const currentTime = performance.now();
    startTime = currentTime; // 更新任务开始时间
    try {
        hasMoreWork = flushWork(currentTime);
    } finally {
        if(hasMoreWork) {
            port2.postMessage(null);
        } else {
            // 没有更多任务了,等待下一次创建新任务
            isMessageLoopRunning = false;
        }
    }
}

可以看到,从任务队列中取任务出来执行,都是在flushWork中做的,它返回任务队列中是否还有任务待处理

let startTime = -1; 任务开始时间
const frameYieldMs = 5; // 任务的连续执行时间不能超过5ms 
let currentTask = null; // 用来保存当前的任务

function flushWork(initialTime){
    try {
        return workLoop(initialTime);
    } finally {
        currentTask = null;
    }
}

function workLoop(initialTime) {
    let currentTime = initialTime;
    // 取出队列中第一个任务,注意这里用的是peek, 不是pop,任务还在队列中
    currentTask = taskQueue.peek(); 
    while(currentTask !== null) {
        if(currentTask.expirationTime > currentTime && shouldYieldToHost()) {
            // 还没到过期时间且调度超过5ms了
            break;
        }
        // 可以调度任务了
        const callback = currentTask.callback;
        if (typeof callback === 'function') {
            currentTask.callback = null;
            const continuationCallback = callback(); // 执行真正的任务
            if (typeof continuationCallback === 'function') {
                // 注意这里很关键:如果我们的任务返回了一个函数,说明这个任务是一个分片任务
                // 执行完第一个分片任务后,不会去取下一个任务,而是给task.callback重新赋值,
                // 然后重新走while循环,执行下一个分片任务。每次执行分片任务前都要判断是否超过5ms了。
                // 超过就会break,等下一个事件循环再重新取出来执行。
                currentTask.callback = continuationCallback;
            } else {
                // 任务没有分片,执行完了就可以删除了
                if(currentTask === taskQueue.peek()) {
                    taskQueue.pop();
                }
            }
        } else {
            // 针对任务分片的情况,所有分片任务执行完了,
            // 意味着当前任务已经完成,可以删除
            taskQueue.pop();
        }
        currentTask = peek(taskQueue); // 取下一个任务执行
    }
    if(currentTask !== null) {
        // 还有任务,下个事件循环再处理
        return true;
    }
}

function shouldYieldToHost() { 
    // 是否应该挂起任务 
    const currentTime = performance.now(); 
    if(currentTime - startTime < frameYieldMs) { 
        return false; 
    } 
    return true; 
}

如果理解了workLoop的逻辑,你会发现其实我们已经实现了高优先级任务的插队功能了。需要注意的是,被调度的任务callback必须有良好的设计,它要么耗时很短,要么是一个分片任务,每个分片耗时很短。分片任务的设计关键在于,每次执行前使用shouldYieldToHost函数来判断任务调度是否已经达到5ms了,如果达到5ms,那么就return一个函数出去,scheduler会在下一个事件循环继续执行它。同时这个任务还必须记住执行的进度,否则每次都从头执行会陷入死循环。

这里解释一下高优先级任务插队的原理:

如果当前正在调度一个分片任务A(分为A1,A2,A3),正在执行A1的时候,调用scheduleCallback调度一个高优先级的任务B,任务会被push进任务队列(最小堆,根据优先级排序)。这个时候需要等A1执行完,判断超时了没,如果没超时,继续执行A2,A2执行完发现超时了,break出循环。进入下一个事件循环,重新进入workLoop函数中的时候,重新从任务队列中取任务,这时候取出来是任务B,B任务就实现插队了。等B执行完,继续取出A来执行,还是那句话,A任务要有正确的分片设计,取出来的时候会从A3开始执行。

经过分析我们知道,任务插队也不是随便插的,最快也要等到下一个事件循环才能执行。

到这里react scheduler最核心的功能都已经实现了。事实上在react源码中,不止有任务队列taskQueue,还有一个timerQueue,它是用来处理一些延时任务的。在某些场景下,当创建一个任务的时候,我们并不想马上加入到taskQueue中,而是要延迟一段时间再加入taskQueue,那么它就会先加入到timerQueue。实现这个的话会让scheduler的逻辑变得很复杂,影响我们掌握最核心的原理,所以就暂不实现了。