React源码系列之任务调度机制Scheduler

一、Scheduler概述

1、Scheduler

Scheduler是一个独立的包,它可以独自承担起任务调度的职责;它与React组成了一个任务调度系统;

React是任务产生的地方;Scheduler是任务的调度者;

Scheduler和React相互配合的模式可以让React的任务执行具备异步可中断的特点;

2、Scheduler的优先级及时间片

(1)优先级

Scheduler拥有属于自己的优先级,并且会根据这些优先级设置超时时间timeout;

按照任务自身的紧急程度排序,保证高优先级的任务优先执行;

JavaScript 复制代码
export const NoPriority = 0; // 没有优先级 
export const ImmediatePriority = 1; // 立即执行的优先级,级别最高 
export const UserBlockingPriority = 2; // 用户阻塞级别的优先级 
export const NormalPriority = 3; // 正常优先级 
export const LowPriority = 4; // 较低优先级 
export const IdlePriority = 5; // 优先级最低,表示任务闲置

(2)timeout设置

Scheduler会根据startTime与timeout设置任务的过期时间expirationTime;

JavaScript 复制代码
var maxSigned31BitInt = 1073741823; 
var IMMEDIATE_PRIORITY_TIMEOUT = -1; // 立即执行的任务 
var USER_BLOCKING_PRIORITY_TIMEOUT = 250; // 用户操作的任务 
var NORMAL_PRIORITY_TIMEOUT = 5000; 
var LOW_PRIORITY_TIMEOUT = 10000; 
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; // 空闲任务

(3)时间切片

时间片规定的是单个任务在这一帧内最大的执行时间,任务执行时间一旦超出时间片的限制,就会被中断;可以保证页面不会因为任务连续执行导致时间过长产生卡顿;

具体做法是:

将VDOM的执行过程拆分成一个个独立的宏任务,将每个宏任务的执行时间限制在一定范围内,初始为5ms;即:将一个会造成掉帧的长任务拆解为多个不会掉帧的短宏任务,以减少掉帧的可能性,这一技术被称为时间切片。

3、Scheduler调度任务的模式

在浏览器中,JS引擎线程与UI渲染线程是互斥的,Scheduler不会一直占用线程去执行任务,而是执行一会,中断一下,继续执行,避免一直占用资源执行任务,解决用户操作时页面卡顿的问题;

Scheduler在执行多个任务时,会按照任务的优先级去执行,保证高优先级的任务优先执行;

4、Scheduler的职责

Scheduler主要负责管理任务、安排任务的执行;不过Scheduler只是去做任务的中断和恢复,任务是否完成依赖于React;

Scheduler执行任务具备的特点:根据时间片去中止任务,并判断任务是否完成,未完成则继续调用任务函数。

Scheduler的核心任务就是回调,回调函数由react-reconciler提供,回调函数名称:performConcurrentWorkOnRoot

concurrent模式才支持时间分片;React v18默认开启了该模式;

5、Scheduler中任务的管理

(1)任务管理队列

  • timerQueue:存放未过期的任务
  • taskQueue:存放已过期的任务

(2)如何判断任务是否过期?

用任务的开始时间startTime和当前时间currentTime对比:

  • 开始时间 > 当前时间,说明未过期,放入到timerQueue队列;
  • 开始时间 <= 当前时间,说明已过期,放入到taskQueue队列;

(3)不同队列中的任务如何排序?

依据任务的紧急程度排序,只不过依据的参数不一样;

  • timerQueue:
    • startTime:开始时间
      • 开始时间越早,说明任务越早开始,开始时间小的排在前边;
      • 任务进来时,默认是当前时间,如果进入调度时,传入延迟时间,开始时间 = 当前时间 + 延迟时间;
  • taskQueue:
    • expirationTime:任务过期时间
      • 过期时间越早,任务越紧急,排在前边;
      • 过期时间根据任务优先级计算得出,优先级越高,过期时间越早;【任务优先级是计算任务过期时间的重要依据,事关过期任务在taskQueue中的排序】

(4)任务的处理

  • taskQueue队列
    • 会立即调度一个函数去循环taskQueue,挨个执行里边的任务;
  • timerQueue队列
    • 该任务不会立即执行,需要调用handleTimeout检查该队列中的第一个任务是否过期
      • 过期,任务从timerQueue中删除,添加到taskQueue中;
      • 不过期,继续检查等待第一个任务过期,循环以上操作;

6、Scheduler中的角色分配

  • 调度入口:scheduleCallback
    • 该函数的调用参数:scheduleCallback( schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root), );
    • performConcurrentWorkOnRoot函数就是我们需要根据优先级分类的任务
  • 开始调度的函数:requestHostCallback
  • 执行任务的函数:workloop(关键逻辑所在)「功能:循环taskQueue执行任务 和 任务状态的判断」
  • 调度者:
    • 非浏览器环境是setTimeout;
    • 浏览器环境是:MessageChannel实例的port.postMessage;
  • 执行者:调用时期的任务执行函数;
    • 执行者:performWorkUntilDeadline
    • 作用:按照时间片的限制去中断任务,并通知调度者再次调度一个新的执行者去继续执行任务;

7、Scheduler工作流程小结

Scheduler管理着taskQueue与timerQueue两个队列,他会定期调用advanceTimers将timerQueue中的过期任务放到taskQueue中,然后让调度者通知执行者循环taskQueue执行掉每一个任务。

执行者控制着每个任务的执行,一旦某个任务的执行时间超出时间片的限制,就会被中断,然后当前的执行者退场,退场之前会通知调度者再去调度一个新的执行者继续完成这个任务,新的执行者在执行任务时依旧会根据时间片中断任务,然后退场,重复这一过程,直到当前这个任务彻底执行完成后,将任务从taskQueue中出队。

taskQueue中的每一个任务都这样被处理,最终完成所有任务。

二、任务的中断及恢复

1、单个任务的中断及恢复:

在循环taskQueue执行每一个任务时,如果某个任务的执行时间过长,达到了时间片限制的时间,该任务必须中断,以便于让位给更重要的事情,像浏览器的绘制等,等事情完成,再恢复任务的执行。

单个任务的完成只能依赖任务函数的返回值去判断;

2、任务是否完成的判断

开始调度后,调度者调度执行者去执行任务;如果执行者判断callback返回值为一个function,说明未完成,会将function再次赋值给callback;

当前任务不会从taskQueue中删除,因为任务还没有执行结束,下次循环时,取到这个任务,继续执行;

3、任务的中止

currentTask表示当前正在执行的任务,它中止的判断条件是:任务并未过期,但已经没有剩余时间了,需要让出执行权给主线程;

由于它只是被中止,currentTask的取值不是null,return一个true表示任务还没执行结束(这是恢复任务的关键),否则说明全部的任务已经执行完成,taskQueue队列被清空,return一个false表示终止本次调度。

flushWork实际上是scheduledHostCallback,当performWorkUntilDeadline检测到scheduledHostCallback的返回值为false,就会停止调度;


JavaScript 复制代码
// 任务的中止判断条件:任务并未过期,但是已经没有剩余时间;或者需要让出执行权给主线程(时间片的限制)
if (
    currentTask.expirationTime > currentTime && 
    ( !hasTimeRemaining || shouldYieldToHost() )
) {
    // 退出本次循环,后边的逻辑无法执行 
    // 这是任务中断的关键 
    /* ----- 任务只是被中止,还没执行结束 ------ */ 
    break; 
}

三、Scheduler源码分析

1、unstable_scheduleCallback

(1)函数作用

  • 处理任务过期时间;
  • 根据过期时间给任务分类;
  • 进入任务调度流程;

(2)任务过期时间计算

JavaScript 复制代码
// 获取当前时间:它是计算任务开始时间、过期时间和判断任务是否过期的依据 
var currentTime = getCurrentTime(); 
// 计算任务开始时间 
var startTime; 
if (typeof options === 'object' && options !== null) { 
    var delay = options.delay; 
    if (typeof delay === 'number' && delay > 0) { 
        startTime = currentTime + delay; // 传入延迟时间 
    } else { 
        startTime = currentTime;
    } 
} else { 
    startTime = currentTime; 
} 
// 根据优先级计算一个延迟时间 
var timeout; 
switch (priotiryLevel) { 
    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; 
} 
// 计算任务过期时间 
var expirationTime = startTime + timeout;

(3)Scheduler中的任务结构

JavaScript 复制代码
var newTask = { 
    id: taskIdCounter++, callback, // 任务函数:真正的任务函数,外部传入【执行结果会影响到任务完成状态的判断】 
    priorityLevel, // 任务优先级:参与计算任务过期时间 
    startTime, // 任务开始时间:影响timerQueue中的排序 
    expirationTime, // 任务过期时间:影响taskQueue中的排序 
    sortIndex: -1 // 在小顶堆中排序的依据:在区分好任务是过期还是非过期之后,sortIndex会被赋值为expirationTime或startTime 
};

(4)任务分组:根据是否过期【startTime > currentTime】

JavaScript 复制代码
if (startTime > currentTime) { // 任务未过期,以开始时间作为timerQueue排序的依据 
    newTask.sortIndex = startTime; 
    push(timerQueue, newTask); // 任务未过期,将newTask放入timerQueue 
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) { 
        /** 
        * 如果taskQueue中没有任务,且当前任务是timerQueue中排名最靠前的那一个 
        * 需要检查timerQueue中有没有需要放到taskQueue中的任务, 
        * 这一步通过调用requestHostTimeout实现 
        */ 
        if (isHostTimeoutScheduled) { // 即将调度一个requestHostTimeout。如果已经调度,则取消掉 
            cancelHostTimeout(); 
        } else { 
            isHostTimeoutScheduled = true; 
        } 
        // 调用requestHostTimeout实现任务的转移,开启调度 
        requestHostTimeout(handleTimeout, startTime - currentTime); 
    } 
} else { 
    // 任务已经过期,以过期时间作为taskQueue排序的依据 
    newTask.sortIndex = expirationTime; 
    push(taskQueue, newTask); // 将任务添加到taskQueue队列中 
    // 开始执行任务,使用flushWork去执行taskQueue 
    if (!isHostCallbackScheduled && !isPerformingWork) { 
        isHostCallbackScheduled = true; // 进入任务调度流程 
        // 任务的执行者本质上调用的就是flushWork 
        // flushWork:真正执行任务的函数,会循环taskQueue 
        requestHostCallback(); 
    } 
}

2、任务的调度

(1)requestHostCallback

进入任务调度流程的函数,接收一个参数:flushWork;

flushWork:任务的执行者本质上调用的就是flushWork,它是真正执行任务的函数;

  • 实现:该函数的实现区分浏览器环境和非浏览器环境;
    • 非浏览器环境不存在帧的概念,也就不会有时间片,可以使用setTimeout实现;
    • 浏览器环境下,任务的执行会有时间片的限制,使用MessageChannel实现;
JavaScript 复制代码
function requestHostCallback( // 该处的callback实际上就是传入的flushWork函数 
    callback: (hasTimeRemaining: boolean, initialTime: number) => boolean 
) { 
    scheduledHostCallback = callback; 
    if (!isMessageLoopRunning) { 
        isMessageLoopRunning = true; 
        schedulePerformWorkUntilDeadline(); 
    } 
}

(2)flushWork

JavaScript 复制代码
function flushWork(hasTimeRemaining: boolean, initialTime: number) { 
    isHostCallbackScheduled = false; 
    if (isHostTimeoutScheduled) { 
        isHostTimeoutScheduled = false; 
        cancelHostTimeout(); 
    } 
    isPerformingWork = true;
    const previousPriorityLevel = currentPriorityLevel; 
    // 循环 
    try { 
        return workLoop(hasTimeRemaining, initialTime); 
    } finally { 
        currentTask = null; 
        currentPriorityLevel = previousPriorityLevel; 
        isPerformingWork = false; 
    } 
}

(3)workLoop

该函数是任务执行的核心内容:实现任务的中断与恢复;

该函数的执行结果会被flushWork函数return出去;

JavaScript 复制代码
// 做了什么:循环taskQueue执行任务 任务状态的判断 
// 单个任务完成状态的判断:workLoop依赖任务函数的返回值去判断任务是否完成 
// 如任务返回值为函数,说明任务尚未完成,需要继续调用任务函数,否则任务完成 
function workLoop(hasTimeRemaining, initialTime) { 
    let currentTime = initialTime; // 开始执行前,检查timerQueue中的过期任务;并放入taskQueue中 
    advanceTimers(currentTime); 
    currentTask = peek(taskQueue); // 获取taskQueue中第一个要执行(最紧急)的任务 
    // 循环taskQueue,执行任务
    while (
        currentTask !== null && 
        !( enableSchedulerDebugging && isSchedulerPaused )
    ) { 
        // 任务的中止判断条件:任务并未过期,但是已经没有剩余时间;或者需要让出执行权给主线程(时间片的限制) 
        if (
            currentTask.expirationTime > currentTime 
            && ( !hasTimeRemaining || shouldYieldToHost() )
        ) { 
            // 退出本次循环,后边的逻辑无法执行 
            // 这是任务中断的关键
            /* ----- 任务只是被中止,还没执行结束 ------ */ 
            break; 
        }
        /* -----------------------执行任务------------------------- */ 
        const callback = currentTask.callback; // 获取任务的执行函数 
        if (typeof callback === 'function') { 
            // 如果callback是函数类型,表示当前有任务,可以执行 
            currentTask.callback = null; 
            // 获取任务的优先级 
            currentPriorityLevel = currentTask.priorityLevel; 
            // 判断任务是否过期 
            const didUserCallbackTimeout = currentTask.expirationTime <= currentTime; 
            // 获取任务函数的执行结果 
            const continuationCallback = callback(didUserCallbackTimeout); 
            currentTime = getCurrentTime(); 
            if (typeof continuationCallback === 'function') { 
                // 判断continuationCallback是否为函数,为函数,则将函数作为当前任务新的回调; 
                currentTask.callback = continuationCallback; 
                // 检查timerQueue中的过期任务,并放入taskQueue 
                advanceTimers(currentTime); 
                // 恢复任务的关键 
                return true; 
            } else { 
                if (currentTask === peek(taskQueue)) { 
                    pop(taskQueue);
                } 
                advanceTimers(currentTime); 
            } 
        } else { 
            // callback为null,任务出队 
            // 取消调度的关键是:将当前任务的callback设置为null; 
            // 因为:在workLoop中,callback为null就会被移出taskQueue,当前任务就不再被执行 
            pop(taskQueue);
        }
        // 从taskQueue中继续获取任务,如果上一个任务未完成,该任务不会从taskQueue中删除 
        // 获取的currentTask还是该任务,继续执行该任务 
        currentTask = peek(taskQueue); 
    } 
    // return的结果会作为performWorkUntilDeadline中判断是否还需要再次发起调取的依据 
    if (currentTask !== null) { 
        return true; 
    } else { 
        // 任务完成,去timerQueue中找优先级最高的任务调度requestHostTimeout
        // 目的:将其放入taskQueue中等待调度 
        const firstTimer = peek(timerQueue); 
        if (firstTimer !== null) {
            requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); 
        } 
        // 让外部终止本次调度 
        return false;
        } 
    }

(4)advanceTimers

JavaScript 复制代码
function advanceTimers(currentTime: number) { 
    // timerQueue队列中的任务不会执行,只是会每隔一段时间判断其是否过期 
    let timer = peek(timerQueue); // 获取timerQueue中优先级最高的任务 
    while(timer !== null) {
        if (timer.callback === null) { 
            // callback:真正要执行的任务
            pop(timerQueue);
        } else if (timer.startTime <= currentTime) { 
            // 任务开始时间小于当前时间,表示任务已经过期 
            pop(timerQueue); // 从timerQueue中删除该任务 
            timer.sortIndex = timer.expirationTime; // 设置排序依据
            push(taskQueue, timer); // 将任务添加到taskQueue队列中,等待执行
        } else { 
            return; 
        }
        timer = peek(timerQueue);
    }
}

(5)requestHostTimeout

JavaScript 复制代码
function requestHostTimeout( 
    callback: (currentTime: number) => void,
    ms: number 
) { 
    taskTimeoutID = localSetTimeout(() => { 
        callback(getCurrentTime());
    }, ms); 
}

(6)handleTimeout

  • 作用:检查timerQueue队列中的过期任务,并且添加到taskQueue中并执行;
  • 实现:
    • 调用advanceTimers,检查timerQueue队列中的过期任务,添加到taskQueue中;
    • 检查是否已经开始调度,若未调度,则检查taskQueue中是否已经有任务
      • 如果有,且空闲,立即开始调度,并执行任务;
      • 如果没有,且空闲,调用requestHostTime检查是否有过期任务
JavaScript 复制代码
function handleTimeout(currentTime: number) { 
    isHostTimeoutScheduled = false; 
    advanceTimers(currentTime); 
    if (!isHostCallbackScheduled) { 
        if (peek(taskQueue) !== null) { 
            isHostCallbackScheduled = true; 
            requestHostCallback(flushWork); 
        } else { 
            const firstTimer = peek(timerQueue);
            if (firstTimer !== null) { 
                    requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); 
            } 
        }
    }
}

(7)performWorkUntilDeadline

JavaScript 复制代码
// 任务的执行:执行者 
// 按照时间片的限制去中断任务,并通知调度者再次调度一个新的执行者去继续任务 
const performWorkUntilDeadline = () => { 
    if (scheduledHostCallback !== null) { 
        const currentTime = getCurrentTime(); 
        startTime = currentTime; 
        // 表示任务是否还有剩余时间,与时间片一起限制任务的执行;
        // 如果没有时间,或者任务执行时间超出时间片限制,就中断任务 
        // postMessage的执行很靠前,中断任务就只看时间片 
        const hasTimeRemaining = true; 
        let hasMoreWork = true; 
        try { 
            // scheduledHostCallback:执行任务 
            // 任务因为时间片被打断,返回true;表示还有任务执行,调度者会再调度一个执行者继续执行任务 
            hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime); 
        } finally { 
            if (hasMoreWork) { 
                // 有任务,调度者调度执行者,继续完成任务 
                schedulePerformWorkUntilDeadline();
            } else { 
                // 没有任务,则停止调度
                isMessageLoopRunning = false;
                scheduledHostCallback = null;
            }
        }
    } else { 
        isMessageLoopRunning = false; 
    }
    needPaint = false; 
}

(8)时间片的实现

MessageChannel在浏览器事件循环中属于宏任务,在调度中心属于异步调度;

JavaScript 复制代码
// 时间分片浏览器实现 
const channel = new MessageChannel(); 
const port = channel.port2; 
channel.port1.onmessage = performWorkUntilDeadline;
let schedulePerformWorkUntilDeadline = () => { 
    port.postMessage(null); 
};

(9)unstable_cancelCallback

JavaScript 复制代码
// 取消任务调度 
// 在workLoop中,callback设置为null,就会被移出taskQueue,那么当前任务就不再被执行; 
// 它只是取消当前任务的执行,while循环还会继续执行下一个任务 
function unstable_cancelCallback(task) { 
    task.callback = null; 
}
相关推荐
TonyH20023 小时前
webpack 4 的 30 个步骤构建 react 开发环境
前端·css·react.js·webpack·postcss·打包
掘金泥石流5 小时前
React v19 的 React Complier 是如何优化 React 组件的,看 AI 是如何回答的
javascript·人工智能·react.js
lucifer3117 小时前
深入解析 React 组件封装 —— 从业务需求到性能优化
前端·react.js
秃头女孩y11 小时前
React基础-快速梳理
前端·react.js·前端框架
sophie旭14 小时前
我要拿捏 react 系列二: React 架构设计
javascript·react.js·前端框架
BHDDGT1 天前
react-问卷星项目(5)
前端·javascript·react.js
liangshanbo12151 天前
将 Intersection Observer 与自定义 React Hook 结合使用
前端·react.js·前端框架
黄毛火烧雪下1 天前
React返回上一个页面,会重新挂载吗
前端·javascript·react.js
BHDDGT2 天前
react-问卷星项目(4)
前端·javascript·react.js
xiaokanfuchen862 天前
React中Hooks使用
前端·javascript·react.js