前言
在工作中,有時會遇到需要一些不能使用分頁方式來加載列表數據的業務情況,對於此,我們稱這種列表叫做長列表。比如,在一些外匯交易系統中,前端會實時的展示用戶的持倉情況(收益、虧損、手數等),此時對於用戶的持倉列表一般是不能分頁的。
在高性能渲染十萬條數據(時間分片)一文中,提到了可以使用時間分片的方式來對長列表進行渲染,但這種方式更適用於列表項的DOM結構十分簡單的情況。本文會介紹使用虛擬列表的方式,來同時加載大量數據。
為什么需要使用虛擬列表
假設我們的長列表需要展示10000條記錄,我們同時將10000條記錄渲染到頁面中,先來看看需要花費多長時間:
<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運行時間和總渲染時間:
- 在 JS 的Event Loop中,當JS引擎所管理的執行棧中的事件以及所有微任務事件全部執行完后,才會觸發渲染線程對頁面進行渲染
- 第一個console.log的觸發時間是在頁面進行渲染之前,此時得到的間隔時間為JS運行所需要的時間
- 第二個console.log是放到 setTimeout 中的,它的觸發時間是在渲染完成,在下一次Event Loop中執行的
關於Event Loop的詳細內容請參見這篇文章-->
然后,我們通過Chrome的Performance工具來詳細的分析這段代碼的性能瓶頸在哪里:

從Performance可以看出,代碼從執行到渲染結束,共消耗了960.8ms,其中的主要時間消耗如下:
- Event(click) : 40.84ms
- Recalculate Style : 105.08ms
- Layout : 731.56ms
- Update Layer Tree : 58.87ms
- Paint : 15.32ms
從這里我們可以看出,我們的代碼的執行過程中,消耗時間最多的兩個階段是Recalculate Style和Layout。
- Recalculate Style:樣式計算,瀏覽器根據css選擇器計算哪些元素應該應用哪些規則,確定每個元素具體的樣式。
- Layout:布局,知道元素應用哪些規則之后,瀏覽器開始計算它要占據的空間大小及其在屏幕的位置。
在實際的工作中,列表項必然不會像例子中僅僅只由一個li標簽組成,必然是由復雜DOM節點組成的。
那么可以想象的是,當列表項數過多並且列表項結構復雜的時候,同時渲染時,會在Recalculate Style和Layout階段消耗大量的時間。
而虛擬列表就是解決這一問題的一種實現。
什么是虛擬列表
虛擬列表其實是按需顯示的一種實現,即只對可見區域進行渲染,對非可見區域中的數據不渲染或部分渲染的技術,從而達到極高的渲染性能。
假設有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-container的scroll事件,獲取滾動位置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 // } ];
並在初始時根據estimatedItemSize對positions進行初始化。
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。
轉發來源:作者:雲中橋 鏈接:https://juejin.im/post/5db684ddf265da4d495c40e5
喜歡這篇文章?歡迎打賞~~