react Scheduler 实现示例教程

目录
  • 正文
  • 简单的css动画
    • etTimeout来实现
    • 循环处理
  • 具体思路

正文

最近在看react源码,react构建fiber树这一块逻辑还比较好理解,但是一旦涉及到任务调度相关的逻辑,看起来是一头雾水。在参考了一些资料和react scheduler源码后,我决定来实现一个简单版的scheduler,相信跟着本文的思路实现一遍,就可以理解为什么react需要有scheduler这个东西来调度任务。

简单的背景知识:

我们知道现在大部分设备的帧率都是60fps,也就是说浏览器每16.7ms会绘制一次。如果页面上有一些动画,那么16.7s绘制一次,看起来是比较流畅的。

简单的css动画

先来写一个简单的css动画:一个普通的div左右滑动

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        #block {
            width: 50px;
            height: 50px;
            margin: 0 0;
            background-color: #ddd;
            animation: move 5s linear infinite;
            position: absolute;
        }
        @keyframes move {
            0% {
                left: 0;
            }
            25% {
                left: 100px;
            }
            50% {
                left: 200px;
            }
            75% {
                left: 100px;
            }
            100% {
                left: 0;
            }
        }
    </style>
</head>
<body>
    <div id="block"></div>
</body>
</html>

使用谷歌浏览器的性能录制面板可以看到:

在主线程上,一帧的时间是16.7ms,我们放大看看一帧时间里面,浏览器做了什么:

完成一次绘制需要执行Schedule Style Recalculation, Recalculate Style, Layout, Pre-Paint, Paint, Composite Layers。这里我们不细究在每个阶段浏览器做了什么,只需要关注这个渲染是在主线程上进行,由CPU完成的就行了。通常每16.7ms浏览器会绘制一次,但是如果本轮事件循环有任务在执行,那么需要等任务执行完再进行绘制。如果任务耗时过长,绘制次数就会变少,也就是所谓“掉帧”。因为我们现在页面非常简单,没有js任务,所以浏览器每16.7ms绘制一次,动画看起来很流畅。

现在我们来加上一个按钮,点击之后会创建5个任务,每个任务耗时20ms,并且马上执行。

<body>
    <button id="btn">click me</button>
</body>

绑定事件:

const works = [];
const btn = document.getElementById('btn');
btn.onclick = function () {
    for (let i = 0; i < 5; i++) {
        works.push(macroTask)
    }
    flushWork();
}
function macroTask(){
    const start = new Date().getTime();
    while (new Date().getTime() - start < 20) {}
}
function flushWork(){
    while(works.length){
        const work = works.shift();
        work.call(null);
    }
}

点击按钮会发现,正在滑动的div卡顿了一下,通过下图可以看到,浏览器直到5个宏任务完成后才会执行渲染,在这段时间里面,页面不能更新,也不能响应用户操作。

etTimeout来实现

如果点击按钮要执行成千上百个任务,那么浏览器会卡死很长一段时间,这显然是不能接受的。最简单的改造方法是执行一个任务后,把后续的任务处理放到下一个事件循环,让浏览器可以在本轮事件循环执行绘制。精通浏览器原理的你肯定知道可以利用setTimeout来实现:

const works = [];
const btn = document.getElementById('btn');
btn.onclick = function () {
    for (let i = 0; i < 50; i++) {
        works.push(macroTask)
    }
    flushWork();
}
function macroTask(){
    const start = Date.now();
    while (Date.now() - start < 20) {}
}
function flushWork(){
    workLoop();
}
function workLoop(){
    const work = works.shift();
    if(work){
        work.call(null);
        // 只执行一个任务,后面的下个事件循环再处理
        setTimeout(workLoop, 0);
    }
}

打开控制台分析一下:

现在可以看到,现在每个宏任务都没有连在一起,它们在不同的事件循环里执行。每个任务完成后,浏览器都会执行一次绘制,就算要执行的任务非常多,动画也不会卡住不动了。

但是,仔细观察一下,后面的宏任务间隔好像都比较大,放大看间隔大概是4ms左右。我们现在一个任务的执行时间是20ms,超过了16.7ms,事实上页面已经有一点卡顿了。主线程资源这么紧张,每个事件循环居然还要浪费4ms,这肯定是不能接受的。很多人应该都听说过setTimeout的最小延时限制,大概意思就是虽然你是setTimeout零秒,实际上嵌套多层之后,至少要过4ms左右,宏任务才会进入到任务队列。

循环处理

setTimeout不能用了,有其他替代方案吗?答案是有的,我们可以使用MessageChannel来把任务放到宏任务队列。 MessageChannel的用法就不详细介绍了,简单地说,就是利用这个api,我们可以监听一个message事件,当事件触发的时候,事件处理函数这个任务会加入到宏任务队列。对应我们的例子,我们就可以绑定onmessage的时候执行workLoop, 在workLoop里面只执行一个任务,如果还有任务没有执行,那就postMessage,在下一个事件循环继续处理。

const channel = new MessageChannel();
const port2 = channel.port2;
const port1 = channel.port1;
port1.onmessage = workLoop;
const works = [];
const btn = document.getElementById('btn');
btn.onclick = function () {
    for (let i = 0; i < 50; i++) {
        works.push(macroTask)
    }
    flushWork();
}
function macroTask(){
    const start = Date.now();
    while (Date.now() - start < 20) {}
}
function flushWork(){
    workLoop();
}
function workLoop(){
    const work = works.shift();
    if(work){
        work.call(null);
        port2.postMessage(null);
    }
}

重新执行后再分析一下,宏任务之间基本没有间隔了:

目前我们的最小任务单元的执行时间是20ms。因为超过了16.7ms会导致页面变卡顿,所以实际上我们应该确保单个任务不能超过16.7ms。假设经过合理的设计,我们的最小任务单元执行时间不会超过2ms(这里随机设置成1ms或2ms)。然后再来看看点击按钮后执行1000个任务会怎么样。

const channel = new MessageChannel();
const port2 = channel.port2;
const port1 = channel.port1;
port1.onmessage = workLoop;
const works = [];
const btn = document.getElementById('btn');
btn.onclick = function () {
    for (let i = 0; i < 1000; i++) {
        works.push(macroTask)
    }
    flushWork();
}
function macroTask(){
    const time = [1, 2];
    const zeroOrOne = Math.round(Math.random());
    const start = Date.now();
    while (Date.now() - start < time[zeroOrOne]) {}
}
function flushWork(){
    workLoop();
}
function workLoop(){
    const work = works.shift();
    if(work){
        work.call(null);
        port2.postMessage(null);
    }
}

分析运行结果,可以看到现在浏览器绘制的帧率还是没有60fps,我们的任务占据主线程时间太长了。所以我们需要一种机制,使得在一帧的时间内尽可能执行多个任务,而且留有充足的时间给浏览器绘制页面和响应用户交互。

最终我们的设计方案是:在一个事件循环里面,我们只占用主线程5ms, 超过5ms就把主线程控制权交还给浏览器,在下一个事件循环处理任务。

具体思路

声明一个全局队列taskQueue存放任务;

声明一个全局变量startTime表示任务调度的开始时间, 当接受到onmessage事件时,获取当前时间赋值给startTime,然后开始调度任务;

调度任务:从taskQueue队列中取出一个任务,获取当前时间currentTime, 计算currentTime - startTime,如果大于或等于5ms,说明调度任务时长已经达到5ms了,break出循环,如果队列里还有任务,postMessage交出主线程控制权,等下个事件循环再调度任务。

浏览器绘制完页面,响应用户交互后,在下一个事件循环再次调度任务,重新计算currentTime,startTime,此时它们的差值一定不会超过5ms, 取出一个任务执行,然后更新currentTime。再次进入while循环,判断currentTime - startTime是否大于5ms, 大于5ms就交出控制权,否则继续执行下一个任务。

改造后的代码:

const channel = new MessageChannel();
const port2 = channel.port2;
const port1 = channel.port1;
port1.onmessage = performWorkUntilDeadline;
const taskQueue = [];
let startTime = -1;
const frameYieldMs = 5; // 任务的连续执行时间不能超过5ms
let currentTask = null; // 用来保存当前的任务
btn.onclick = function () {
    for (let i = 0; i < 1000; i++) {
        taskQueue.push(macroTask)
    }
    // 在下个事件循环开始调度任务
    port2.postMessage(null);
}
function performWorkUntilDeadline() {
    startTime = performance.now(); // 更新开始时间
    let hasMoreWork = true;
    try {
        hasMoreWork = flushWork();
    } finally {
        currentTask = null;
        if(hasMoreWork) {
            port2.postMessage(null);
        }
    }
}
function flushWork(){
    return workLoop();
}
function workLoop() {
    // 这里用currentTask全局变量来保存当前任务看起来似乎有点丑。
    // 其实是为了后续实现任务优先级和任务插队功能,先不管,就这么写。
    currentTask = taskQueue[0];
    while(currentTask) {
        if(shouldYieldToHost()) {
            break;
        }
        currentTask.call(null);
        taskQueue.shift(); // 执行完的任务从队列中删除
        currentTask = taskQueue[0]; // 继续拿下一个任务
    }
    if(currentTask) {
        // 还有任务需要在下个事件循环处理
        return true;
    }
}
function shouldYieldToHost() {
    // 是否应该挂起任务
    const currentTime = performance.now();
    if(currentTime - startTime < frameYieldMs) {
        return false;
    }
    return true;
}
function macroTask(){
    const time = [1, 2];
    const zeroOrOne = Math.round(Math.random());
    const start = performance.now();
    while (performance.now() - start < time[zeroOrOne]) {}
}

好了我们再看看运行结果:浏览器的帧率现在已经可以保持在60fps了,效果已经很不错了。但是目前我们的任务队列只是一个普通的先进先出队列,并没有实现优先级和任务插队功能。下一篇文章我们将继续跟着react的实现思路,用最小堆来实现优先队列。

以上就是react Scheduler 实现示例教程的详细内容,更多关于react Scheduler 教程的资料请关注我们其它相关文章!

(0)

相关推荐

  • react中的watch监视属性-useEffect介绍

    目录 react的watch监视属性-useEffect useEffect使用指南 最基本的使用 响应更新 如何处理Loading和Error 处理表单 自定义hooks 使用useReducer整合逻辑 取消数据请求 react的watch监视属性-useEffect 在vue中可以使用watch属性,去监视一个值,当这个值进行变化的时候就去执行一些操作.在react是没有这个属性的,但是它也一样可以达到相同的效果,那么接下来看看它是怎么实现的呢? 在react中实现监听效果有一个比较简单的

  • React的特征单向数据流学习

    目录 正文 状态 => 视图 事件 => 状态改变 => 视图 正文 React推荐one-way单向数据流,注意只是推荐,并不强制,常见有以下两种情况: 状态 => 视图 事件 => 状态改变 => 视图 状态 => 视图 import React from 'react' const App = () => { //设置状态 const [data, setData] = React.useState('状态 => 视图') return ( &l

  • React项目中使用Redux的 react-redux

    目录 背景 UI 组件 容器组件 connect() mapStateToProps() mapDispatchToProps() 组件 实例:计数器 背景 在前面文章一文理解Redux及其工作原理中,我们了解到redux是用于数据状态管理,而react是一个视图层面的库 如果将两者连接在一起,可以使用官方推荐react-redux库,其具有高效且灵活的特性 react-redux将组件分成: 容器组件:存在逻辑处理 UI 组件:只负责现显示和交互,内部不处理逻辑,状态由外部控制 通过redux

  • react-router-dom v6 使用详细示例

    目录 一.基本使用 二.路由跳转 2.1 Link 组件 2.2 NavLink 组件 2.3 编程式跳转 三.动态路由参数 3.1 路径参数 路径匹配规则 兼容类组件 3.2 search 参数 四.嵌套路由 5.1 路由定义 5.2 在父组件中展示 5.3 在组件中定义 五.默认路由 六.全匹配路由 七.多组路由 八.路由重定向 九.布局路由 十.订阅和操作 history stack的原理 10.1 History对象 11.2 Location对象 state key 十一. 各类Rou

  • React如何接收excel文件下载导出功能封装

    目录 React接收excel文件下载导出功能封装 react导出excel文件的几种方式 1.原生js导出 (带样式) 2.使用xlsx导出(此方法导出的excel文件无样式,但导出的文件格式是 xlsx格式) 3.使用 js-export-excel (可以导出多张sheet表) 4.第四种 使用react-html-table-to-excel   不推荐使用 React接收excel文件下载导出功能封装 因为最近项目又需求要导出excel,所以封装了这部分的功能,对fetch的封装做了修

  • react Scheduler 实现示例教程

    目录 正文 简单的css动画 etTimeout来实现 循环处理 具体思路 正文 最近在看react源码,react构建fiber树这一块逻辑还比较好理解,但是一旦涉及到任务调度相关的逻辑,看起来是一头雾水.在参考了一些资料和react scheduler源码后,我决定来实现一个简单版的scheduler,相信跟着本文的思路实现一遍,就可以理解为什么react需要有scheduler这个东西来调度任务. 简单的背景知识: 我们知道现在大部分设备的帧率都是60fps,也就是说浏览器每16.7ms会

  • react context优化四重奏教程示例

    目录 一.前言 二.用法 三.缺点 四.context优化 一重奏--使用PureComponent 二重奏--使用shouldComponentUpdate 三重奏--使用React.memo 四重奏--Provider再封装+props.children 总结 一.前言 我们在使用react的过程中,经常会遇到需要跨层级传递数据的情况.props传递数据应用在这种场景下会极度繁琐,且不利于维护,于是context应运而生 官方解释: Context 提供了一种在组件之间共享此类值的方式,而不

  • React Redux应用示例详解

    目录 一 React-Redux的应用 1.学习文档 2.Redux的需求 3.什么是Redux 4.什么情况下需要使用redux 二.最新React-Redux 的流程 安装Redux Toolkit 创建一个 React Redux 应用 基础示例 Redux Toolkit 示例 三.使用教程 安装Redux Toolkit和React-Redux​ 创建 Redux Store​ 为React提供Redux Store​ 创建Redux State Slice​ 将Slice Reduc

  • 详解Java 10 var关键字和示例教程

    关键要点 Java 10引入了一个闪亮的新功能:局部变量类型推断.对于局部变量,现在可以使用特殊的保留类型名称"var"代替实际类型. 提供这个特性是为了增强Java语言,并将类型推断扩展到局部变量的声明上.这样可以减少板代码,同时仍然保留Java的编译时类型检查. 由于编译器需要通过检查赋值等式右侧(RHS)来推断var的实际类型,因此在某些情况下,这个特性具有局限性,例如在初始化Array和Stream的时候. 如何使用新的"var"来减少样板代码. 在本文中,

  • C# 多进程打开PPT的示例教程

    1.背景 PPT文件打开和操作是在一个进程中进行的,如果对多个PPT进行操作,PowerPoint进程默认会以阻塞的方式依次进行,如果打开的PPT特别大(比如超过1GB)很容易造成PPT无响应,这样几乎所有的PPT操作都无法进行. 解决PPT无响应的一种方式是定时检测PPT进程(POWERPNT.exe)是否无响应,如果无响应就将POWERPNT.exe进程Kill掉,重新打开PPT.这种方式并不能解决需要多个PPT操作的问题,如果多个PPT文件都很大,操作多个PPT会频繁出现PPT无响应的情况

  • react hooks入门详细教程

    State Hooks 案例: import { useState } from 'react'; function Example() { const [count, setCount] = useState(0); //count:声明的变量:setCount:改变count值的函数:0:count的初始值 return ( <div> <p>You clicked {count} times</p> <button onClick={() => set

  • java开发SpringBoot参数校验过程示例教程

    目录 为什么需要参数校验 SpringBoot中集成参数校验 第一步,引入依赖 第二步,定义要参数校验的实体类 常见的约束注解如下: 第三步,定义校验类进行测试 第四步,体验效果 自定义参数校验 第一步,创建自定义注解 第二步,自定义校验逻辑 第三步,在字段上增加注解 第四步,体验效果 分组校验 第一步:定义分组接口 第二步,在模型中给参数分配分组 第三步,给需要参数校验的方法指定分组 第四步,体验效果 小结 大家好,我是飘渺. 前几天写了一篇SpringBoot如何统一后端返回格式?老鸟们都是

  • Java程序中Doc文档注释示例教程

    目录 Doc注释规范 @符号的用处 如何生成Doc文档 第一个:Dos命令生成 第二个:IDE工具生成 许多人写代码时总不喜欢写注释,每个程序员如此,嘿嘿,我也一样 不过,话说回来,该写还是要写哦!没人会喜欢一个不写注释的程序员,当然,也没有一个喜欢写注释的程序员,今天,我们就来说说Java注释之一--Doc注释 我们知道,Java支持 3 种注释,分别是单行注释.多行注释和文档注释,我们来看看他们的样子 //单行注释   /* 多行注释 */   /** *@... *.... *文档注释 *

  • 分位数回归模型quantile regeression应用详解及示例教程

    目录 什么是分位数? 什么是分位数回归? statsmodels中的分位数回归 分位数回归与线性回归 xgboost的分位数回归 普通最小二乘法如何处理异常值? 它对待一切事物都是一样的--它将它们平方! 但是对于异常值,平方会显著增加它们对平均值等统计数据的巨大影响. 我们从描述性统计中知道,中位数对异常值的鲁棒性比均值强. 这种理论也可以在预测统计中为我们服务,这正是分位数回归的意义所在--估计中位数(或其他分位数)而不是平均值. 通过选择任何特定的分位数阈值,我们既可以缓和异常值,也可以调

  • TensorFlow神经网络构造线性回归模型示例教程

    先制作一些数据: import numpy as np import tensorflow as tf import matplotlib.pyplot as plt # 随机生成1000个点,围绕在y=0.1x+0.3的直线周围 num_points = 1000 vectors_set = [] for i in range(num_points): x1 = np.random.normal(0.0, 0.55) # np.random.normal(mean,stdev,size)给出均

随机推荐