高性能渲染十万级数据(时间分片)

背景:在实际工作中,我们很少会遇到一次性需要向页面中插入大量数据的情况
我们有必要了解并清楚当遇到大量数据时,如何才能在不卡主页面的情况下渲染数据,以及其中背后的原理。

对于一次性插入大量数据的情况,一般有两种做法:
时间分片: 使用定时器
虚拟列表
着重来介绍如何使用 时间分片的方式来渲染大量数据,虚拟列表相关的内容,日后会持续整理。

最粗暴的做法(一次性渲染)

<ul id="container"></ul>
    <script>
        // 记录任务开始时间
         let now = Date.now();
         // 插入十万条数据
         const total = 100000;
         // 获取容器
         let ul = document.getElementById('container');
         // 将数据插入容器
         for (let i = 0; i < total; i++) {
             let li = document.createElement('li');
             li.innerText = ~~(Math.random() * total)
             ul.appendChild(li)
         }
         console.log('js运行时间:', Date.now() - now);
         setTimeout(() => {
             console.log('总运行时间:', Date.now() - now);
         }, 0)
         // print: JS运行时间: 187
         // print: 总运行时间: 2844  
    </script>

我们对十万条记录进行循环操作,JS的运行时间为 187ms,还是蛮快的,但是最终渲染完成后的总时间是 2844ms。
简单说明一下,为何两次 console.log的结果时间差异巨大,并且是如何简单来统计 JS运行时间和 总渲染时间:

  • 在 JS 的 EventLoop中,当JS引擎所管理的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染
  • 第一个 console.log的触发时间是在页面进行渲染之前,此时得到的间隔时间为JS运行所需要的时间
  • 第二个 console.log是放到 setTimeout 中的,它的触发时间是在渲染完成,在下一次 EventLoop中执行的
    依照两次 console.log的结果,可以得出结论:
    对于大量数据渲染的时候,JS运算并不是性能的瓶颈,性能的瓶颈主要在于渲染阶段, 页面卡顿是由于同时渲染大量DOM所引起的

使用定时器

// 记录任务开始时间
         let now = Date.now();
         // 获取容器
         let ul = document.getElementById('container');
         // 插入十万条数据
         const total = 100000;
         // 一次插入20条
        const once = 20;
        // 计算总页数
        const page = total / once;
        // 每条记录的索引
        let index = 0;
         // 循环加载数据
         function loop(curTotal, curIndex){
            if (curTotal <= 0){
                return false;
            }
            // 每页多少条
            let pageCount = Math.min(curTotal, once);
            setTimeout(() => {
                for (var i = 0; i < pageCount; i++) {
                    let li = document.createElement('li');
                    li.innerText = curIndex + i + ":" + ~~(Math.random() * total)
                    ul.appendChild(li);
                }
                console.log('总运行时间:', Date.now() - now); // print: 2
                // loop(curTotal - pageCount, curIndex + pageCount)
            }, 0)
         }
         loop(total, index)
         console.log('js运行时间:', Date.now() - now); // print 0
    </script>

我们可以看到,页面加载的时间已经非常快了,每次刷新时可以很快的看到第一屏的所有数据,但是当我们快速滚动页面的时候,会发现页面出现闪屏或白屏的现象

为什么会出现闪屏现象呢
FPS表示的是每秒钟画面更新次数。
我们平时所看到的连续画面都是由一幅幅静止画面组成的,每幅画面称为一
FPS是描述 帧变化速度的物理
大多数电脑显示器的刷新频率是60Hz,大概相当于每秒钟重绘60次, FPS为60frame/s,为这个值的设定受屏幕分辨率、屏幕尺寸和显卡的影响。
因此,当你对着电脑屏幕什么也不做的情况下,大多显示器也会以每秒60次的频率正在不断的更新屏幕上的图像。

为什么你感觉不到这个变化?

那是因为人的眼睛有视觉停留效应,即前一副画面留在大脑的印象还没消失,紧接着后一副画面就跟上来了, 这中间只间隔了16.7ms(1000/60≈16.7),所以会让你误以为屏幕上的图像是静止不动的。
最平滑动画的最佳循环间隔是1000ms/60,约等于16.6ms。
直观感受,不同帧率的体验:

  • 帧率能够达到 50 ~ 60 FPS 的动画将会相当流畅,让人倍感舒适;
  • 帧率在 30 ~ 50 FPS 之间的动画,因各人敏感程度不同,舒适度因人而异;
  • 帧率在 30 FPS 以下的动画,让人感觉到明显的卡顿和不适感;
  • 帧率波动很大的动画,亦会使人感觉到卡顿。

简单聊一下 setTimeout 和闪屏现象

setTimeout的执行时间并不是确定的。在JS中, setTimeout任务被放进事件队列中,只有主线程执行完才会去检查事件队列中的任务是否需要执行,因此 setTimeout的实际执行时间可能会比其设定的时间晚一些。
刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的刷新频率可能会不同,而 setTimeout只能设置一个固定时间间隔,这个时间不一定和屏幕的刷新时间相同。
以上两种情况都会导致setTimeout的执行步调和屏幕的刷新步调不一致

在 setTimeout中对dom进行操作,必须要等到屏幕下次绘制时才能更新到屏幕上,如果两者步调不一致,就可能导致中间某一帧的操作被跨越过去,而直接更新下一帧的元素,从而导致丢帧现象。

使用 requestAnimationFrame

与 setTimeout相比, requestAnimationFrame最大的优势是由系统来决定回调函数的执行时机。
如果屏幕刷新率是60Hz,那么回调函数就每16.7ms被执行一次,如果刷新率是75Hz,那么这个时间间隔就变成了1000/75=13.3ms,换句话说就是, requestAnimationFrame的步伐跟着系统的刷新步伐走。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象

 <ul id="container"></ul>
    <script>
         // 记录任务开始时间
         let now = Date.now();
         // 获取容器
         let ul = document.getElementById('container');
         // 插入十万条数据
         const total = 100000;
         // 一次插入20条
        const once = 20;
        // 计算总页数
        const page = total / once;
        // 每条记录的索引
        let index = 0;
         // 循环加载数据
         function loop(curTotal, curIndex){
            if (curTotal <= 0){
                return false;
            }
            // 每页多少条
            let pageCount = Math.min(curTotal, once);
            console.log('js运行时间:', Date.now() - now);
            window.requestAnimationFrame(() => {
                for (var i = 0; i < pageCount; i++) {
                    let li = document.createElement('li');
                    li.innerText = curIndex + i + ":" + ~~(Math.random() * total)
                    ul.appendChild(li);
                }
                console.log('总运行时间:', Date.now() - now);
                loop(curTotal - pageCount, curIndex + pageCount)
            })
         }
         loop(total, index)
    </script>

使用 DocumentFragment

DocumentFragment,文档片段接口,表示一个没有父级文件的最小文档对象。它被作为一个轻量版的 Document使用,用于存储已排好版的或尚未打理好格式的XML片段。最大的区别是因为 DocumentFragment不是真实DOM树的一部分,它的变化不会触发DOM树的(重新渲染) ,且不会导致性能等问题。
可以使用 document.createDocumentFragment方法或者构造函数来创建一个空的
从MDN的说明中,我们得知 DocumentFragments是DOM节点,但并不是DOM树的一部分,可以认为是存在内存中的,所以将子元素插入到文档片段时不会引起页面回流
当 append元素到 document中时,被 append进去的元素的样式表的计算是同步发生的,此时调用 getComputedStyle 可以得到样式的计算值。而 append元素到 documentFragment 中时,是不会计算元素的样式表,所以 documentFragment 性能更优。当然现在浏览器的优化已经做的很好了,
当 append元素到 document中后,没有访问 getComputedStyle 之类的方法时,现代浏览器也可以把样式表的计算推迟到脚本执行之后。

<ul id="container"></ul>
    <script>
         // 记录任务开始时间
         let now = Date.now();
         // 获取容器
         let ul = document.getElementById('container');
         // 插入十万条数据
         const total = 100000;
         // 一次插入20条
        const once = 20;
        // 计算总页数
        const page = total / once;
        // 每条记录的索引
        let index = 0;
         // 循环加载数据
         function loop(curTotal, curIndex){
            if (curTotal <= 0){
                return false;
            }
            // 每页多少条
            let pageCount = Math.min(curTotal, once);
            console.log('js运行时间:', Date.now() - now);
            window.requestAnimationFrame(() => {
                let fragment = document.createDocumentFragment();
                for (var i = 0; i < pageCount; i++) {
                    let li = document.createElement('li');
                    li.innerText = curIndex + i + ":" + ~~(Math.random() * total)
                    fragment.appendChild(li);
                }
                ul.appendChild(fragment);
                console.log('总运行时间:', Date.now() - now);
                loop(curTotal - pageCount, curIndex + pageCount)
            })
         }
         loop(total, index)
    </script>

最后

本文更多的是提供一个思路,通过时间分片的方式来同时加载大量简单DOM。对于复杂DOM的情况,一般会用到虚拟列表的方式来实现

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,717评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,501评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,311评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,417评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,500评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,538评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,557评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,310评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,759评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,065评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,233评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,909评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,548评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,172评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,420评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,103评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,098评论 2 352

推荐阅读更多精彩内容