JavaScript利用虛擬列表實現(xiàn)高性能渲染數(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 Style
和Layout
。
Recalculate Style
:樣式計算,瀏覽器根據(jù)css選擇器計算哪些元素應(yīng)該應(yīng)用哪些規(guī)則,確定每個元素具體的樣式。Layout
:布局,知道元素應(yīng)用哪些規(guī)則之后,瀏覽器開始計算它要占據(jù)的空間大小及其在屏幕的位置。
在實際的工作中,列表項必然不會像例子中僅僅只由一個li標簽組成,必然是由復(fù)雜DOM節(jié)點組成的。
那么可以想象的是,當列表項數(shù)過多并且列表項結(jié)構(gòu)復(fù)雜的時候,同時渲染時,會在Recalculate Style
和Layout
階段消耗大量的時間。
而虛擬列表就是解決這一問題的一種實現(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-container
的scroll
事件,獲取滾動位置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); } } };
最終效果如下:
列表項動態(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ù)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; }
由于需要在渲染完成
后,獲取列表每項的位置信息并緩存,所以使用鉤子函數(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() // 長文本 }) }
最終效果如下:
從演示效果上看,我們實現(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); }
最終效果如下:
基于這個方案,個人開發(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)文章
微信小程序?qū)崿F(xiàn)動態(tài)獲取元素寬高的方法分析
這篇文章主要介紹了微信小程序?qū)崿F(xiàn)動態(tài)獲取元素寬高的方法,結(jié)合實例形式分析了微信小程序動態(tài)獲取、設(shè)置元素寬高的相關(guān)操作技巧與注意事項,需要的朋友可以參考下2018-12-12高性能Javascript筆記 數(shù)據(jù)的存儲與訪問性能優(yōu)化
在JavaScript中,數(shù)據(jù)的存儲位置對代碼的整體性能有著重要的影響。有四種數(shù)據(jù)訪問類型:直接量,局部變量,數(shù)組項,對象成員2012-08-08js利用與或運算符優(yōu)先級實現(xiàn)if else條件判斷表達式
利用與或運算符優(yōu)先級實現(xiàn)if else運算,讓你的代碼更精簡。2010-04-04JavaScript計算值然后把值嵌入到html中的實現(xiàn)方法
下面小編就為大家?guī)硪黄狫avaScript計算值然后把值嵌入到html中的實現(xiàn)方法。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2016-10-10