大数据渲染的渐进式方案

·  阅读 96
大数据渲染的渐进式方案

一、前言

大家好,我是story,一名react开发者,今天通过这篇文章来,我们来聊聊大数据渲染场景下的解决方案;

在正式讲解主题之前,我还是希望来通过聊聊react的背景来慢慢引出主题,在从事react开发的过程中,我们也许都有这样一个共识: React在进入16版本之后将自身升级到了Fiber架构,几乎是重写了原来15版本的虚拟DOM架构;

那为什么会这样呢?

二、初识fps

这里我们需要聊一个很重要的基础知识:关于浏览器的帧率,然后揭晓答案!

大家现在应该正在使用浏览器观看我现在这篇文章,但是我需要跟各位分享的是,其实在过去的每一秒钟,我们的浏览器都会刷新很多次,那到底是多少次呢?

根据我掌握的资料,每个浏览器都不太一样,最少可能是30多次,有的50多次,根据我自己在chrome上的查看(command + shift + p)后输入fps,似乎每时每刻还不一样,但是大体都接近60次每秒;所以,为了方便研究,一般的,我们也可以认为对于现代浏览器而言,浏览器的刷新频率,我们称之为帧率60fps; 在这样快速的频率下,我们人眼才会感知到流畅的视觉体验,如动画、视频、交互...

总结:每16.6ms我们的浏览器都会绘制/刷新一次;

那有什么样的情况会使得这样顺畅的体验会被打破呢?

答案就是js引擎;在浏览器的世界里,有两个独立的线程,一个是JS引擎线程,一个是GUI绘制线程,而上述我们所讲述的绘制和fps概念的产生就是由GUI线程去做的;但是一山不容二虎,在浏览器的世界里,他们两个关系是互斥的;

因此在同一时刻,JS引擎在做事情的时候,GUI就不能做事情,GUI在在绘制的过程中,JS引擎就不能做事情;

我们来通过下面这个例子来证明这一点:

<!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>
      .box {
        width: 100px;
        height: 100px;
        background: red;
        animation: normal-animate 5s linear infinite;
        margin: 50px 0;
        position: absolute;
      }

      @keyframes normal-animate {
        0% {
          left: 0px;
        }
        50% {
          left: 100px;
        }
        100% {
          left: 0px;
        }
      }
    </style>
  </head>
  <body>
    <h1>时间切片案例</h1>
    <p>
      <span>input事件测试</span>
      <input />
    </p>
    <button onclick="sync()">同步</button>
    <div class="box"></div>
    <script>
      let index = 0;
      const sync = () => {
        let now = performance.now(); /* 开始时间*/
        while (performance.now() - now <= 5000) {
          // 必须执行5秒种,且是同步
          index++;
        }
      };
    </script>
  </body>
</html>

复制代码

屏幕录制2022-08-08 下午10.21.53.gif

通过上面的例子,我们可以看到他们的确是互斥的,当我们调用一个同步函数时,浏览器仿佛被冻住了一般,对事件都失去了敏感,因此我们可以慢慢开始回答上面提出的问题,为什么React重写了原来的方案,改为fiber架构;

那是因为对于一个庞大的项目来说由于节点太多,如果采用原来15版本的方案,使用递归的话,则等价于一个庞大的同步任务,那就相当于上面例子中的sync函数一样,无疑会阻塞GUI的绘制,这个时候就会导致卡顿的问题;因为有这个问题,所以react需要重新思考新的方案,而fiber架构就是新的方案;

三、纤细的时光

聊完fps和react重写的原因,我来引出一个重要的问题;上面我们提到了每一个16.6ms的时间段我们称之为一帧,那在这一帧中,浏览器做了哪些事情呢?

我们先来看一张图!

image.png

这张图详细的展示了每一帧中浏览器做了哪些事情;我总结一下,他们分别是;

  1. 事件处理/event
  2. js脚本执行
  3. scroll/resize 事件
  4. requestAnimationFrame
  5. 布局/绘制

对于每一帧而言他们都会做至少以上5件事情,包括这个叫做requestAnimationFrame的api,我们可以验证一下:

温馨提示:在接下来的案例中,为了节省空间,保持更好阅读体验,永远都以第一个案例中的单页html为蓝本,只展示增量代码,不展示全量代码

let index = 0;
const RAF = () => {
    let now = performance.now(); /* 开始时间*/
    const task = (handle) => {
      console.log("执行")
      index++
      if (performance.now() - now <= 5000) {
        requestAnimationFrame(task)
      }else{
        console.log(`在5秒内执行了${index}次,本次实验帧率为${ index/5 }fps`);
      }
    };
    requestAnimationFrame(task)
};

复制代码

演示:

屏幕录制2022-08-09 上午9.28.34.gif

通过上面的例子我们可以论证一帧的帧率基本都是接近60fps的,包括使用requestAnimationFrame,由于它是在绘制之前执行的,通过使用它,相较于setTimeout,我们可以得到更好的动画效果,这个内容由于并非今天的核心内容,我们之后的文章聊;

我们接下来聊一个更重要的话题,在一帧的16.6ms过程中,我们都知道需要执行一段js脚本,但是如果执行js我们就绕不开一个问题,那就是事件循环,我们都知道在js执行的过程中,可能会产生无数个事件循环;如下图所示:

image.png

不知道聪明的你有没有这样的一个疑问:对于每一帧(16.6ms)而言,这里面的js脚本执行是执行确定的一个loop呢?还是多个loop呢?如果是多个loop,那到底是多少个呢?可以确定么?

别急,我们慢慢来聊!

其实我们可以通过一个实验来验证,我们知道有一个创建宏任务的经典api叫做setTimeout,在这里面注册的回调都是宏任务,因此我们可以对比一下,使用setTimeout和requestAnimationFrame在执行同样的时间的情况下,谁消费的函数帧更多一些;于是我们写出下面的代码:

let index = 0;
const setTimeFn = () => {
    let now = performance.now(); /* 开始时间*/
    const task = (handle) => {
      console.log("执行")
      index++
      if (performance.now() - now <= 5000) {
        setTimeout(task)
      }else{
        console.log(`在5秒内执行了${index}次,比RAF的300次要${ index > 300 ? "多":"少" }`);
      }
    };
    setTimeout(task)
};
// 只是单纯的替换了宏任务,其他逻辑不变;
复制代码

演示

屏幕录制2022-08-09 上午9.38.20.gif 我这边测试的结果是setTimeout消费的函数次数是requestAnimationFrame的3倍还多一点;因此我们可以分析一下:

由于requestAnimationFrame是确定的每一帧会执行一次,因此根据上述测试结果,我们可以大胆的推断,在一个帧当中,eventLoop一定是执行了多次,因为只有这样,才可以得到上述实验的结果。

而这个多次到底是几次也并不是确定的,浏览器会根据单次宏任务的时长自己去进行调度和调配;

总结:在每一帧的js脚本执行过程中,时间循环会执行不确定的n次,而不是一次,这段时间我们可以亲切的称其为 纤细的时光

四、渐进式

好了,前面铺垫了这么多,该点一下题了,否则各位可能以为是标题党了,我们继续聊!

本文要讨论的就是如果我们有一个页面,需要一下渲染很多很多的内容,这个内容可能是海量的DOM,可能是长列表,可能是大型的计算,我们如何保证这个过程不会影响影响浏览器的帧率,并且始终保持对点击、鼠标、聚焦等事件的敏感;

本文总结一种方案:渐进式渲染

渐进是达尔文进化论的一个基本概念,物种的进化并不是一蹴而就,而是一点点的产生变异,通过适应环境,经过长时间的进化才一点点成就了它现在的样子;

我的理解是:不要一口吃个大胖子,慢慢来;

大数据渲染也是这样,不要使用同步任务一下渲染所有的东西,而是慢慢来,在保证浏览器有时间绘制的情况下,一点点的执行JS。

五、案例实践

假如我们有一个需求:在一个页面中,需要渲染10万个随机小球,我们如何设计?

对于这样的问题,我们直接一上来开写:

<!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>
      .circle {
        width: 10px;
        height: 10px;
        border-radius: 50%;
        position: absolute;
      }
    </style>
  </head>
  <body>
    <button onclick="render()">render</button>
    <div id="root"></div>
    <script>
      const factorArr = [0,1,2,3,4,5,6,7,8,9,'a','b','c','d','e','f']
      /* 颜色 */
      function getColor() {
        const r = factorArr[Math.floor(Math.random() * 16)];
        const g = factorArr[Math.floor(Math.random() * 16)];
        const b = factorArr[Math.floor(Math.random() * 16)];
        return `#${r}${g}${b}`;
      }
      /* 位置 */
      function getPostion(position) {
        const { width, height } = position;
        return {
          left: Math.ceil(Math.random() * width) + "px",
          top: Math.ceil(Math.random() * height) + "px",
        };
      }
      /* 色块组件 */
      function Circle(position) {
        return {
          background: getColor(),
          ...getPostion(position),
        };
      }

      const root = document.getElementById("root");

      function renderRoot(num) {
        const fragment = document.createElement("fragment");
        for (let index = 0; index < num; index++) {
          const div = document.createElement("div");
          const { left, top, background } = Circle({
            width: 1000,
            height: 600,
          });
          div.className = "circle";
          div.style.left = left;
          div.style.top = top;
          div.style.backgroundColor = background;
          fragment.appendChild(div);
        }

        root.appendChild(fragment);
      }

      const render = () => {
        renderRoot(100000);
      };
    </script>
  </body>
</html>

复制代码

效果如下:

屏幕录制2022-08-09 上午9.57.05.gif

通过上面的演示,我们能够明显感受到卡顿,原因我相信在座的各位都知道了,大量的js计算阻塞了DOM的绘制,导致我们能够感知的卡顿;如果我们将渲染的数量调的更大一下,效果会更加明显;

产品经理坐不住了,去,赶紧给我去优化!

五、帧渲染

任何思路都来源于我们对于基础知识的认识,分析原因我们得知,卡顿的原因是因为同步JS过长,那我们需要构建一种运行机制,使得浏览器在保证绘制的情况下,渐进的完成我们的大任务;因此我们可以从浏览器每一帧做的事情开始考虑,我们让浏览器正常刷新,然后每一帧塞一个小任务(同步执行1000个div的渲染),由于这个小任务足够小,所以根本就谈不上阻塞,能塞进去的接口有哪些呢?

首先想到了是requestAnimationFrame;

于是写一个渲染器:

// 提示:在上面html中需要新增一个按钮调用renderSlice

const scheduler = (size, scale, index) => {
    let start = performance.now(); /* 开始时间*/
    const handleTask = () => {
      renderRoot(size);
      console.log(index, scale);
      if (index < scale) {
        index++;
        requestAnimationFrame(handleTask);
      }else {
        let end = performance.now(); /* 结束时间*/
        console.log(`${(end - start) / 1000}s`);
      }
    };
    requestAnimationFrame(handleTask);
};

const renderSlice = () => {
    const size = 2000;
    const scale = 500000 / size;
    scheduler(size, scale, 0);
};

复制代码

看看效果:

屏幕录制2022-08-09 下午7.26.11.gif

可以观察到,这样渲染效果会好很多,没有刚开始卡顿的感觉,但是整体的渲染时间,确实会拉长一些,慢着,既然是帧间渲染我们其实也可以用requestIdleCallback,这是chorme浏览器提供的一个api,能够在浏览器每一帧有盈余的时间的时候,做一些事情。我们只需将上面的requestAnimationFrame替换为requestIdleCallback,便能够看到效果;

// 提示:在上面html中需要新增一个按钮调用renderSlice

const scheduler = (size, scale, index) => {
    let start = performance.now(); /* 开始时间*/
    const handleTask = () => {
      renderRoot(size);
      console.log(index, scale);
      if (index < scale) {
        index++;
        requestIdleCallback(handleTask);
      }else {
        let end = performance.now(); /* 结束时间*/
        console.log(`${(end - start) / 1000}s`);
      }
    };
    requestIdleCallback(handleTask);
};

const renderSlice = () => {
    const size = 1000;
    const scale = 500000 / size;
    scheduler(size, scale, 0);
};

复制代码

屏幕录制2022-08-09 下午7.27.17.gif

看效果几乎都是一样的,因为他们都是每一帧执行一次;

但是有一个问题,上面这个每一帧只能做一次任务,总感觉效率不够高,所以整个大任务渲染完毕的周期就会拉的特别长,我们能不能尽可能让一帧执行多次任务呢?

六、宏渲染

聪明的你可能已经想到了宏任务,在铺垫环节我们已经讲到了,宏任务在一帧钟可以执行多次,因此可以极大的提升渲染效率;等不及了,赶紧先用setTimeout试一下;

const scheduler = (size, scale, index) => {
    let start = performance.now(); /* 开始时间*/
    const handleTask = () => {
      renderRoot(size);
      console.log(index, scale);
      if (index < scale) {
        index++;
        setTimeout(handleTask, 0);
      }else {
        let end = performance.now(); /* 结束时间*/
        console.log(`${(end - start) / 1000}s`);
      }
    };
    setTimeout(handleTask, 0);
};

const renderSlice = () => {
    const size = 1000;
    const scale = 500000 / size;
    scheduler(size, scale, 0);
};

复制代码

屏幕录制2022-08-09 下午7.28.11.gif 奇怪,感觉好想也没有好多少;多做几次的实验结果是平均就少了1s,虽然是优化了一些,但是远低于我们的预期,这是为什么呢?

原来setTimeout本身就是有性能损耗的,每一个setTimeout宏任务都会有4ms-5ms的误差,我们以为我们写的是立马执行的定时器,但其实内部他们给我们偷一下懒,偏偏给我们慢个5ms左右,本来一帧就只有16.6ms,它还给你慢个5ms,那可不耽误事么?

所以我们选择放弃setTimeout!转而使用MessageChannel,关于它的介绍,可查阅文档。我们直接改进我们的渲染器;

const async = (size, scale, index) => {
    let start = performance.now(); /* 开始时间*/
    const { port1, port2 } = new MessageChannel();
    port2.onmessage = (e) => {
      renderRoot(size);
      if (index < scale) {
        index++;
        port1.postMessage("去给我执行任务去");
      }else {
        let end = performance.now(); /* 结束时间*/
        console.log(`${(end - start) / 1000}s`);
      }
    };

    port1.postMessage("去给我执行任务去");
  };

  const renderSlice = () => {
    const size = 1000;
    const scale = 100000 / size;
    async(size, scale, 0);
};

复制代码

屏幕录制2022-08-09 下午7.28.55.gif 可以看到,快了一倍不止,所以我们可以总结,MessageChannel,拥有更高的效率,兼容性也好,并且属于一个宏任务,可以作为我们目前这个阶段的最佳方案;

关于使用微任务,可能会有同学想,既然帧渲染,宏渲染都可以,那我可不可以使用微任务渲染呢?目前来说还是不行的,因为微任务如果不断的堆积,那么第一个loop就永远结束不了,事件循环是以宏任务为单位的,因此微任务的堆积从效果上来看等价于同步任务,因为这种方案我们不采取;

七、结合react

可能对于react熟悉的朋友已经发现了,我整篇文章的逻辑正式按照react中scheduler模版的迭代历史去写的,在react现在版本中,正式使用MessageChanel作为调度器的核心api的,如果浏览器不支持这个api则使用setTimeout作为垫片,因此我想通过这样一篇文章,结合实践来帮助各位更好的认识react;

最后贡献一道面试题:

const { port1, port2 } = new MessageChannel();

port2.onmessage = (e) => {
    console.log(`MessageChannel`);
};

setTimeout(() => {
    console.log("setTimeout");
});

port1.postMessage("");

requestAnimationFrame(() => {
    console.log("requestAnimationFrame");
});

Promise.resolve().then(() => {
    console.log("Promise");
});

// 输出的顺序
复制代码

上面面试题:欢迎评论区留下答案,如果可以,分享一下您的分析;

八、总结

本文通过多个例子,简单阐述了一个核心思想,那就是渐进式渲染,它的代价是能够始终使得浏览器保持敏感,且最终可以完成大任务,缺点就是整体的耗时会更长一些,但从用户体验的角度,这是值得的。

如果有觉得错误或者不理解的地方,欢迎私信或者评论区我们一起讨论;

如果对您有帮助,还希望点赞支持一下?万分感谢!持续输出高质量文章,我们一起加油!

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改