「前端進階」高性能渲染十萬條數據(時間分片)

  • 2019 年 10 月 5 日
  • 筆記

前言

在實際工作中,我們很少會遇到一次性需要向頁面中插入大量數據的情況,但是為了豐富我們的知識體系,我們有必要了解並清楚當遇到大量數據時,如何才能在不卡主頁面的情況下渲染數據,以及其中背後的原理。

對於一次性插入大量數據的情況,一般有兩種做法:

  1. 時間分片
  2. 虛擬列表

本文作為開篇,著重來介紹如何使用 時間分片的方式來渲染大量數據,虛擬列表相關的內容,日後會持續整理。

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

我們先來看看最粗暴的做法,一次性將大量數據插入到頁面中:

<ul id="container"></ul>
// 記錄任務開始時間  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

我們對十萬條記錄進行循環操作,JS的運行時間為 187ms,還是蠻快的,但是最終渲染完成後的總時間是 2844ms

簡單說明一下,為何兩次 console.log的結果時間差異巨大,並且是如何簡單來統計 JS運行時間總渲染時間

  • 在 JS 的 EventLoop中,當JS引擎所管理的執行棧中的事件以及所有微任務事件全部執行完後,才會觸發渲染執行緒對頁面進行渲染
  • 第一個 console.log的觸發時間是在頁面進行渲染之前,此時得到的間隔時間為JS運行所需要的時間
  • 第二個 console.log是放到 setTimeout 中的,它的觸發時間是在渲染完成,在下一次 EventLoop中執行的

關於Event Loop的詳細內容請參見這篇文章–>

依照兩次 console.log的結果,可以得出結論:

對於大量數據渲染的時候,JS運算並不是性能的瓶頸,性能的瓶頸主要在於渲染階段

使用定時器

從上面的例子,我們已經知道,頁面的卡頓是由於同時渲染大量DOM所引起的,所以我們考慮將渲染過程分批進行

在這裡,我們使用 setTimeout來實現分批渲染

<ul id="container"></ul>
//需要插入的容器  let ul = document.getElementById('container');  // 插入十萬條數據  let total = 100000;  // 一次插入 20 條  let once = 20;  //總頁數  let page = total/once  //每條記錄的索引  let index = 0;  //循環載入數據  function loop(curTotal,curIndex){      if(curTotal <= 0){          return false;      }      //每頁多少條      let pageCount = Math.min(curTotal , once);      setTimeout(()=>{          for(let i = 0; i < pageCount; i++){              let li = document.createElement('li');              li.innerText = curIndex + i + ' : ' + ~~(Math.random() * total)              ul.appendChild(li)          }          loop(curTotal - pageCount,curIndex + pageCount)      },0)  }  loop(total,index);

用一個gif圖來看一下效果

我們可以看到,頁面載入的時間已經非常快了,每次刷新時可以很快的看到第一屏的所有數據,但是當我們快速滾動頁面的時候,會發現頁面出現閃屏或白屏的現象

為什麼會出現閃屏現象呢

首先,理清一些概念。FPS表示的是每秒鐘畫面更新次數。我們平時所看到的連續畫面都是由一幅幅靜止畫面組成的,每幅畫面稱為一 FPS是描述 變化速度的物理量。

大多數電腦顯示器的刷新頻率是60Hz,大概相當於每秒鐘重繪60次, FPS為60frame/s,為這個值的設定受螢幕解析度、螢幕尺寸和顯示卡的影響。

因此,當你對著電腦螢幕什麼也不做的情況下,大多顯示器也會以每秒60次的頻率正在不斷的更新螢幕上的影像。

為什麼你感覺不到這個變化?

那是因為人的眼睛有視覺停留效應,即前一副畫面留在大腦的印象還沒消失,緊接著後一副畫面就跟上來了, 這中間只間隔了16.7ms(1000/60≈16.7),所以會讓你誤以為螢幕上的影像是靜止不動的。

而螢幕給你的這種感覺是對的,試想一下,如果刷新頻率變成1次/秒,螢幕上的影像就會出現嚴重的閃爍, 這樣就很容易引起眼睛疲勞、酸痛和頭暈目眩等癥狀。

大多數瀏覽器都會對重繪操作加以限制,不超過顯示器的重繪頻率,因為即使超過那個頻率用戶體驗也不會有提升。因此,最平滑動畫的最佳循環間隔是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的步伐跟著系統的刷新步伐走。它能保證回調函數在螢幕每一次的刷新間隔中只被執行一次,這樣就不會引起丟幀現象。

我們使用 requestAnimationFrame來進行分批渲染:

<ul id="container"></ul>
//需要插入的容器  let ul = document.getElementById('container');  // 插入十萬條數據  let total = 100000;  // 一次插入 20 條  let once = 20;  //總頁數  let page = total/once  //每條記錄的索引  let index = 0;  //循環載入數據  function loop(curTotal,curIndex){      if(curTotal <= 0){          return false;      }      //每頁多少條      let pageCount = Math.min(curTotal , once);      window.requestAnimationFrame(function(){          for(let i = 0; i < pageCount; i++){              let li = document.createElement('li');              li.innerText = curIndex + i + ' : ' + ~~(Math.random() * total)              ul.appendChild(li)          }          loop(curTotal - pageCount,curIndex + pageCount)      })  }  loop(total,index);

看下效果

我們可以看到,頁面載入的速度很快,並且滾動的時候,也很流暢沒有出現閃爍丟幀的現象。

這就結束了么,還可以再優化么?

當然~~

使用 DocumentFragment

先解釋一下什麼是 DocumentFragment ,文獻引用自MDN

DocumentFragment,文檔片段介面,表示一個沒有父級文件的最小文檔對象。它被作為一個輕量版的 Document使用,用於存儲已排好版的或尚未打理好格式的XML片段。最大的區別是因為 DocumentFragment不是真實DOM樹的一部分,它的變化不會觸發DOM樹的(重新渲染) ,且不會導致性能等問題。 可以使用 document.createDocumentFragment方法或者構造函數來創建一個空的 DocumentFragment

從MDN的說明中,我們得知 DocumentFragments是DOM節點,但並不是DOM樹的一部分,可以認為是存在記憶體中的,所以將子元素插入到文檔片段時不會引起頁面迴流。

append元素到 document中時,被 append進去的元素的樣式表的計算是同步發生的,此時調用 getComputedStyle 可以得到樣式的計算值。而 append元素到 documentFragment 中時,是不會計算元素的樣式表,所以 documentFragment 性能更優。當然現在瀏覽器的優化已經做的很好了, 當 append元素到 document中後,沒有訪問 getComputedStyle 之類的方法時,現代瀏覽器也可以把樣式表的計算推遲到腳本執行之後。

最後修改程式碼如下:

<ul id="container"></ul>
//需要插入的容器  let ul = document.getElementById('container');  // 插入十萬條數據  let total = 100000;  // 一次插入 20 條  let once = 20;  //總頁數  let page = total/once  //每條記錄的索引  let index = 0;  //循環載入數據  function loop(curTotal,curIndex){      if(curTotal <= 0){          return false;      }      //每頁多少條      let pageCount = Math.min(curTotal , once);      window.requestAnimationFrame(function(){          let fragment = document.createDocumentFragment();          for(let i = 0; i < pageCount; i++){              let li = document.createElement('li');              li.innerText = curIndex + i + ' : ' + ~~(Math.random() * total)              fragment.appendChild(li)          }          ul.appendChild(fragment)          loop(curTotal - pageCount,curIndex + pageCount)      })  }  loop(total,index);

最後

本文更多的是提供一個思路,通過時間分片的方式來同時載入大量簡單DOM。對於複雜DOM的情況,一般會用到虛擬列表的方式來實現,關於這一問題,會持續整理,敬請期待。

參考

  • https://www.cnblogs.com/coco1s/archive/2017/12/13/8029582.html
  • https://www.cnblogs.com/onepixel/p/7078617.htm