React Scheduler机制及Demo

162 阅读5分钟

主要理解React Scheduler的 逻辑/设计 思路,了解Scheduler的作用是什么,如何运行的?

React源码版本:v16

作用

React引入的Fiber概念,将React的所有操作都化为Fiber”任务“,而所有”任务“何时执行,则靠Scheduler来确定。

运行路径

此模块主要为Scheduler的逻辑思路,不涉及理解Scheduler主要功能的case不会过多赘述,也可以理解为这是一个”字典“,带着你 ”阅读“ Scheduler的源码。 学会工具、思想,然后将其应用到工作中的各种场景。

  1. 初始化:当组件的状态发生变更时,会通过 scheduleWork “初始化” Scheduler,准备开始新的一轮工作。不同组件的发起初始化位置不一,但基本都在数据更新完,准备开始渲染的阶段(Hooks组件在dispatchAction最后,ClassComponents在classComponentUpdater的setState之后)。

  2. 开始工作:scheduleWork 内先判断当前的状态,假如没有在工作 isWorking ,则通过调用 requestWork 开始新的一轮工作,从 root 节点开始。

  3. 任务调度:Scheduler 的主要任务是在浏览器空闲时执行任务,这里就涉及到两个点,任务封装、循环调度、空闲判定

    1. scheduleCallbackWithExpirationTime 中,会通过 scheduleDeferredCallback 来封装Fiber的更新计算:performAsyncWork 为调度器中的任务。
    2. scheduleDeferredCallback 可以看到是来自于scheduler/src/Scheduler.jsunstable_scheduleCallback:根据任务的优先级计算失效时间expirationTime 并封装成一个任务节点taskNode,插入到当前的任务链表 TaskQueue里,通过ensureHostCallbackIsScheduled 启动整个TaskQueue的工作。
    3. taskNode 的内容:参照上面可以看到就是performAsyncWork,而performAsyncWork会通过workLoop 不断循环,不断处理单个Fiber节点为nextUnitOfWork,当js繁忙则断开workLoop ,并将未完成的Fiber更新计算任务封装成新的callback更新任务丢给TaskQueue。而恢复执行状态则是scheduler会在下次js不繁忙时,重新触发TaskQueue的执行。 所以需要额外了解的是,Scheduler的终端,一般指的js繁忙则断开workLoop,繁忙判定nextUnitOfWork !== null && !shouldYieldToRenderer()
    4. 如上,可以看到主要判定是否停下workLoop主要靠shouldYieldToRenderer,而shouldYieldToRenderer主要原理,则是判断帧是否已经超时。超时了,则认为js繁忙并停下执行中的任务链。

[重点] 循环调度机制

这里对 Scheduler 的循环调度再额外展开讲讲,这才是Scheduler的重点

以前React是通过requestIdleCallback来实现,现在可以看到React是基于MessageChannel+rAF来实现自行实现的。

为何需要requestIdleCallbackMessageChannel ?

  1. 在浏览器繁忙断开了workLoop后,需要将主线程还给浏览器(这就是不使用微任务的原因)。
  2. 在下次浏览器空闲时,重新恢复任务执行。这俩api都是开启一个宏任务,这就保证了下一次浏览器空闲时一定会执行。

那为何弃用requestIdleCallback

  1. 兼容性问题:大部分浏览器都得到几乎很新的版本才会有该api
  2. 极端情况下不够用:因为requestIdleCallback 的帧率上限问题,空闲期帧率会定为20fps,这不够React要求的最低30fps(w3c.github.io/requestidle…

为何不使用setTimeout来开启宏任务?

setTimeout只作为React的备用方案,即没有MessageChannel 的时候会使用setTimeout代替,原因便是轮询setTimeout 时timeout会延迟3~4ms,估计是因为浏览器实现上的问题,并没有找到相关的规范说明。

为何还有rAF,它做了什么?

rAF会注册animationTick——用于更新帧时间的回调,并不涉及到任务的执行或恢复。animationTick会计算出当前帧执行后的页面时间戳frameDeadline = rafTime + activeFrameTime; 这便是用于判定在React规定的帧率内,当前任务执行后是否超时,超时则需要先暂时中断任务执行,等待页面

总结

总结下Scheduler调度机制的实现思路:

  1. 恢复执行:通过MessageChannel 注册宏任务,恢复TaskQueue执行。
  2. 停止执行:通过rAF计算当前帧率判断是否超时,假如当前帧已超时frameDeadline - currentTime <= 0,则didTimout=true ,停止后续任务。
  3. 链式执行: ensureHostCallbackIsScheduled 开启链式执行,将flusWork这个不断出栈TaskQueue的函数包给requestHostCallback,而requestHostCallback就是上面1、2点的恢复/停止任务的回调内容。

Scheduler Demo

基于总结的实现思路,我们也可以实现一个简易的调度器。

// 定义帧率和每帧的时间
const frameRate = 30;
const frameLength = 1000 / frameRate;

// 定义任务队列
const taskQueue = [];

// 定义消息通道和消息处理器
const channel = new MessageChannel();
const port = channel.port2;

channel.port1.onmessage = function () {
    if (taskQueue.length === 0) {
        return;
    }

    // 获取当前时间
    let currentTime = performance.now();

    // 计算下一帧的结束时间
    const frameDeadline = currentTime + frameLength;

    // 一直执行任务,直到任务队列为空或超出帧时长
    while (taskQueue.length > 0 && currentTime < frameDeadline) {
        const task = taskQueue.shift();
        task();

        // 更新当前时间
        currentTime = performance.now();
    }

    console.log('test: stop or end')

    // 如果还有任务,继续请求执行
    if (taskQueue.length > 0) {
        port.postMessage(null);
    }
};

// 定义任务调度函数
function scheduleTask(task) {
    // 将任务添加到队列
    taskQueue.push(task);

    // 请求在下一帧执行任务
    port.postMessage(null);
}

// 定义动画帧回调
function frameCallback(time) {
    // 如果有任务,请求执行
    if (taskQueue.length > 0) {
        port.postMessage(null);
    }

    // 在下一帧继续检查
    requestAnimationFrame(frameCallback);
}

// 启动动画帧回调
requestAnimationFrame(frameCallback);
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport"
        content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <h1 id="test-h1">Hello world!</h1>
</body>
<script src="./scheduler.js"></script>
<script>
    window.onload = function () {
        // 使用示例:
        function increamText(index) {
            console.log('test: update ', index)
            const el = document.getElementById("test-h1")
            if (el) {
                el.innerText = 'hello world! index = ' + index + '!'
            }
        }

        // 观察器的配置(需要观察什么变动)
        const config = { attributes: true, childList: true, subtree: true, characterData: true };

        // 当观察到变动时执行的回调函数
        const callback = function (mutationsList, observer) {
            // 使用传入的mutationsList参数进行MutationRecord对象遍历
            for (let mutation of mutationsList) {
                // 如果是字符数据变动
                console.log('test: observer: ', mutation?.addedNodes?.[0]?.data);
            }
        };

        // 创建一个观察器实例并传入回调函数
        const observer = new MutationObserver(callback);

        // 以上述配置开始观察目标节点
        const el = document.getElementById("test-h1")
        observer.observe(el, config);

        setTimeout(() => {
            new Array(3000).fill(1).forEach((_, index) => {
                scheduleTask(() => {
                    increamText(index)
                });
            })
        }, 1000)

    }
</script>

</html>

上述代码执行时,可以清晰看到,在打印了几百次test: update后,就会出现一次test: stop ,并且执行对应update回调的test: observer

这便对应了React Scheduler的几个阶段:

  1. 执行任务:test: update
  2. 执行时,等到frameDeadline,则中断任务:test: stop
  3. 交出主线程,让页面正常渲染:test: observer
  4. 渲染后浏览器重新进入空闲期,则回到阶段1