当前位置: 首页 > news >正文

React 的调度系统 Scheduler

大家好,我是前端西瓜哥。今天来学习 React 的调度系统 Scheduler。

React 版本为 18.2.0

React 使用了全新的 Fiber 架构,将原本需要一次性递归找出所有的改变,并一次性更新真实 DOM 的流程,改成通过时间分片,先分成一个个小的异步任务在空闲时间找出改变,最后一次性更新 DOM。

这里需要使用调度器,在浏览器空闲的时候去做这些异步小任务。

Scheduler

做这个调度工作的在 React 中叫做 Scheduler(调度器)模块。

其实浏览器是提供一个 requestIdleCallback 的方法,让我们可以在浏览器空闲的时去调用传入去的回调函数。但因为兼容性不好,给的优先级可能太低,执行是在渲染帧执行等缺点。

所以 React 实现了 requestIdleCallback 的替代方案,也就是这个 Scheduler。它的底层是 基于 MessageChannel 的。

为什么是 MessageChannel?

选择 MessageChannel 的原因,是首先异步得是个宏任务,因为宏任务中会在下次事件循环中执行,不会阻塞当前页面的更新。MessageChannel 是一个宏任务。

没选常见的 setTimeout,是因为MessageChannel 能较快执行,在 0~1ms 内触发,像 setTimeout 即便设置 timeout 为 0 还是需要 4~5ms。相同时间下,MessageChannel 能够完成更多的任务。

若浏览器不支持 MessageChannel,还是得降级为 setTimeout。

其实如果 setImmediate 存在的话,会优先使用 setImmediate,但它只在少量环境(比如 IE 的低版本、Node.js)中存在。

逻辑是在 packages/scheduler/src/forks/Scheduler.js 中实现的:

// Capture local references to native APIs, in case a polyfill overrides them.
const localSetTimeout = typeof setTimeout === 'function' ? setTimeout : null;
const localClearTimeout =
  typeof clearTimeout === 'function' ? clearTimeout : null;
const localSetImmediate =
  typeof setImmediate !== 'undefined' ? setImmediate : null; // IE and Node.js + jsdom

/***** 异步选择策略 *****/
// 【1】 优先使用 setImmediate
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} 
// 【2】 然后是 MessageChannel
else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} 
// 【3】 最后是 setTimeout(兜底)
else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

另外,也没有选择使用 requestAnimationFrame,是因为它的机制比较特别,是在更新页面前执行,但更新页面的时机并没有规定,执行时机并不稳定。

底层的异步循环

requestHostCallback 方法,用于请求宿主(指浏览器)去执行函数。该方法会将传入的函数保存起来到 scheduledHostCallback 上,

然后调用 schedulePerformWorkUntilDeadline 方法。

schedulePerformWorkUntilDeadline 方法一调用,就停不下来了。

它会异步调用 performWorkUntilDeadline,后者又调用回 schedulePerformWorkUntilDeadline,最终实现 不断地异步循环执行 performWorkUntilDeadline

// 请求宿主(指浏览器)执行函数
function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

isMessageLoopRunning 是一个 flag,表示是否正在走循环。防止同一时间调用多次 schedulePerformWorkUntilDeadline。

React 会调度 workLoopSync / workLoopConcurrent

我们在 React 项目启动后,执行一个更新操作,会调用 ensureRootIsScheduled 方法。

function ensureRootIsScheduled(root, currentTime) {
  // 最高优先级
  if (newCallbackPriority === SyncLane) {
    // Special case: Sync React callbacks are scheduled on a special
    // internal queue
    if (root.tag === LegacyRoot) {
      // Legacy Mode,即 ReactDOM.render() 启用的同步模式
      scheduleLegacySyncCallback(performSyncWorkOnRoot.bind(null, root));
    } else {
      scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
    }
    // 立即执行优先级,去清空需要同步执行的任务
    scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
  } else {
    // 初始化 schedulerPriorityLevel 并计算出 Scheduler 支持的优先级值
    let schedulerPriorityLevel;
    // ...
    
    scheduleCallback(
      schedulerPriorityLevel, 
      performConcurrentWorkOnRoot.bind(null, root), // 并发模式
    );
  }
}

该方法有很多分支,最终会根据条件调用:

  1. performSyncWorkOnRoot(立即执行)
  2. performConcurrentWorkOnRoot(并发执行,且会用 scheduler 的 scheduleCallback 进行异步调用)

performSyncWorkOnRoot 最终会执行重要的 workLoopSync 方法:

// 调用链路:
// performSyncWorkOnRoot -> renderRootSync -> workLoopSync
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

workInProgress 表示一个需要进行处理的 FiberNode。

performUnitOfWork 方法用于处理一个 workInProgress,进行调和操作,计算出新的 fiberNode。

同样,performConcurrentWorkOnRoot 最终会执行重要的 workLoopConcurrent 方法。

// 调用链路:
// performConcurrentWorkOnRoot -> performConcurrentWorkOnRoot -> renderRootConcurrent
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

和 workLoopSync 很相似,但循环条件里多了一个来自 Scheduler 的 shouldYield() 决定是否将进程让出给浏览器,这样就能做到中断 Fiber 的调和阶段,做到时间分片。

scheduleCallback

上面的 workLoopSync 和 workLoopConcurrent 都是通过 scheduleCallback 去调度的。

scheduleCallback 方法传入优先级 priorityLevel、需要指定的回调函数 callback ,以及一个可选项 options。

scheduleCallback 的实现如下(做了简化):

function unstable_scheduleCallback(priorityLevel, callback, options) {
  var currentTime = getCurrentTime();

  var startTime;
  if (options?.delay) {
    startTime = currentTime + options.delay;
  }
  // 有效期时长,根据优先级设置。
  var timeout;
  // ...
  // 计算出 过期时间点
  var expirationTime = startTime + timeout;

  // 创建一个任务
  var newTask = {
    id: taskIdCounter++,
    callback, // 这个就是任务本身
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  
  // 说明新任务是加了 option.delay 的任务,需要延迟执行
  // 我们会放到未逾期队列(timerQueue)中
  if (startTime > currentTime) {
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    // 没有需要逾期的任务,且优先级最高的未逾期任务就是这个新任务
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // 那,用 setTimeout 延迟 options.delay 执行 handleTimeout
    	requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } 
  // 立即执行的任务,加入到逾期队列(taskQueue)
  else {
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
 
    // Schedule a host callback, if needed. If we're already performing work,
    // wait until the next time we yield.
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }
}

push / peek / pop 这些是 scheduler 提供的操作 优先级队列 的操作方法。

优先级队列的底层实现是小顶堆,实现原理不展开讲。我们只需要记住优先级队列的特性:就是出队的时候,会取优先级最高的任务。在 scheduler 中,sortIndex 最小的任务的优先级最高

push(queue, task) 表示入队,加一个新任务;peek(queue) 表示得到最高优先级(不出队);pop(queue) 表示将最高优先级任务出队。

taskQueue 为逾期的任务队列,需要赶紧执行。新生成的任务(没有设置 options.delay)会放到 taskQueue,并以 expirationTime 作为优先级(sortIndex)来比较。

timerQueue 是还没逾期的任务队列,以 startTime 作为优先级来比较。如果逾期了,就会 取出放到 taskQueue 里。

handleTimeout

// 如果没有逾期的任务,且优先级最高的未逾期任务就是这个新任务
// 延迟执行 handleTimeout
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
  requestHostTimeout(handleTimeout, startTime - currentTime);
}

requestHostTimeout 其实就是 setTimeout 定时器的简单封装,在 newTask 过期的时间点(startTime - currentTime 后)执行 handleTimeout。

function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false;
  advanceTimers(currentTime); // 更新 timerQueue 和 taskQueue

  if (!isHostCallbackScheduled) {
    if (peek(taskQueue) !== null) { // 有要执行的逾期任务
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork); // 清空 taskQueue 任务
    } else { // 没有逾期任务
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) { // 但有未逾期任务,用 setTimeout 晚点再调用自己
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

handleTimeout 下会调用 advanceTimers 方法,根据当前时间要将 timerTask 中逾期的任务搬到 taskQueue 下。

(advanceTimers 这个方法会在多个位置被调用。搬一搬,更健康)

搬完后,看看 taskQueue 有没有任务要做,有的话就调用 flushWork 清空 taskQueue 任务。没有的话看看有没有未逾期任务,用定时器在它过期的时间点再递归执行 handleTimeout。

workLoop

flushWork 会 调用 workLoop。flushWork 还需要做一些额外的修改模块文件变量的操作。

function flushWork(hasTimeRemaining, initialTime) {
  // ...
	return workLoop(hasTimeRemaining, initialTime); 
}

workLoop 会不停地从 taskQueue 取出任务来执行。其核心逻辑为:

function workLoop(hasTimeRemaining, initialTime) {
  // 更新 taskQueue,并取出一个任务
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);

  while (currentTask !== null) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // This currentTask hasn't expired, and we've reached the deadline.
      break;
    }
    // 执行任务
    const callback = currentTask.callback;
    callback();
 
    // 更新 taskQueue,并取出一个任务
    currentTime = getCurrentTime();
    advanceTimers(currentTime);
    currentTask = peek(taskQueue);
  }
  return currentTask !== null;
}

shouldYieldToHost

上面的循环并不是一直会执行到 currentTask 为 null 为止,在必要的时候还是会跳出的。我们是通过 shouldYieldToHost 方法判断是否要跳出。

此外,Fiber 异步更新的 workLoopConcurrent 方法用到的 shouldYield,其实就是这个 shouldYieldToHost。

shouldYieldToHost 核心实现:

const frameYieldMs = 5;
var frameInterval = frameYieldMs;

function shouldYieldToHost() {
  var timeElapsed = getCurrentTime() - startTime;
  // 经过的时间小于 5 ms,不需要让出进程
  if (timeElapsed < frameInterval) {
    return false;
  }
  return true;
}

export {
  // 会重命名为 unstable_shouldYield 导出
  shouldYieldToHost as unstable_shouldYield,
}

计算经过的时间,如果小于帧间隔时间(frameInterval,通常为 5ms),不需要让出进程,否则让出。

startTime 是模块文件的最外层变量,会在 performWorkUntilDeadline 方法中赋值,也就是任务开始调度的时候。

流程图

试着画一下 Scheduler 的调度流程图。

image-20221204101845850

结尾

Scheduler 一套下来还是挺复杂的。

首先是 Scheduler 底层大多数情况下会使用 MessageChannel,作为循环执行异步任务的能力。通过它来不断地执行任务队列中的任务。

任务队列是特殊的优先级队列,特性是出队时,拿到优先级最高的任务(在 Scheduler 中对比的是 sortIndex,值是一个时间戳)。

任务队列在 Scheduler 中有两种。一种是逾期任务 taskQueue,需要赶紧执行,另一种是延期任务 timerQueue,还不到时间执行。Scheduler 会根据当前时间,将逾期的 timerQueue 任务放到 taskQueue 中,然后从 taskQueue 取出优先级最高的任务去执行。

Scheduler 向外暴露 scheduleCallback 方法,该方法接受一个优先级和一个函数(就是任务),对于 React 来说,它通常是 workLoopSync 或 workLoopConcurrent。

scheduleCallback 会设置新任务的过期时间(根据优先级),并判断是否为延时任务(根据 options.delay)决定放入哪个任务队列中。然后启用循环执行异步任务,不断地清空执行 taskQueue。

Scheduler 也向外暴露了 shouldYield,通过它可以知道是否执行时间过长,应该让出进程给浏览器。该方法同时也在 Scheduler 内部的循环执行异步任务中作为一种打断循环的判断条件。

React 的并发模式下,可以用它作为暂停调和阶段的依据。

我是前端西瓜哥,欢迎关注我,学习更多前端知识。

相关文章:

  • .NET 桌面软件内存泄露分析
  • 【校招VIP】【约起来】java引言:java校招对项目的要求
  • 20221207英语学习
  • 数据聚合——DSLRestAPI
  • IBM SPSS Modeler分类决策树C5.0模型分析空气污染物数据
  • 软件测试工程师,如何工资过万?(经验之谈)
  • TextMeshPro源码移植-替换掉PackageManager
  • 【校招VIP】[推电影项目]产品的规划和商业化分析
  • chatGPT与世界杯的故事:人工智能的双重面对
  • 信号相角位移量的计算与信号位移计算-附Matlab代码
  • 小红书店铺需不需要开?在小红书开店需要注意什么
  • 通过图卷积网络从单词语义学习到句子句法,用于基于方面的情感分析
  • Springboot你写我评小说同好交流网站hp7zh计算机毕业设计-课程设计-期末作业-毕设程序代做
  • GitHub上架即巅峰!《Spring Cloud微服务架构实战》标星已超30k
  • ADI的DSP开发软件:CCES软件的使用说明(很早期写的一点心得)
  • Webpack完整打包流程分析
  • ubuntu 20.04 设置DNS
  • 小型扫雪机结构设计
  • Day17 | 每天五道题
  • 无人机边缘计算中的计算卸载——Stackelberg博弈方法论文复现附matlab代码
  • myBaits Target Capture Kits;myBaits 靶向捕获试剂盒,快速捕获富集目标序列
  • 【表格单元格可编辑】vue-elementul简单实现table表格点击单元格可编辑,点击单元格变成输入框修改数据
  • ES7-ES13 新特性
  • 【C++】打开C++的大门
  • 【深度学习】U-Net和FCN具体分析
  • Linux下 git 上传与删除 的基本指令
  • Swift 新 async/await 同步机制小技巧:消除“多余”的 await 关键字
  • Github如何使用详细介绍(保姆级教学)
  • 【ardunio+sx1268】与【esp32+sx1268】实现不同主控单片机lora通讯
  • Linux常用命令——pvscan命令
  • 旅游管理专业学什么 难就业吗
  • 2022海南高考体育专业考试时间 什么时候考试
  • 云南楚雄高考时间2021具体时间:6月7日
  • 高考什么特长可以加分 政策有哪些
  • 2022年贵州高考218分能报什么大学 218分能上哪些院校
  • 2022大专石油化工专业毕业后待遇 工资高吗
  • 中国科学院大学2021年各省录取分数线及专业分数线
  • 学英语用点读笔好不好 有效果吗
  • 2021河南职业技术学院学费多少 各专业收费标准
  • 2022广西外语口试成绩查询时间公布 什么时候查分