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

【React Scheduler源码第一篇】哪些API适合用于任务调度

欢迎关注我的Github一起学习前端各种框架的源码。掘金的文章只是仓库中的一部分,如果对源码感兴趣,可以直接关注github,github里面的文章是最新的

本章是手写 React Scheduler 源码系列的第一篇文章,第二篇查看Scheduler 基础用法详解

学习目标

了解屏幕刷新率,下面这些 API 的基础用法及执行时机。从浏览器 Performance 面板中看每一帧的执行时间以及工作。探索哪些 API 适合用来调度任务

  • requestAnimationFrame
  • requestIdleCallback
  • setTimeout
  • MessageChannel
  • 微任务
    • MutationObserver
    • Promise

屏幕刷新率

  • 目前大多数设备的屏幕刷新率为 60 次/秒
  • 页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿
  • 每帧的预算时间是 16.66 毫秒(1 秒/60),因此在写代码时,注意避免一帧的工作量超过 16ms。在每一帧内,浏览器都会执行以下操作:
    • 执行宏任务、用户事件等。
    • 执行 requestAnimationFrame
    • 执行样式计算、布局和绘制。
    • 如果还有空闲时间,则执行 requestIdelCallback
    • 如果某个任务执行时间过长,则当前帧不会绘制,会造成掉帧的现象。
  • 显卡会在每一帧开始时间给浏览器发送一个 vSync 标记符,从而让浏览器刷新频率和屏幕的刷新频率保持同步。

以下面的例子为例:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Frame</title>
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
    />
    <style>
      #animation {
        width: 30px;
        height: 30px;
        background: red;
        animation: myfirst 5s infinite;
      }
      @keyframes myfirst {
        from {
          width: 30px;
          height: 30px;
          border-radius: 0;
          background: red;
        }
        to {
          width: 300px;
          height: 300px;
          border-radius: 50%;
          background: yellow;
        }
      }
    </style>
  </head>
  <body>
    <div id="animation">test</div>
  </body>
  <script>
    function rafCallback(timestamp) {
      window.requestAnimationFrame(rafCallback);
    }
    window.requestAnimationFrame(rafCallback);

    function timeoutCallback() {
      setTimeout(timeoutCallback, 0);
    }
    setTimeout(timeoutCallback, 0);

    const timeout = 1000;
    requestIdleCallback(workLoop, { timeout });
    function workLoop(deadline) {
      requestIdleCallback(workLoop, { timeout });
      const start = new Date().getTime();
      while (new Date().getTime() - start < 2) {}
    }
  </script>
</html>

在浏览器控制台的 performance 中查看上例的运行结果,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hAIqhbSt-1662305177376)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b0a49871aa0948f2bd5832f673a3243d~tplv-k3u1fbpfcp-watermark.image?)]

从图中可以看出每一帧的执行时间都是 16.7ms,在这一帧内,浏览器执行 raf,计算样式,布局,重绘,requestIdleCallback、定时器,放大每一帧可以看到:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7bvlkngF-1662305177377)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3839d53966d54a24b88fb214bc3e9d05~tplv-k3u1fbpfcp-watermark.image?)]

在本篇文章中,会复用上面的 html 中的动画 demo

requestAnimationFrame

requestAnimationFrame 在每一帧绘制之前执行,嵌套(递归)调用 requestAnimationFrame 并不会导致页面死循环从而崩溃。每执行完一次 raf 回调,js 引擎都会将控制权交还给浏览器,等到下一帧时再执行。

function rafCallback(timestamp) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 2) {}
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

上面的例子中使用 while 循环模拟耗时 2 毫秒的任务,观察浏览器页面发现动画很流畅,Performance 查看每一帧的执行情况如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RQPpPN3s-1662305177378)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a741dae3387f407aae1956ca9905daa4~tplv-k3u1fbpfcp-watermark.image?)]

如果将 while 循环改成 100 毫秒,页面动画明显的卡顿,Performance 查看会提示一堆长任务

function rafCallback(timestamp) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 100) {}
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U1NUoT4j-1662305177378)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3d58e8453d8a479d94a851c43b562533~tplv-k3u1fbpfcp-watermark.image?)]

raf 在每一帧开始绘制前执行,两次 raf 之间间隔 16ms。在执行完一次 raf 回调后,会让出控制权给浏览器。嵌套递归调用 raf 不会导致页面死循环

requestIdleCallback

requestIdleCallback 在每一帧剩余时间执行。

本例中使用deadline.timeRemaining() > 0 || deadline.didTimeout判断如果当前帧中还有剩余时间,则继续 while 循环

const timeout = 1000;
requestIdleCallback(workLoop, { timeout });
function workLoop(deadline) {
  while (deadline.timeRemaining() > 0 || deadline.didTimeout) {}
  requestIdleCallback(workLoop, { timeout });
}

Performance 查看如下,几乎用满了一帧的时间,极致压榨 😁

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-i9AnvKHE-1662305177379)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f2d23109af7740e2adee25dd0653e1ff~tplv-k3u1fbpfcp-watermark.image?)]

requestIdleCallback 会在每一帧剩余时间执行,两次调用之间的时间间隔不确定,同时这个 API 有兼容性问题。在执行完一次 requestIdleCallback 回调后会主动让出控制权给浏览器,嵌套递归调用不会导致死循环

setTimeout

setTimeout 是一个宏任务,用于启动一个定时器,当然时间间隔并不一定准确。在本例中我将间隔设置为 0 毫秒

function work() {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 2) {}
  setTimeout(work, 0);
}
setTimeout(work, 0);

Performance 查看如下,可以发现,即使我将时间间隔设置为 0 毫秒,两次 setTimeout 之间的间隔差不多是 4 毫秒(如图中红线所示)。可以看出 setTimeout 会有至少 4 毫秒的延迟

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dskbxeEQ-1662305177379)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4a205761a77f4deaba783e316239d109~tplv-k3u1fbpfcp-watermark.image?)]

setTimeout 嵌套调用不会导致死循环,js 引擎执行完一次 settimeout 回调就会将控制权让给浏览器。settimeout 至少有 4 毫秒的延迟

MessageChannel

和 setTimeout 一样,MessageChannel 回调也是一个宏任务,具体用法如下:

var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = work;
function work() {
  port.postMessage(null);
}
port.postMessage(null);

Performance 查看如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VIUwndVN-1662305177380)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/131443c8ed38489abf63d9ea8acaae1e~tplv-k3u1fbpfcp-watermark.image?)]

放大每一帧可以看到,一帧内,MessageChannel 回调的调用频次超高

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-262YyZkZ-1662305177380)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/89e539e572184f49b94dca5cc22df531~tplv-k3u1fbpfcp-watermark.image?)]

从图中可以看出,相比于 setTimeout,MessageChannel 有以下特点:

  • 在一帧内的调用频次超高
  • 两次之间的时间间隔几乎可以忽略不计,没有 setTimeout 4 毫秒延迟的特点

微任务

微任务是在当前主线程执行完成后立即执行的,浏览器会在页面绘制前清空微任务队列,嵌套调用微任务会导致死循环。这里我会介绍两个微任务相关的 API

Promise

在这个例子中,我使用 count 来控制 promise 嵌套的次数,防止死循环

let count = 0;
function mymicrotask() {
  Promise.resolve(1).then((res) => {
    count++;
    if (count < 100000) {
      mymicrotask();
    }
  });
}
function rafCallback(timestamp) {
  mymicrotask();
  count = 0;
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

这里,我在 requestAnimationFrame 调用 mymicrotask,mymicrotask 中会调用 Promise 启用一个微任务,在 Promise then 中又会嵌套调用 mymicrotask 递归的调研 Promise。从图中可以看到,在本次页面更新前执行完全部的微任务

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kLS8kPGk-1662305177380)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8e7cd866e6354e38b0013f8bb26f7d6d~tplv-k3u1fbpfcp-watermark.image?)]

如果像下面这样嵌套调用,页面直接卡死,和死循环效果一样

function mymicrotask() {
  Promise.resolve(1).then((res) => {
    mymicrotask();
  });
}
function rafCallback(timestamp) {
  mymicrotask();
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

MutationObserver

和 Promise 一样,为了防止死循环,我使用 count 控制,在一次 raf 中只调用 2000 次 mymicrotask

let count = 0;
const observer = new MutationObserver(mymicrotask);
let textNode = document.createTextNode(String(count));
observer.observe(textNode, {
  characterData: true,
});
function mymicrotask() {
  if (count > 2000) return;
  count++;
  textNode.data = String(count);
}
function rafCallback(timestamp) {
  mymicrotask();
  count = 0;
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qe1p5Wbh-1662305177381)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ed8b8beaa51248ca8e703866f00fe6b7~tplv-k3u1fbpfcp-watermark.image?)]

当然,如果取消 count 的限制,页面直接卡死,死循环了。

let count = 0;
const observer = new MutationObserver(mymicrotask);
let textNode = document.createTextNode(String(count));
observer.observe(textNode, {
  characterData: true,
});
function mymicrotask() {
  count++;
  textNode.data = String(count);
}
function rafCallback(timestamp) {
  mymicrotask();
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

小结

从上面的例子中可以看出

  • 嵌套递归调用微任务 API 会导致死循环,JS 引擎需要执行完全部微任务才会让出控制权,因此不适用于任务调度
  • requestAnimationFrame、requestIdleCallback、setTimeout、MessageChannel 等 API 嵌套递归调用不会导致死循环,JS 引擎每执行完一次回调都会让出控制权,适用于任务调度。我们需要综合考虑这几个 API 调用间隔、执行时机等因素选择合适的 API

相关文章:

  • 【笔记:模拟MOS集成电路】偏置电路(基本原理+结构分析)
  • 【第六章 final、abstract】
  • 【JavaEE初阶】文件操作 和 IO (上篇)
  • Spring教程-01-IOC控制反转
  • Spring Cloud Gateway过滤器配置
  • Tomcat服务
  • REDIS05_SpringBoot整合redis、RedisTemplate操作各个基本类型、工具类的抽取
  • Sentinel的安装与配置
  • 生命周期函数
  • Go语言学习笔记——正则表达式
  • 无线传感器网络数据压缩与融合及安全机制的matlab仿真
  • 【C++】红黑树的性质以及实现
  • 软件测试 -- 入门 4 软件测试原则
  • java毕业设计慢性病管理mybatis+源码+调试部署+系统+数据库+lw
  • java毕业设计旅游攻略开发系统mybatis+源码+调试部署+系统+数据库+lw
  • #Java异常处理
  • 【162天】黑马程序员27天视频学习笔记【Day02-上】
  • 【笔记】你不知道的JS读书笔记——Promise
  • gcc介绍及安装
  • JavaScript HTML DOM
  • Java基本数据类型之Number
  • MYSQL 的 IF 函数
  • OpenStack安装流程(juno版)- 添加网络服务(neutron)- controller节点
  • python_bomb----数据类型总结
  • Sequelize 中文文档 v4 - Getting started - 入门
  • Vue 重置组件到初始状态
  • 浮动相关
  • 互联网大裁员:Java程序员失工作,焉知不能进ali?
  • 普通函数和构造函数的区别
  • 让你的分享飞起来——极光推出社会化分享组件
  • 如何实现 font-size 的响应式
  • 使用 @font-face
  • 限制Java线程池运行线程以及等待线程数量的策略
  • 小程序 setData 学问多
  • PostgreSQL 快速给指定表每个字段创建索引 - 1
  • 国内开源镜像站点
  • ​DB-Engines 11月数据库排名:PostgreSQL坐稳同期涨幅榜冠军宝座
  • #android不同版本废弃api,新api。
  • #pragma预处理命令
  • #我与虚拟机的故事#连载20:周志明虚拟机第 3 版:到底值不值得买?
  • (4)Elastix图像配准:3D图像
  • (C)一些题4
  • (超详细)语音信号处理之特征提取
  • (附源码)spring boot车辆管理系统 毕业设计 031034
  • (力扣)循环队列的实现与详解(C语言)
  • (每日持续更新)jdk api之StringBufferInputStream基础、应用、实战
  • (三)c52学习之旅-点亮LED灯
  • (五) 一起学 Unix 环境高级编程 (APUE) 之 进程环境
  • (转) SpringBoot:使用spring-boot-devtools进行热部署以及不生效的问题解决
  • (转)关于pipe()的详细解析
  • .mysql secret在哪_MySQL如何使用索引
  • .NET CORE 第一节 创建基本的 asp.net core
  • .NET HttpWebRequest、WebClient、HttpClient
  • .Net IOC框架入门之一 Unity
  • .NET MVC第三章、三种传值方式