React 的调度系统 Scheduler

news2024/11/15 10:15:55

大家好,我是前端西瓜哥。今天来学习 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 的并发模式下,可以用它作为暂停调和阶段的依据。

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/69536.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

nnUnet测试

https://github.com/MIC-DKFZ/nnUNet nnUnet要在Windows上跑起来有点麻烦&#xff0c;主要是项目路径的问题&#xff0c;我目前测试了2分类遥感数据&#xff08;其实只要是二分类都行&#xff0c;无所谓什么数据&#xff09;&#xff0c;我这里说难是因为我没有安装&#xff0…

【SQL】MVCC 多版本并发控制

MVCC多版本并发控制快照读与当前读隔离级别隐藏字段&#xff0c;undo log 版本链隐藏字段trx_id版本链read view举例说明read committed&#xff08;读已提交&#xff09;隔离级别下repeatable read&#xff08;可重复读&#xff09;隔离级别下innodb如何解决幻读总结并发问题的…

LaTex使用技巧9:argmin / argmax下标写法

记录两种写法 1.arg⁡max⁡θ\mathop{\arg\max}\limits_{\theta}θargmax​的写法 写法1&#xff1a; $\mathop{\arg\max}\limits_{\theta}$ 写法2&#xff1a; $\sideset{}{}{\arg\max}_{\theta}^{} $ 2.arg⁡min⁡θ\mathop{\arg\min}\limits_{\theta}θargmin​的写法 写法…

STL常用生成算法和集合算法(20221207)

STL的常用算法 概述&#xff1a; 算法主要是由头文件<algorithm> <functional> <numeric> 组成。 <algorithm>是所有STL头文件中最大的一个&#xff0c;涉及比较、交换、查找、遍历等等&#xff1b; <functional>定义了一些模板类&#xff0…

做一个公司网站大概要多少钱?

做一个公司网站大概要多少钱&#xff0c;很多公司在做网站之前可能已经简单了解过费用&#xff0c;但是费用差距都会比较大&#xff0c;为什么的呢&#xff0c;因为一般都是受到制作方式因素的影响。下面给大家说说不同的方式做一个公司网站大概要多少钱。 一、自己/团队做公司…

SQLyog —— 图形化工具使用

SQLyog下载链接&#xff1a; 点击跳转 在这一篇内容MySQL数据库 —— 常用语句当中讲到关于MySQL数据库命令的基本使用&#xff0c;这一篇是关于SQLyog数据库图形化工具的内容&#xff0c;先进行安装演示后在通过SQLyog进行操作数据库&#xff1a; SQLyog 安装 下载完成之后双击…

pageoffice在线打开word文件加盖电子印章

一、加盖印章的 js 方法 js方法 二、常见使用场景 1、常规盖章。弹出用户名、密码输入框&#xff0c;选择对应印章。 点击盖章按钮弹出用户名密码登录框&#xff0c;登录以后显示选择电子印章。 document.getElementById("PageOfficeCtrl1").ZoomSeal.AddSeal(…

Python模块pathlib操作文件和目录操作总结

前言 目前大家常用的对于文件和操作的操作使用 os.path 较多&#xff0c;比如 获取当前路径os.getcwd()&#xff0c;判断文件路径是否存在os.path.exists(folder) 等等。 在Python3.4开始&#xff0c;官方提供了 pathlib 面向对象的文件系统路径&#xff0c;核心的点在于 面向…

chatGPT代码写的有点好啊,程序员要失业了?

AI神器ChatGPT 火了。 能直接生成代码、会自动修复bug、在线问诊、模仿莎士比亚风格写作……各种话题都能hold住&#xff0c;它就是OpenAI刚刚推出的——ChatGPT。 有脑洞大开的网友甚至用它来设计游戏&#xff1a;先用ChatGPT生成游戏设定&#xff0c;再用Midjourney出图&…

element-plus elplus el-tree三种图标自定义 并且点击图标展开收起 点击文字获取数据

前言 公司需求,需要实现如下样式的树形列表 (基于vue3 element-plus) 当节点展开时,显示展开的文件夹图标,当节点收起时显示收起的文件夹,最后一级显示文件样式 废话没有了, 代码如下 <!-- 树形列表组件 --> <template><div class"tree-input" v-i…

Vue学习:回顾Object.defineProperty(给对象添加或者定义属性的)

<script>//定义对象let person{name:李四,sex:"男"}Object.defineProperty(person,age,{value:18});//参数:添加属性的对象 添加的属性名 配置项console.log(person)</script> 颜色不同&#xff1a;说明了age不可以枚举age属性不参与遍历 Object.keys(…

电脑屏幕录制怎么弄?电脑上怎么录制屏幕, 3个实用方法

对于日常办公的小伙伴来说&#xff0c;电脑、键盘、鼠标等办公设备都是不可分割的。事实上&#xff0c;不仅仅是在日常办公&#xff0c;在很多业余的活动中&#xff0c;也会使用到电脑设备。在使用电脑的时候&#xff0c;会经常有需要录制电脑屏幕的情况&#xff0c;比如记录会…

阿里云Linux热扩容云盘(growpart和resize2fs工具)

阿里云linux机器系统盘空间不够进行扩容 一、扩容物理盘 阿里云控制台在线扩容完成 二、安装growpart工具和resize2fs工具 [rootA ~]# yum install cloud-utils-growpart [rootA ~]# yum install xfsprogs 三、检查扩容磁盘属性 1、检查云盘大小 /dev/vda1显示容量为20G(在线…

Properties类的使用

Properties类是一个配置文件类&#xff0c;主要作用就是用来封装配置文件&#xff0c;将配置文件加载成为一个Properties对象。 注意&#xff1a;Properties类一般用来加载 .properties配置文件 首先看一下.properties配置文件的样子 driverClassNamecom.mysql.cj.jdbc.Drive…

电力系统潮流【牛顿-拉夫逊法】(4节点、5节点、6节点、9节点)(Matlab代码实现)

目录 1 概述 2 电力系统潮流计算概述 2.1 电力潮流发展进程 2.2牛顿拉夫逊法潮流计算 3 仿真结果 4 Matlab代码及文章讲解 &#x1f4cb;&#x1f4cb;&#x1f4cb;本文目录如下&#xff1a;⛳️⛳️⛳️ ​ 1 概述 最初&#xff0c;电力系统潮流计算是通过人工手算的。后…

Java内存区域与内存分配策略

java很聪明&#xff0c;它将手动改为自动&#xff0c;把内存的控制权交给了虚拟机&#xff0c;下面我们就来探究一下JVM是怎么进行自动内存管理的。 手动内存管理分为两部分&#xff1a;给对象分配内存和回收分配给对象的内存。 一、运行时数据区域 线程公有 在运行时数据区中…

基础入门 - SpringBoot 底层注解

目录 1、SpringBoot特点 1.1、依赖管理 1.2、自动配置 2、容器功能 2.1、组件添加 1、Configuration Spring Boot 在底层 Configuration 的两个配置 2、Import 3、Conditional 2.2、原生配置文件引入 1、ImportResource 2.3、配置绑定 1、ConfigurationProperties …

前端开发踩坑笔记(2022-11)

文章目录1、Mac上SourceTree更新已删除的远端分支和tag2、echarts x轴文字显示不全&#xff08;解决方案&#xff09;3、如何渲染多行多列的表格&#xff08;非固定的行数和列数&#xff09;4、umy-ui标题过长或内容过长时的处理5、dateRange的时间选择只能选择一个周6、如何将…

Servlet API 详解

目录 一、HttpServlet ① init() 方法 ② service() 方法 ③ destroy() 方法 ④ doGet()方法 ⑤ doPost()方法 ⑥ doPut/deDelete/doOptions 常见面试题&#xff1a; 请你谈谈Servlet的生命周期 二、Http请求&#xff1a;HttpServletRequest 1. 获取请求行信息 2. 获…

一个redux使用案例模板

目录 redux 纯函数和高阶函数&#xff1a; redux 开发工具使用 react-redux redux 1. 结构&#xff1a; count--index.jsx import React, { Component } from react import store from ../../redux/store import { acDecrement,acIncrement,acAsyncIncrement } from ../..…