「前端進階」高性能渲染十萬條數據(虛擬列表)

前言

在工做中,有時會遇到須要一些不能使用分頁方式來加載列表數據的業務狀況,對於此,咱們稱這種列表叫作長列表。好比,在一些外匯交易系統中,前端會實時的展現用戶的持倉狀況(收益、虧損、手數等),此時對於用戶的持倉列表通常是不能分頁的。javascript

高性能渲染十萬條數據(時間分片)一文中,提到了可使用時間分片的方式來對長列表進行渲染,但這種方式更適用於列表項的DOM結構十分簡單的狀況。本文會介紹使用虛擬列表的方式,來同時加載大量數據。css

爲何須要使用虛擬列表

假設咱們的長列表須要展現10000條記錄,咱們同時將10000條記錄渲染到頁面中,先來看看須要花費多長時間:html

<button id="button">button</button><br>
<ul id="container"></ul>  
複製代碼
document.getElementById('button').addEventListener('click',function(){
    // 記錄任務開始時間
    let now = Date.now();
    // 插入一萬條數據
    const total = 10000;
    // 獲取容器
    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運行時間: 38
    // print 總運行時間: 957 
  })
複製代碼

當咱們點擊按鈕,會同時向頁面中加入一萬條記錄,經過控制檯的輸出,咱們能夠粗略的統計到,JS的運行時間爲38ms,但渲染完成後的總時間爲957ms前端

簡單說明一下,爲什麼兩次console.log的結果時間差別巨大,而且是如何簡單來統計JS運行時間總渲染時間vue

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

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

而後,咱們經過ChromePerformance工具來詳細的分析這段代碼的性能瓶頸在哪裏:node

Performance能夠看出,代碼從執行到渲染結束,共消耗了960.8ms,其中的主要時間消耗以下:react

  • Event(click) : 40.84ms
  • Recalculate Style : 105.08ms
  • Layout : 731.56ms
  • Update Layer Tree : 58.87ms
  • Paint : 15.32ms

從這裏咱們能夠看出,咱們的代碼的執行過程當中,消耗時間最多的兩個階段是Recalculate StyleLayoutgit

  • Recalculate Style:樣式計算,瀏覽器根據css選擇器計算哪些元素應該應用哪些規則,肯定每一個元素具體的樣式。
  • Layout:佈局,知道元素應用哪些規則以後,瀏覽器開始計算它要佔據的空間大小及其在屏幕的位置。

在實際的工做中,列表項必然不會像例子中僅僅只由一個li標籤組成,必然是由複雜DOM節點組成的。github

那麼能夠想象的是,當列表項數過多而且列表項結構複雜的時候,同時渲染時,會在Recalculate StyleLayout階段消耗大量的時間。

虛擬列表就是解決這一問題的一種實現。

什麼是虛擬列表

虛擬列表實際上是按需顯示的一種實現,即只對可見區域進行渲染,對非可見區域中的數據不渲染或部分渲染的技術,從而達到極高的渲染性能。

假設有1萬條記錄須要同時渲染,咱們屏幕的可見區域的高度爲500px,而列表項的高度爲50px,則此時咱們在屏幕中最多隻能看到10個列表項,那麼在首次渲染的時候,咱們只需加載10條便可。

說完首次加載,再分析一下當滾動發生時,咱們能夠經過計算當前滾動值得知此時在屏幕可見區域應該顯示的列表項。

假設滾動發生,滾動條距頂部的位置爲150px,則咱們可得知在可見區域內的列表項爲第4項至`第13項。

實現

虛擬列表的實現,實際上就是在首屏加載的時候,只加載可視區域內須要的列表項,當滾動發生時,動態經過計算得到可視區域內的列表項,並將非可視區域內存在的列表項刪除。

  • 計算當前可視區域起始數據索引(startIndex)
  • 計算當前可視區域結束數據索引(endIndex)
  • 計算當前可視區域的數據,並渲染到頁面中
  • 計算startIndex對應的數據在整個列表中的偏移位置startOffset並設置到列表上

因爲只是對可視區域內的列表項進行渲染,因此爲了保持列表容器的高度並可正常的觸發滾動,將Html結構設計成以下結構:

<div class="infinite-list-container">
    <div class="infinite-list-phantom"></div>
    <div class="infinite-list">
      <!-- item-1 -->
      <!-- item-2 -->
      <!-- ...... -->
      <!-- item-n -->
    </div>
</div>
複製代碼
  • infinite-list-container可視區域的容器
  • infinite-list-phantom 爲容器內的佔位,高度爲總列表高度,用於造成滾動條
  • infinite-list 爲列表項的渲染區域

接着,監聽infinite-list-containerscroll事件,獲取滾動位置scrollTop

  • 假定可視區域高度固定,稱之爲screenHeight
  • 假定列表每項高度固定,稱之爲itemSize
  • 假定列表數據稱之爲listData
  • 假定當前滾動位置稱之爲scrollTop

則可推算出:

  • 列表總高度listHeight = listData.length * itemSize
  • 可顯示的列表項數visibleCount = Math.ceil(screenHeight / itemSize)
  • 數據的起始索引startIndex = Math.floor(scrollTop / itemSize)
  • 數據的結束索引endIndex = startIndex + visibleCount
  • 列表顯示數據爲visibleData = listData.slice(startIndex,endIndex)

當滾動後,因爲渲染區域相對於可視區域已經發生了偏移,此時我須要獲取一個偏移量startOffset,經過樣式控制將渲染區域偏移至可視區域中。

  • 偏移量startOffset = scrollTop - (scrollTop % itemSize);

最終的簡易代碼以下:

<template>
  <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
    <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="infinite-list" :style="{ transform: getTransform }">
      <div ref="items" class="infinite-list-item" v-for="item in visibleData" :key="item.id" :style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }" >{{ item.value }}</div>
    </div>
  </div>
</template>
複製代碼
export default {
  name:'VirtualList',
  props: {
    //全部列表數據
    listData:{
      type:Array,
      default:()=>[]
    },
    //每項高度
    itemSize: {
      type: Number,
      default:200
    }
  },
  computed:{
    //列表總高度
    listHeight(){
      return this.listData.length * this.itemSize;
    },
    //可顯示的列表項數
    visibleCount(){
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    //偏移量對應的style
    getTransform(){
      return `translate3d(0,${this.startOffset}px,0)`;
    },
    //獲取真實顯示列表數據
    visibleData(){
      return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
    }
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },
  data() {
    return {
      //可視區域高度
      screenHeight:0,
      //偏移量
      startOffset:0,
      //起始索引
      start:0,
      //結束索引
      end:null,
    };
  },
  methods: {
    scrollEvent() {
      //當前滾動位置
      let scrollTop = this.$refs.list.scrollTop;
      //此時的開始索引
      this.start = Math.floor(scrollTop / this.itemSize);
      //此時的結束索引
      this.end = this.start + this.visibleCount;
      //此時的偏移量
      this.startOffset = scrollTop - (scrollTop % this.itemSize);
    }
  }
};
複製代碼

點擊查看在線DEMO及完整代碼

最終效果以下:

列表項動態高度

在以前的實現中,列表項的高度是固定的,由於高度固定,因此能夠很輕易的獲取列表項的總體高度以及滾動時的顯示數據與對應的偏移量。而實際應用的時候,當列表中包含文本之類的可變內容,會致使列表項的高度並不相同。

好比這種狀況:

在虛擬列表中應用動態高度的解決方案通常有以下三種:

1.對組件屬性itemSize進行擴展,支持傳遞類型爲數字數組函數

  • 能夠是一個固定值,如 100,此時列表項是固高的
  • 能夠是一個包含全部列表項高度的數據,如 [50, 20, 100, 80, ...]
  • 能夠是一個根據列表項索引返回其高度的函數:(index: number): number

這種方式雖然有比較好的靈活度,但僅適用於能夠預先知道或能夠經過計算得知列表項高度的狀況,依然沒法解決列表項高度由內容撐開的狀況。

2.將列表項渲染到屏幕外,對其高度進行測量並緩存,而後再將其渲染至可視區域內。

因爲預先渲染至屏幕外,再渲染至屏幕內,這致使渲染成本增長一倍,這對於數百萬用戶在低端移動設備上使用的產品來講是不切實際的。

3.以預估高度先行渲染,而後獲取真實高度並緩存。

這是我選擇的實現方式,能夠避免前兩種方案的不足。

接下來,來看如何簡易的實現:

定義組件屬性estimatedItemSize,用於接收預估高度

props: {
  //預估高度
  estimatedItemSize:{
    type:Number
  }
}
複製代碼

定義positions,用於列表項渲染後存儲每一項的高度以及位置信息,

this.positions = [
  // {
  // top:0,
  // bottom:100,
  // height:100
  // }
];
複製代碼

並在初始時根據estimatedItemSizepositions進行初始化。

initPositions(){
  this.positions = this.listData.map((item,index)=>{
    return {
      index,
      height:this.estimatedItemSize,
      top:index * this.estimatedItemSize,
      bottom:(index + 1) * this.estimatedItemSize
    }
  })
}
複製代碼

因爲列表項高度不定,而且咱們維護了positions,用於記錄每一項的位置,而列表高度實際就等於列表中最後一項的底部距離列表頂部的位置。

//列表總高度
listHeight(){
  return this.positions[this.positions.length - 1].bottom;
}
複製代碼

因爲須要在渲染完成後,獲取列表每項的位置信息並緩存,因此使用鉤子函數updated來實現:

updated(){
  let nodes = this.$refs.items;
  nodes.forEach((node)=>{
    let rect = node.getBoundingClientRect();
    let height = rect.height;
    let index = +node.id.slice(1)
    let oldHeight = this.positions[index].height;
    let dValue = oldHeight - height;
    //存在差值
    if(dValue){
      this.positions[index].bottom = this.positions[index].bottom - dValue;
      this.positions[index].height = height;
      for(let k = index + 1;k<this.positions.length; k++){
        this.positions[k].top = this.positions[k-1].bottom;
        this.positions[k].bottom = this.positions[k].bottom - dValue;
      }
    }
  })
}
複製代碼

滾動後獲取列表開始索引的方法修改成經過緩存獲取:

//獲取列表起始索引
getStartIndex(scrollTop = 0){
  let item = this.positions.find(i => i && i.bottom > scrollTop);
  return item.index;
}
複製代碼

因爲咱們的緩存數據,自己就是有順序的,因此獲取開始索引的方法能夠考慮經過二分查找的方式來下降檢索次數:

//獲取列表起始索引
getStartIndex(scrollTop = 0){
  //二分法查找
  return this.binarySearch(this.positions,scrollTop)
},
//二分法查找
binarySearch(list,value){
  let start = 0;
  let end = list.length - 1;
  let tempIndex = null;
  while(start <= end){
    let midIndex = parseInt((start + end)/2);
    let midValue = list[midIndex].bottom;
    if(midValue === value){
      return midIndex + 1;
    }else if(midValue < value){
      start = midIndex + 1;
    }else if(midValue > value){
      if(tempIndex === null || tempIndex > midIndex){
        tempIndex = midIndex;
      }
      end = end - 1;
    }
  }
  return tempIndex;
},
複製代碼

滾動後將偏移量的獲取方式變動:

scrollEvent() {
  //...省略
  if(this.start >= 1){
    this.startOffset = this.positions[this.start - 1].bottom
  }else{
    this.startOffset = 0;
  }
}
複製代碼

經過faker.js 來建立一些隨機數據

let data = [];
for (let id = 0; id < 10000; id++) {
  data.push({
    id,
    value: faker.lorem.sentences() // 長文本
  })
}
複製代碼

點擊查看在線DEMO及完整代碼

最終效果以下:

從演示效果上看,咱們實現了基於文字內容動態撐高列表項狀況下的虛擬列表,可是咱們可能會發現,當滾動過快時,會出現短暫的白屏現象

爲了使頁面平滑滾動,咱們還須要在可見區域的上方和下方渲染額外的項目,在滾動時給予一些緩衝,因此將屏幕分爲三個區域:

  • 可視區域上方:above
  • 可視區域:screen
  • 可視區域下方:below

定義組件屬性bufferScale,用於接收緩衝區數據可視區數據比例

props: {
  //緩衝區比例
  bufferScale:{
    type:Number,
    default:1
  }
}
複製代碼

可視區上方渲染條數aboveCount獲取方式以下:

aboveCount(){
  return Math.min(this.start,this.bufferScale * this.visibleCount)
}
複製代碼

可視區下方渲染條數belowCount獲取方式以下:

belowCount(){
  return Math.min(this.listData.length - this.end,this.bufferScale * this.visibleCount);
}
複製代碼

真實渲染數據visibleData獲取方式以下:

visibleData(){
  let start = this.start - this.aboveCount;
  let end = this.end + this.belowCount;
  return this._listData.slice(start, end);
}
複製代碼

點擊查看在線DEMO及完整代碼

最終效果以下:

基於這個方案,我的開發了一個基於Vue2.x的虛擬列表組件:vue-virtual-listview,可點擊查看完整代碼

面向將來

在前文中咱們使用監聽scroll事件的方式來觸發可視區域中數據的更新,當滾動發生後,scroll事件會頻繁觸發,不少時候會形成重複計算的問題,從性能上來講無疑存在浪費的狀況。

可使用IntersectionObserver替換監聽scroll事件,IntersectionObserver能夠監聽目標元素是否出如今可視區域內,在監聽的回調事件中執行可視區域數據的更新,而且IntersectionObserver的監聽回調是異步觸發,不隨着目標元素的滾動而觸發,性能消耗極低。

遺留問題

咱們雖然實現了根據列表項動態高度下的虛擬列表,但若是列表項中包含圖片,而且列表高度由圖片撐開,因爲圖片會發送網絡請求,此時沒法保證咱們在獲取列表項真實高度時圖片是否已經加載完成,從而形成計算不許確的狀況。

這種狀況下,若是咱們能監聽列表項的大小變化就能獲取其真正的高度了。咱們可使用ResizeObserver來監聽列表項內容區域的高度改變,從而實時獲取每一列表項的高度。

不過遺憾的是,在撰寫本文的時候,僅有少數瀏覽器支持ResizeObserver

參考

寫在最後

  • 文中若有錯誤,歡迎在評論區指正,若是這篇文章幫到了你,歡迎點贊關注
  • 本文同步首發與github,可在github中找到更多精品文章,歡迎Watch & Star ★
  • 後續文章參見:計劃

歡迎關注微信公衆號【前端小黑屋】,每週1-3篇精品優質文章推送,助你走上進階之旅

同時歡迎加我好友,回覆加羣,拉你入羣,和我一塊兒學前端~

相關文章
相關標籤/搜索