高性能渲染十萬(wàn)級(jí)數(shù)據(jù)(時(shí)間分片)

背景:在實(shí)際工作中,我們很少會(huì)遇到一次性需要向頁(yè)面中插入大量數(shù)據(jù)的情況
我們有必要了解并清楚當(dāng)遇到大量數(shù)據(jù)時(shí),如何才能在不卡主頁(yè)面的情況下渲染數(shù)據(jù),以及其中背后的原理。

對(duì)于一次性插入大量數(shù)據(jù)的情況,一般有兩種做法:
時(shí)間分片: 使用定時(shí)器
虛擬列表
著重來(lái)介紹如何使用 時(shí)間分片的方式來(lái)渲染大量數(shù)據(jù),虛擬列表相關(guān)的內(nèi)容,日后會(huì)持續(xù)整理。

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

<ul id="container"></ul>
    <script>
        // 記錄任務(wù)開始時(shí)間
         let now = Date.now();
         // 插入十萬(wàn)條數(shù)據(jù)
         const total = 100000;
         // 獲取容器
         let ul = document.getElementById('container');
         // 將數(shù)據(jù)插入容器
         for (let i = 0; i < total; i++) {
             let li = document.createElement('li');
             li.innerText = ~~(Math.random() * total)
             ul.appendChild(li)
         }
         console.log('js運(yùn)行時(shí)間:', Date.now() - now);
         setTimeout(() => {
             console.log('總運(yùn)行時(shí)間:', Date.now() - now);
         }, 0)
         // print: JS運(yùn)行時(shí)間: 187
         // print: 總運(yùn)行時(shí)間: 2844  
    </script>

我們對(duì)十萬(wàn)條記錄進(jìn)行循環(huán)操作,JS的運(yùn)行時(shí)間為 187ms,還是蠻快的,但是最終渲染完成后的總時(shí)間是 2844ms。
簡(jiǎn)單說(shuō)明一下,為何兩次 console.log的結(jié)果時(shí)間差異巨大,并且是如何簡(jiǎn)單來(lái)統(tǒng)計(jì) JS運(yùn)行時(shí)間和 總渲染時(shí)間:

  • 在 JS 的 EventLoop中,當(dāng)JS引擎所管理的執(zhí)行棧中的事件以及所有微任務(wù)事件全部執(zhí)行完后,才會(huì)觸發(fā)渲染線程對(duì)頁(yè)面進(jìn)行渲染
  • 第一個(gè) console.log的觸發(fā)時(shí)間是在頁(yè)面進(jìn)行渲染之前,此時(shí)得到的間隔時(shí)間為JS運(yùn)行所需要的時(shí)間
  • 第二個(gè) console.log是放到 setTimeout 中的,它的觸發(fā)時(shí)間是在渲染完成,在下一次 EventLoop中執(zhí)行的
    依照兩次 console.log的結(jié)果,可以得出結(jié)論:
    對(duì)于大量數(shù)據(jù)渲染的時(shí)候,JS運(yùn)算并不是性能的瓶頸,性能的瓶頸主要在于渲染階段, 頁(yè)面卡頓是由于同時(shí)渲染大量DOM所引起的

使用定時(shí)器

// 記錄任務(wù)開始時(shí)間
         let now = Date.now();
         // 獲取容器
         let ul = document.getElementById('container');
         // 插入十萬(wàn)條數(shù)據(jù)
         const total = 100000;
         // 一次插入20條
        const once = 20;
        // 計(jì)算總頁(yè)數(shù)
        const page = total / once;
        // 每條記錄的索引
        let index = 0;
         // 循環(huán)加載數(shù)據(jù)
         function loop(curTotal, curIndex){
            if (curTotal <= 0){
                return false;
            }
            // 每頁(yè)多少條
            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('總運(yùn)行時(shí)間:', Date.now() - now); // print: 2
                // loop(curTotal - pageCount, curIndex + pageCount)
            }, 0)
         }
         loop(total, index)
         console.log('js運(yùn)行時(shí)間:', Date.now() - now); // print 0
    </script>

我們可以看到,頁(yè)面加載的時(shí)間已經(jīng)非??炝?,每次刷新時(shí)可以很快的看到第一屏的所有數(shù)據(jù),但是當(dāng)我們快速滾動(dòng)頁(yè)面的時(shí)候,會(huì)發(fā)現(xiàn)頁(yè)面出現(xiàn)閃屏或白屏的現(xiàn)象

為什么會(huì)出現(xiàn)閃屏現(xiàn)象呢
FPS表示的是每秒鐘畫面更新次數(shù)。
我們平時(shí)所看到的連續(xù)畫面都是由一幅幅靜止畫面組成的,每幅畫面稱為一
FPS是描述 幀變化速度的物理
大多數(shù)電腦顯示器的刷新頻率是60Hz,大概相當(dāng)于每秒鐘重繪60次, FPS為60frame/s,為這個(gè)值的設(shè)定受屏幕分辨率、屏幕尺寸和顯卡的影響。
因此,當(dāng)你對(duì)著電腦屏幕什么也不做的情況下,大多顯示器也會(huì)以每秒60次的頻率正在不斷的更新屏幕上的圖像。

為什么你感覺(jué)不到這個(gè)變化?

那是因?yàn)槿说难劬τ幸曈X(jué)停留效應(yīng),即前一副畫面留在大腦的印象還沒(méi)消失,緊接著后一副畫面就跟上來(lái)了, 這中間只間隔了16.7ms(1000/60≈16.7),所以會(huì)讓你誤以為屏幕上的圖像是靜止不動(dòng)的。
最平滑動(dòng)畫的最佳循環(huán)間隔是1000ms/60,約等于16.6ms。
直觀感受,不同幀率的體驗(yàn):

  • 幀率能夠達(dá)到 50 ~ 60 FPS 的動(dòng)畫將會(huì)相當(dāng)流暢,讓人倍感舒適;
  • 幀率在 30 ~ 50 FPS 之間的動(dòng)畫,因各人敏感程度不同,舒適度因人而異;
  • 幀率在 30 FPS 以下的動(dòng)畫,讓人感覺(jué)到明顯的卡頓和不適感;
  • 幀率波動(dòng)很大的動(dòng)畫,亦會(huì)使人感覺(jué)到卡頓。

簡(jiǎn)單聊一下 setTimeout 和閃屏現(xiàn)象

setTimeout的執(zhí)行時(shí)間并不是確定的。在JS中, setTimeout任務(wù)被放進(jìn)事件隊(duì)列中,只有主線程執(zhí)行完才會(huì)去檢查事件隊(duì)列中的任務(wù)是否需要執(zhí)行,因此 setTimeout的實(shí)際執(zhí)行時(shí)間可能會(huì)比其設(shè)定的時(shí)間晚一些。
刷新頻率受屏幕分辨率和屏幕尺寸的影響,因此不同設(shè)備的刷新頻率可能會(huì)不同,而 setTimeout只能設(shè)置一個(gè)固定時(shí)間間隔,這個(gè)時(shí)間不一定和屏幕的刷新時(shí)間相同。
以上兩種情況都會(huì)導(dǎo)致setTimeout的執(zhí)行步調(diào)和屏幕的刷新步調(diào)不一致。

在 setTimeout中對(duì)dom進(jìn)行操作,必須要等到屏幕下次繪制時(shí)才能更新到屏幕上,如果兩者步調(diào)不一致,就可能導(dǎo)致中間某一幀的操作被跨越過(guò)去,而直接更新下一幀的元素,從而導(dǎo)致丟幀現(xiàn)象。

使用 requestAnimationFrame

與 setTimeout相比, requestAnimationFrame最大的優(yōu)勢(shì)是由系統(tǒng)來(lái)決定回調(diào)函數(shù)的執(zhí)行時(shí)機(jī)。
如果屏幕刷新率是60Hz,那么回調(diào)函數(shù)就每16.7ms被執(zhí)行一次,如果刷新率是75Hz,那么這個(gè)時(shí)間間隔就變成了1000/75=13.3ms,換句話說(shuō)就是, requestAnimationFrame的步伐跟著系統(tǒng)的刷新步伐走。它能保證回調(diào)函數(shù)在屏幕每一次的刷新間隔中只被執(zhí)行一次,這樣就不會(huì)引起丟幀現(xiàn)象

 <ul id="container"></ul>
    <script>
         // 記錄任務(wù)開始時(shí)間
         let now = Date.now();
         // 獲取容器
         let ul = document.getElementById('container');
         // 插入十萬(wàn)條數(shù)據(jù)
         const total = 100000;
         // 一次插入20條
        const once = 20;
        // 計(jì)算總頁(yè)數(shù)
        const page = total / once;
        // 每條記錄的索引
        let index = 0;
         // 循環(huán)加載數(shù)據(jù)
         function loop(curTotal, curIndex){
            if (curTotal <= 0){
                return false;
            }
            // 每頁(yè)多少條
            let pageCount = Math.min(curTotal, once);
            console.log('js運(yùn)行時(shí)間:', 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('總運(yùn)行時(shí)間:', Date.now() - now);
                loop(curTotal - pageCount, curIndex + pageCount)
            })
         }
         loop(total, index)
    </script>

使用 DocumentFragment

DocumentFragment,文檔片段接口,表示一個(gè)沒(méi)有父級(jí)文件的最小文檔對(duì)象。它被作為一個(gè)輕量版的 Document使用,用于存儲(chǔ)已排好版的或尚未打理好格式的XML片段。最大的區(qū)別是因?yàn)?DocumentFragment不是真實(shí)DOM樹的一部分,它的變化不會(huì)觸發(fā)DOM樹的(重新渲染) ,且不會(huì)導(dǎo)致性能等問(wèn)題。
可以使用 document.createDocumentFragment方法或者構(gòu)造函數(shù)來(lái)創(chuàng)建一個(gè)空的
從MDN的說(shuō)明中,我們得知 DocumentFragments是DOM節(jié)點(diǎn),但并不是DOM樹的一部分,可以認(rèn)為是存在內(nèi)存中的,所以將子元素插入到文檔片段時(shí)不會(huì)引起頁(yè)面回流
當(dāng) append元素到 document中時(shí),被 append進(jìn)去的元素的樣式表的計(jì)算是同步發(fā)生的,此時(shí)調(diào)用 getComputedStyle 可以得到樣式的計(jì)算值。而 append元素到 documentFragment 中時(shí),是不會(huì)計(jì)算元素的樣式表,所以 documentFragment 性能更優(yōu)。當(dāng)然現(xiàn)在瀏覽器的優(yōu)化已經(jīng)做的很好了,
當(dāng) append元素到 document中后,沒(méi)有訪問(wèn) getComputedStyle 之類的方法時(shí),現(xiàn)代瀏覽器也可以把樣式表的計(jì)算推遲到腳本執(zhí)行之后。

<ul id="container"></ul>
    <script>
         // 記錄任務(wù)開始時(shí)間
         let now = Date.now();
         // 獲取容器
         let ul = document.getElementById('container');
         // 插入十萬(wàn)條數(shù)據(jù)
         const total = 100000;
         // 一次插入20條
        const once = 20;
        // 計(jì)算總頁(yè)數(shù)
        const page = total / once;
        // 每條記錄的索引
        let index = 0;
         // 循環(huán)加載數(shù)據(jù)
         function loop(curTotal, curIndex){
            if (curTotal <= 0){
                return false;
            }
            // 每頁(yè)多少條
            let pageCount = Math.min(curTotal, once);
            console.log('js運(yùn)行時(shí)間:', 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('總運(yùn)行時(shí)間:', Date.now() - now);
                loop(curTotal - pageCount, curIndex + pageCount)
            })
         }
         loop(total, index)
    </script>

最后

本文更多的是提供一個(gè)思路,通過(guò)時(shí)間分片的方式來(lái)同時(shí)加載大量簡(jiǎn)單DOM。對(duì)于復(fù)雜DOM的情況,一般會(huì)用到虛擬列表的方式來(lái)實(shí)現(xiàn)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容