• 【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>Frametitle>
        <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">testdiv>
      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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    在浏览器控制台的 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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    上面的例子中使用 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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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 });
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    这里,我在 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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    小结

    从上面的例子中可以看出

    • 嵌套递归调用微任务 API 会导致死循环,JS 引擎需要执行完全部微任务才会让出控制权,因此不适用于任务调度
    • requestAnimationFrame、requestIdleCallback、setTimeout、MessageChannel 等 API 嵌套递归调用不会导致死循环,JS 引擎每执行完一次回调都会让出控制权,适用于任务调度。我们需要综合考虑这几个 API 调用间隔、执行时机等因素选择合适的 API
  • 相关阅读:
    基于pgrouting的路径规划处理
    Vue中实现过渡动画
    Eclipse启动SpringBoot无法读取application.properties或者application.yml文件内容
    80/10/10 饮食法:到底是健康饮食还是危险时尚?
    「全球数字经济大会」登陆 N 世界,融云提供通信云服务支持
    Redis缓存满了咋办?什么叫近似LRU算法?为啥不使用真实LRU?
    【MAPBOX基础功能】15、mapbox地图事件:点击、移入、移出、解绑
    视频汇聚/安防监控/EasyCVR平台播放器EasyPlayer更新:新增【性能面板】
    C++ QT QSerialPort基操
    拖动排序与置顶的Java实现
  • 原文地址:https://blog.csdn.net/qq_20567691/article/details/126696505