欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

JavaScript利用虛擬列表實現(xiàn)高性能渲染數(shù)據(jù)詳解

 更新時間:2023年05月23日 11:34:02   作者:云中橋  
在前文中我們提到可以使用時間分片的方式來對長列表進行渲染,但這種方式更適用于列表項的DOM結(jié)構(gòu)十分簡單的情況,所以本文來講講如何使用虛擬列表的方式,來同時加載大量數(shù)據(jù)吧

前言

在工作中,有時會遇到需要一些不能使用分頁方式來加載列表數(shù)據(jù)的業(yè)務(wù)情況,對于此,我們稱這種列表叫做長列表。比如,在一些外匯交易系統(tǒng)中,前端會實時的展示用戶的持倉情況(收益、虧損、手數(shù)等),此時對于用戶的持倉列表一般是不能分頁的。

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

為什么需要使用虛擬列表

假設(shè)我們的長列表需要展示10000條記錄,我們同時將10000條記錄渲染到頁面中,先來看看需要花費多長時間:

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

當我們點擊按鈕,會同時向頁面中加入一萬條記錄,通過控制臺的輸出,我們可以粗略的統(tǒng)計到,JS的運行時間為38ms,但渲染完成后的總時間為957ms。

簡單說明一下,為何兩次console.log的結(jié)果時間差異巨大,并且是如何簡單來統(tǒng)計JS運行時間和總渲染時間:

  • 在 JS 的Event Loop中,當JS引擎所管理的執(zhí)行棧中的事件以及所有微任務(wù)事件全部執(zhí)行完后,才會觸發(fā)渲染線程對頁面進行渲染
  • 第一個console.log的觸發(fā)時間是在頁面進行渲染之前,此時得到的間隔時間為JS運行所需要的時間
  • 第二個console.log是放到 setTimeout 中的,它的觸發(fā)時間是在渲染完成,在下一次Event Loop中執(zhí)行的

關(guān)于Event Loop的詳細內(nèi)容請參見這篇文章-->JS進階之從多線程到Event Loop全面梳理

然后,我們通過Chrome的Performance工具來詳細的分析這段代碼的性能瓶頸在哪里:

Performance可以看出,代碼從執(zhí)行到渲染結(jié)束,共消耗了960.8ms,其中的主要時間消耗如下:

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

從這里我們可以看出,我們的代碼的執(zhí)行過程中,消耗時間最多的兩個階段是Recalculate StyleLayout。

  • Recalculate Style:樣式計算,瀏覽器根據(jù)css選擇器計算哪些元素應(yīng)該應(yīng)用哪些規(guī)則,確定每個元素具體的樣式。
  • Layout:布局,知道元素應(yīng)用哪些規(guī)則之后,瀏覽器開始計算它要占據(jù)的空間大小及其在屏幕的位置。

在實際的工作中,列表項必然不會像例子中僅僅只由一個li標簽組成,必然是由復(fù)雜DOM節(jié)點組成的。

那么可以想象的是,當列表項數(shù)過多并且列表項結(jié)構(gòu)復(fù)雜的時候,同時渲染時,會在Recalculate StyleLayout階段消耗大量的時間。

而虛擬列表就是解決這一問題的一種實現(xiàn)。

什么是虛擬列表

虛擬列表其實是按需顯示的一種實現(xiàn),即只對可見區(qū)域進行渲染,對非可見區(qū)域中的數(shù)據(jù)不渲染或部分渲染的技術(shù),從而達到極高的渲染性能。

假設(shè)有1萬條記錄需要同時渲染,我們屏幕的可見區(qū)域的高度為500px,而列表項的高度為50px,則此時我們在屏幕中最多只能看到10個列表項,那么在首次渲染的時候,我們只需加載10條即可。

說完首次加載,再分析一下當滾動發(fā)生時,我們可以通過計算當前滾動值得知此時在屏幕可見區(qū)域應(yīng)該顯示的列表項。

假設(shè)滾動發(fā)生,滾動條距頂部的位置為150px,則我們可得知在可見區(qū)域內(nèi)的列表項為第4項至`第13項。

實現(xiàn)

虛擬列表的實現(xiàn),實際上就是在首屏加載的時候,只加載可視區(qū)域內(nèi)需要的列表項,當滾動發(fā)生時,動態(tài)通過計算獲得可視區(qū)域內(nèi)的列表項,并將非可視區(qū)域內(nèi)存在的列表項刪除。

  • 計算當前可視區(qū)域起始數(shù)據(jù)索引(startIndex)
  • 計算當前可視區(qū)域結(jié)束數(shù)據(jù)索引(endIndex)
  • 計算當前可視區(qū)域的數(shù)據(jù),并渲染到頁面中
  • 計算startIndex對應(yīng)的數(shù)據(jù)在整個列表中的偏移位置startOffset并設(shè)置到列表上

由于只是對可視區(qū)域內(nèi)的列表項進行渲染,所以為了保持列表容器的高度并可正常的觸發(fā)滾動,將Html結(jié)構(gòu)設(shè)計成如下結(jié)構(gòu):

<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可視區(qū)域的容器
  • infinite-list-phantom 為容器內(nèi)的占位,高度為總列表高度,用于形成滾動條
  • infinite-list 為列表項的渲染區(qū)域

接著,監(jiān)聽infinite-list-containerscroll事件,獲取滾動位置scrollTop

  • 假定可視區(qū)域高度固定,稱之為screenHeight
  • 假定列表每項高度固定,稱之為itemSize
  • 假定列表數(shù)據(jù)稱之為listData
  • 假定當前滾動位置稱之為scrollTop

則可推算出:

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

當滾動后,由于渲染區(qū)域相對于可視區(qū)域已經(jīng)發(fā)生了偏移,此時我需要獲取一個偏移量startOffset,通過樣式控制將渲染區(qū)域偏移至可視區(qū)域中。

偏移量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: {
    //所有列表數(shù)據(jù)
    listData:{
      type:Array,
      default:()=>[]
    },
    //每項高度
    itemSize: {
      type: Number,
      default:200
    }
  },
  computed:{
    //列表總高度
    listHeight(){
      return this.listData.length * this.itemSize;
    },
    //可顯示的列表項數(shù)
    visibleCount(){
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    //偏移量對應(yīng)的style
    getTransform(){
      return `translate3d(0,${this.startOffset}px,0)`;
    },
    //獲取真實顯示列表數(shù)據(jù)
    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 {
      //可視區(qū)域高度
      screenHeight:0,
      //偏移量
      startOffset:0,
      //起始索引
      start:0,
      //結(jié)束索引
      end:null,
    };
  },
  methods: {
    scrollEvent() {
      //當前滾動位置
      let scrollTop = this.$refs.list.scrollTop;
      //此時的開始索引
      this.start = Math.floor(scrollTop / this.itemSize);
      //此時的結(jié)束索引
      this.end = this.start + this.visibleCount;
      //此時的偏移量
      this.startOffset = scrollTop - (scrollTop % this.itemSize);
    }
  }
};

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

最終效果如下:

列表項動態(tài)高度

在之前的實現(xiàn)中,列表項的高度是固定的,因為高度固定,所以可以很輕易的獲取列表項的整體高度以及滾動時的顯示數(shù)據(jù)與對應(yīng)的偏移量。而實際應(yīng)用的時候,當列表中包含文本之類的可變內(nèi)容,會導致列表項的高度并不相同。

比如這種情況:

在虛擬列表中應(yīng)用動態(tài)高度的解決方案一般有如下三種:

1.對組件屬性itemSize進行擴展,支持傳遞類型為數(shù)字、數(shù)組、函數(shù)

  • 可以是一個固定值,如 100,此時列表項是固高的
  • 可以是一個包含所有列表項高度的數(shù)據(jù),如 [50, 20, 100, 80, ...]
  • 可以是一個根據(jù)列表項索引返回其高度的函數(shù):(index: number): number

這種方式雖然有比較好的靈活度,但僅適用于可以預(yù)先知道或可以通過計算得知列表項高度的情況,依然無法解決列表項高度由內(nèi)容撐開的情況。

2.將列表項渲染到屏幕外,對其高度進行測量并緩存,然后再將其渲染至可視區(qū)域內(nèi)。

由于預(yù)先渲染至屏幕外,再渲染至屏幕內(nèi),這導致渲染成本增加一倍,這對于數(shù)百萬用戶在低端移動設(shè)備上使用的產(chǎn)品來說是不切實際的。

3.以預(yù)估高度先行渲染,然后獲取真實高度并緩存。

這是我選擇的實現(xiàn)方式,可以避免前兩種方案的不足。

接下來,來看如何簡易的實現(xiàn):

定義組件屬性estimatedItemSize,用于接收預(yù)估高度

props: {
  //預(yù)估高度
  estimatedItemSize:{
    type:Number
  }
}

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

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

并在初始時根據(jù)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;
}

由于需要在渲染完成后,獲取列表每項的位置信息并緩存,所以使用鉤子函數(shù)updated來實現(xiàn):

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;
}

由于我們的緩存數(shù)據(jù),本身就是有順序的,所以獲取開始索引的方法可以考慮通過二分查找的方式來降低檢索次數(shù):

//獲取列表起始索引
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 來創(chuàng)建一些隨機數(shù)據(jù)

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

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

最終效果如下:

從演示效果上看,我們實現(xiàn)了基于文字內(nèi)容動態(tài)撐高列表項情況下的虛擬列表,但是我們可能會發(fā)現(xiàn),當滾動過快時,會出現(xiàn)短暫的白屏現(xiàn)象

為了使頁面平滑滾動,我們還需要在可見區(qū)域的上方和下方渲染額外的項目,在滾動時給予一些緩沖,所以將屏幕分為三個區(qū)域:

  • 可視區(qū)域上方:above
  • 可視區(qū)域:screen
  • 可視區(qū)域下方:below

定義組件屬性bufferScale,用于接收緩沖區(qū)數(shù)據(jù)可視區(qū)數(shù)據(jù)比例

props: {
  //緩沖區(qū)比例
  bufferScale:{
    type:Number,
    default:1
  }
}

可視區(qū)上方渲染條數(shù)aboveCount獲取方式如下:

aboveCount(){
  return Math.min(this.start,this.bufferScale * this.visibleCount)
}

可視區(qū)下方渲染條數(shù)belowCount獲取方式如下:

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

真實渲染數(shù)據(jù)visibleData獲取方式如下:

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

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

最終效果如下:

基于這個方案,個人開發(fā)了一個基于Vue2.x的虛擬列表組件:vue-virtual-listview,可點擊查看完整代碼。

面向未來

在前文中我們使用監(jiān)聽scroll事件的方式來觸發(fā)可視區(qū)域中數(shù)據(jù)的更新,當滾動發(fā)生后,scroll事件會頻繁觸發(fā),很多時候會造成重復(fù)計算的問題,從性能上來說無疑存在浪費的情況。

可以使用IntersectionObserver替換監(jiān)聽scroll事件,IntersectionObserver可以監(jiān)聽目標元素是否出現(xiàn)在可視區(qū)域內(nèi),在監(jiān)聽的回調(diào)事件中執(zhí)行可視區(qū)域數(shù)據(jù)的更新,并且IntersectionObserver的監(jiān)聽回調(diào)是異步觸發(fā),不隨著目標元素的滾動而觸發(fā),性能消耗極低。

遺留問題

我們雖然實現(xiàn)了根據(jù)列表項動態(tài)高度下的虛擬列表,但如果列表項中包含圖片,并且列表高度由圖片撐開,由于圖片會發(fā)送網(wǎng)絡(luò)請求,此時無法保證我們在獲取列表項真實高度時圖片是否已經(jīng)加載完成,從而造成計算不準確的情況。

這種情況下,如果我們能監(jiān)聽列表項的大小變化就能獲取其真正的高度了。我們可以使用ResizeObserver來監(jiān)聽列表項內(nèi)容區(qū)域的高度改變,從而實時獲取每一列表項的高度。

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

以上就是JavaScript利用虛擬列表實現(xiàn)高性能渲染數(shù)據(jù)詳解的詳細內(nèi)容,更多關(guān)于JavaScript渲染數(shù)據(jù)的資料請關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

最新評論