前言
在工作中,有時會遇到需要一些不能使用分頁方式來加載清單資料的業務情況,對于此,我們稱這種清單叫做長清單。比如,在一些外彙交易系統中,前端會實時的展示使用者的持倉情況(收益、虧損、手數等),此時對于使用者的持倉清單一般是不能分頁的。
在高性能渲染十萬條資料(時間分片)一文中,提到了可以使用時間分片的方式來對長清單進行渲染,但這種方式更适用于清單項的DOM結構十分簡單的情況。本文會介紹使用虛拟清單的方式,來同時加載大量資料。
為什麼需要使用虛拟清單
假設我們的長清單需要展示10000條記錄,我們同時将10000條記錄渲染到頁面中,先來看看需要花費多長時間:
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結構設計成如下結構:
- 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);
最終的簡易代碼如下:
{{ item.value }}
複制代碼
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
滾動後擷取清單開始索引的方法修改為通過緩存擷取:
//擷取清單起始索引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。
參考
- 淺說虛拟清單的實作原理
- react-virtualized元件的虛拟清單實作
- React和無限清單
- 再談前端虛拟清單的實作