【React】任务调度scheduler的实现(一)

1,128 阅读5分钟

实现时间分片调度

知识储备

  • MessageChannel

    1. MessageChannel允许我们创建一个消息通道,有两个端口,可以互相通话,类似于2个人打电话。

    2. postMessage进行发送消息,onmessage进行接受消息。

    3. MessageChannel是一个宏任务

    4. 示例

      // 消息管道
      let messageChannel = new MessageChannel();
      let port1 = messageChannel.port1;
      let port2 = messageChannel.port2;
      
      port1.onmessage = (event) => {
          console.log('port1.onmessage', event.data);
      }
      
      port2.onmessage = (event) => {
          console.log('port2.onmessage', event.data);
      }
      
      port1.postMessage('port1发的消息');
      port2.postMessage('port2发的消息');
      

WechatIMG2567.jpeg

  • performance Performance接口可以获取到当前页面中与性能相关的信息。你可以去MDN查看详细它的详细的属性及方法。

实现基本的任务调度

WechatIMG2569.jpeg

上图是本次编程需要用到的文件目录。

  1. 首先我们在index.js里写一个计算任务calculate函数。在calculate里利用for循环去模拟一个大任务。
let result = 0;
let i = 0;
function calculate() {
    for (; i < 1000000; i++) {
        result += 1;
    }
    console.log(result);
}
  1. 现在把calculate委托给一个任务调度器帮我们执行,仿照react源码新建一个scheduler的文件夹,在scheduler的文件夹下,新建一个index.js文件,导入这个模块的所有导出并导出。
export * from './src/Scheduler';
  1. 在scheduler文件夹下新建文件夹src,并建好文件Scheduler.js,Scheduler.js就是我们react当中的调度模块。
/**
 * 调度一个回调任务
 * @param {*} callback 
 */
function scheduleCallback (callback) {
    callback();
}

export {
    scheduleCallback
}
  1. 利用scheduleCallback对我们calculate函数进行调度回调。
import { scheduleCallback } from './scheduler';
let result = 0;
let i = 0;
function calculate() {
    for (; i < 1000000; i++) {
        result += 1;
    }
    console.log(result);
}

scheduleCallback(calculate);
  1. 执行我们的代码,我们可以在浏览器中看到,任务被标红,calculate这个函数执行的时间特别的长。calculate长时间去占据我们的主线程,就会导致我们的页面卡顿。

WechatIMG2570.jpeg

  1. 为了解决任务长时间占用主线程的问题,我们可以实现一个时间切片。将任务的执行时间切成多个时间片,每个帧最大执行时间为5ms,如果任务执行超过时间片,则会任务暂停执行,等下一帧的时间再执行。

  2. 修改一下,我们的calculate函数,在条件循环里加入shouldYield函数用于控制是否中断任务,并修改calculate函数未执行完返回函数本身,执行完返回空。

import { 
    scheduleCallback, // 调度回调,计划执行回调
    shouldYield, // 应该放弃执行权/中断
 } from './scheduler';
let result = 0;
let i = 0;
/**
 * 要想能过方便的让任务能过 暂停和恢复,需要数据结构支持
 * @returns 
 */
function calculate() {
    // shouldYield如果任务没有结束,并且浏览器分配的时间片(一般是4ms)已经到期了,就会放弃本任务的执行,
    // 把线程的资源交还给浏览器,让浏览器执行更高优先级的工作,比如页面绘制,响应用户输入
    for (; i < 1000000 && (!shouldYield()); i++) {
        result += 1;
    }
    // 当推出本任务的时候,如果任务没有完成,返回任务函数本身,如果任务完成了就返回null
    if (i < 1000000) {
        return calculate;
    } else {
        console.log(result);
        return null;
    }
}

scheduleCallback(calculate);
  1. 改写Scheduler.js,由于react是跨平台的,所有Scheduler会适配很多种不同的环境,此处我们只写浏览器相关的,在src下新建一个SchedulerHostConfig.js文件。在Scheduler模块中从SchedulerHostConfig引入requestHostCallback,以及shouldYieldToHost,将shouldYieldToHost作为别名shouldYield导出。同时将我们scheduleCallback的callback交给我们的requestHostCallback函数。
import { 
    requestHostCallback,
    shouldYieldToHost as shouldYield
} from './SchedulerHostConfig';
/**
 * 调度一个回调任务
 * @param {*} callback 
 */
function scheduleCallback (callback) {
    requestHostCallback(callback);
}

export {
    scheduleCallback,
    shouldYield
}
  1. 编写SchedulerHostConfig.js文件,定义变量scheduledHostCallback用来存储将要被调度的函数,同时创建一个消息管道messageChannel,在messageChannel的port1收到消息时,去执行performWorkUntilDeadline【注:该函数的定义为执行工作直到截止时间】函数,定义变量deadline用来记录每一帧中任务截止的时间,定义yieldInterval变量代表在每一帧中我有5毫秒的时间去执行回调函数【注:一帧大概有16.6ms,一般来说浏览器执行重要的工作大概需要10ms,我们在剩余的6.6ms中取一个合理的值】。定义一个获取当前帧时间的函数getCurrentTime,利用performance api的now方法返回得到当前帧时间。编写请求调度requestHostCallback函数,接受一个回调函数callback,将callback函数赋于scheduledHostCallback变量,然后通过消息管道messageChannel,从port2发出消息通知执行performWorkUntilDeadline函数。编写performWorkUntilDeadline函数,首先需要获取到我们当前帧的时间currentTime,然后再计算出我们任务执行结束的时间deadline,最后获取到我们scheduledHostCallback函数是否执行完【注:之前改写的calculate函数,执行完了返回null,没执行完返回函数本身】,如果没有执行完,我们就继续在下一帧去执行,通过messageChannel的port2去通知port1。之前我们改写calculate函数在for条件判断中,增加了一个shouldYield函数用于我们判断是否终止函数的执行,现编写shouldYieldToHost函数,通过对比当前时间与截止时间即可判断。
// 将要被调度的回调函数
let scheduledHostCallback = null;
// 创建消息管道
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = performWorkUntilDeadline;
// 截止时间
let deadline = 0;
// 每一帧我会申请5毫秒
let yieldInterval = 5;
function getCurrentTime() {
    return performance.now();
}
/**
 * 执行工作直到截止时间
 */
function performWorkUntilDeadline() {
    // 获取当前的时间
    const currentTime = getCurrentTime();
    // 计算截止时间:当前时间➕申请时间 等于截止时间
    deadline = currentTime + yieldInterval;
    // 需要知道这个 被调度的回调函数 有没有执行完;
    const hasMoreWork = scheduledHostCallback();
    // 如果hasMoreWork为true,说明工作没干完,就被打断放弃了,后面还得继续干,
    // 它会让浏览器再添加一个宏任务performWorkUntilDeadline,会在下一帧开始的执行
    if (hasMoreWork) {
        messageChannel.port2.postMessage(null);
    }
}
export function requestHostCallback(callback) {
    scheduledHostCallback = callback;
    // 一旦port2发消息了,会向宏任务队列中添加一个宏任务,执行port1.onmessage方法;
    // 告诉浏览器在下一帧执行preforWorkUntilDeadline
    messageChannel.port2.postMessage(null);
}

/**
 * 是否应该放弃执行权/中断
 * @returns boolean
 */
export function shouldYieldToHost() {
    // 获取当前的时间
    const currentTime = getCurrentTime();
    // 如果当前时间大于截止时间了,说明到期了,时间片已经用完了,需要返回true,放弃执行任务
    return currentTime >= deadline;
}

具体的工作流程,我们可以看如下流程图:

1647435412377-132e4f25-2b7e-412c-8f56-994b50c049f5.png

1647488427416-5c3c0a35-487c-44fa-9f2c-696549e26194.png 可以看到此时我们的calculate函数已经被切成了很多段执行了。现阶段我们就实现了一个基本的时间分片调度。

  1. MessageChannel是个宏任务有点像我们的requestAnimationFrame,那为什么不用requestAnimationFrame呢?用过改写代码用requestAnimationFrame替代MessageChannel,我们可以从性能分析看到requestAnimationFrame的触发间隔时间不确定,如果浏览器间隔了 10ms 才更新页面,那么这 10ms 就浪费了。
/**
 * 执行工作直到截止时间
 */
function performWorkUntilDeadline() {
    // 获取当前的时间
    const currentTime = getCurrentTime();
    // 计算截止时间:当前时间➕申请时间 等于截止时间
    deadline = currentTime + yieldInterval;
    // 需要知道这个 被调度的回调函数 有没有执行完;
    const hasMoreWork = scheduledHostCallback();
    // 如果hasMoreWork为true,说明工作没干完,就被打断放弃了,后面还得继续干,
    // 它会让浏览器再添加一个宏任务performWorkUntilDeadline,会在下一帧开始的执行
    if (hasMoreWork) {
        // messageChannel.port2.postMessage(null);
        requestAnimationFrame(performWorkUntilDeadline)
    }
}
export function requestHostCallback(callback) {
    scheduledHostCallback = callback;
    // 一旦port2发消息了,会向宏任务队列中添加一个宏任务,执行port1.onmessage方法;
    // 告诉浏览器在下一帧执行preforWorkUntilDeadline
    // messageChannel.port2.postMessage(null);
    requestAnimationFrame(performWorkUntilDeadline);
}

1647497309304-31d2b31c-6e43-46b6-aa0e-dbc0c1581696.png

实现同时调度多个任务

  1. 现在我们再写一个calculate2函数
import { 
    scheduleCallback, // 调度回调,计划执行回调
    shouldYield, // 应该放弃执行权/中断
 } from './scheduler';
 let result = 0, result2 = 0;
 let i = 0, i2 = 0;
/**
 * 要想能过方便的让任务能过 暂停和恢复,需要数据结构支持
 * @returns 
 */
function calculate() {
    // shouldYield如果任务没有结束,并且浏览器分配的时间片(一般是4ms)已经到期了,就会放弃本任务的执行,
    // 把线程的资源交还给浏览器,让浏览器执行更高优先级的工作,比如页面绘制,响应用户输入
    for (; i < 1000000 && (!shouldYield()); i++) {
        result += 1;
    }
    // 当推出本任务的时候,如果任务没有完成,返回任务函数本身,如果任务完成了就返回null
    if (i < 1000000) {
        return calculate;
    } else {
        console.log(result);
        return null;
    }
}
function calculate2() {
    for (; i2 < 2000000 && (!shouldYield()); i2++) {
        result2 += 1;
    }
    // 当推出本任务的时候,如果任务没有完成,返回任务函数本身,如果任务完成了就返回null
    if (i2 < 2000000) {
        return calculate2;
    } else {
        console.log(result2);
        return null;
    }
}

scheduleCallback(calculate);
scheduleCallback(calculate2);

在浏览器里执行我们会发现,浏览器打印了2次2000000,我们理想的是输出一个1000000和2000000,之所以输出两次2000000,是因为我们的scheduledHostCallback是一个全局变量,第一次的回调函数被第二次的回调函数覆盖了。

  1. 那我们如果想要支持多个任务的话,需要怎么做呢?我们可以让我们的任务调度器进行排队,利用队列去实现。在Scheduler.js模块中,定义一个队列taskQueue,用变量currentTask来表示当前任务。在SchedulerCallback函数中,将我们的回调函数callback放到我们的队列taskQueue中。然后调度一个清理刷新工作flushWork函数,flushWork会去依次执行我们队列中的任务,返回一个工作循环workLoop。在工作循环中,我们每次都会取出队头的任务,如果任务存在我们会进入循环,然后去获取当前任务返回值,如果任务执行完毕,则数组的第一个任务出队,继续取队头的任务,至到时间片到期或者是当前任务不存在。
import { 
    requestHostCallback,
    shouldYieldToHost as shouldYield
} from './SchedulerHostConfig';
// 为了同时调度多个任务,而不会互相覆盖,需要搞一个任务队列
let taskQueue = [];
// 当前的任务
let currentTask;
/**
 * 调度一个回调任务
 * @param {*} callback 
 */
function scheduleCallback (callback) {
    taskQueue.push(callback);
    requestHostCallback(flushWork);
}
/**
 * 依次执行任务队列中的任务
 */
function flushWork() {
    return workLoop();
}
/**
 * 在这里有两个打断或者停止执行
 * 在执行每一个任务的时候,如果时间片到期了会退出workLoop
 * 另一个是在执行currentTask的时候,如果时间片到期了,也会退出执行
 * @returns 
 */
function workLoop() {
    // 取出任务队列中的第一个任务
    currentTask = taskQueue[0];
    while(currentTask) {
        // 如果说时间片到期了,就退出循环
        if (shouldYield()) {
            break;
        }
        // 继续执行回调
        const continuationCallback = currentTask();
        // 如果为function说明任务没结束,当前任务还为之前的。
        if (typeof continuationCallback === 'function') {
            currentTask = continuationCallback;
        } else {
            // 移除最先进队的回调函数
            taskQueue.shift();
        }
        currentTask = taskQueue[0];
    }
    return currentTask;
}

export {
    scheduleCallback,
    shouldYield
}

((M{FNV[NWK]T@FL4PI6Y.png