JavaScript利用虛擬列表實(shí)現(xiàn)高性能渲染數(shù)據(jù)詳解
前言
在工作中,有時(shí)會(huì)遇到需要一些不能使用分頁方式來加載列表數(shù)據(jù)的業(yè)務(wù)情況,對于此,我們稱這種列表叫做長列表。比如,在一些外匯交易系統(tǒng)中,前端會(huì)實(shí)時(shí)的展示用戶的持倉情況(收益、虧損、手?jǐn)?shù)等),此時(shí)對于用戶的持倉列表一般是不能分頁的。
在高性能渲染十萬條數(shù)據(jù)(時(shí)間分片)一文中,提到了可以使用時(shí)間分片的方式來對長列表進(jìn)行渲染,但這種方式更適用于列表項(xiàng)的DOM結(jié)構(gòu)十分簡單的情況。本文會(huì)介紹使用虛擬列表的方式,來同時(shí)加載大量數(shù)據(jù)。
為什么需要使用虛擬列表
假設(shè)我們的長列表需要展示10000條記錄,我們同時(shí)將10000條記錄渲染到頁面中,先來看看需要花費(fèi)多長時(shí)間:
<button id="button">button</button><br> <ul id="container"></ul>
document.getElementById('button').addEventListener('click',function(){
// 記錄任務(wù)開始時(shí)間
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運(yùn)行時(shí)間:',Date.now() - now);
setTimeout(()=>{
console.log('總運(yùn)行時(shí)間:',Date.now() - now);
},0)
// print JS運(yùn)行時(shí)間: 38
// print 總運(yùn)行時(shí)間: 957
})當(dāng)我們點(diǎn)擊按鈕,會(huì)同時(shí)向頁面中加入一萬條記錄,通過控制臺(tái)的輸出,我們可以粗略的統(tǒng)計(jì)到,JS的運(yùn)行時(shí)間為38ms,但渲染完成后的總時(shí)間為957ms。
簡單說明一下,為何兩次console.log的結(jié)果時(shí)間差異巨大,并且是如何簡單來統(tǒng)計(jì)JS運(yùn)行時(shí)間和總渲染時(shí)間:
- 在 JS 的Event Loop中,當(dāng)JS引擎所管理的執(zhí)行棧中的事件以及所有微任務(wù)事件全部執(zhí)行完后,才會(huì)觸發(fā)渲染線程對頁面進(jìn)行渲染
- 第一個(gè)console.log的觸發(fā)時(shí)間是在頁面進(jìn)行渲染之前,此時(shí)得到的間隔時(shí)間為JS運(yùn)行所需要的時(shí)間
- 第二個(gè)console.log是放到 setTimeout 中的,它的觸發(fā)時(shí)間是在渲染完成,在下一次Event Loop中執(zhí)行的
關(guān)于Event Loop的詳細(xì)內(nèi)容請參見這篇文章-->JS進(jìn)階之從多線程到Event Loop全面梳理
然后,我們通過Chrome的Performance工具來詳細(xì)的分析這段代碼的性能瓶頸在哪里:

從Performance可以看出,代碼從執(zhí)行到渲染結(jié)束,共消耗了960.8ms,其中的主要時(shí)間消耗如下:
- Event(click) :
40.84ms - Recalculate Style :
105.08ms - Layout :
731.56ms - Update Layer Tree :
58.87ms - Paint :
15.32ms
從這里我們可以看出,我們的代碼的執(zhí)行過程中,消耗時(shí)間最多的兩個(gè)階段是Recalculate Style和Layout。
Recalculate Style:樣式計(jì)算,瀏覽器根據(jù)css選擇器計(jì)算哪些元素應(yīng)該應(yīng)用哪些規(guī)則,確定每個(gè)元素具體的樣式。Layout:布局,知道元素應(yīng)用哪些規(guī)則之后,瀏覽器開始計(jì)算它要占據(jù)的空間大小及其在屏幕的位置。
在實(shí)際的工作中,列表項(xiàng)必然不會(huì)像例子中僅僅只由一個(gè)li標(biāo)簽組成,必然是由復(fù)雜DOM節(jié)點(diǎn)組成的。
那么可以想象的是,當(dāng)列表項(xiàng)數(shù)過多并且列表項(xiàng)結(jié)構(gòu)復(fù)雜的時(shí)候,同時(shí)渲染時(shí),會(huì)在Recalculate Style和Layout階段消耗大量的時(shí)間。
而虛擬列表就是解決這一問題的一種實(shí)現(xiàn)。
什么是虛擬列表
虛擬列表其實(shí)是按需顯示的一種實(shí)現(xiàn),即只對可見區(qū)域進(jìn)行渲染,對非可見區(qū)域中的數(shù)據(jù)不渲染或部分渲染的技術(shù),從而達(dá)到極高的渲染性能。
假設(shè)有1萬條記錄需要同時(shí)渲染,我們屏幕的可見區(qū)域的高度為500px,而列表項(xiàng)的高度為50px,則此時(shí)我們在屏幕中最多只能看到10個(gè)列表項(xiàng),那么在首次渲染的時(shí)候,我們只需加載10條即可。

說完首次加載,再分析一下當(dāng)滾動(dòng)發(fā)生時(shí),我們可以通過計(jì)算當(dāng)前滾動(dòng)值得知此時(shí)在屏幕可見區(qū)域應(yīng)該顯示的列表項(xiàng)。
假設(shè)滾動(dòng)發(fā)生,滾動(dòng)條距頂部的位置為150px,則我們可得知在可見區(qū)域內(nèi)的列表項(xiàng)為第4項(xiàng)至`第13項(xiàng)。

實(shí)現(xiàn)
虛擬列表的實(shí)現(xiàn),實(shí)際上就是在首屏加載的時(shí)候,只加載可視區(qū)域內(nèi)需要的列表項(xiàng),當(dāng)滾動(dòng)發(fā)生時(shí),動(dòng)態(tài)通過計(jì)算獲得可視區(qū)域內(nèi)的列表項(xiàng),并將非可視區(qū)域內(nèi)存在的列表項(xiàng)刪除。
- 計(jì)算當(dāng)前
可視區(qū)域起始數(shù)據(jù)索引(startIndex) - 計(jì)算當(dāng)前
可視區(qū)域結(jié)束數(shù)據(jù)索引(endIndex) - 計(jì)算當(dāng)前
可視區(qū)域的數(shù)據(jù),并渲染到頁面中 - 計(jì)算
startIndex對應(yīng)的數(shù)據(jù)在整個(gè)列表中的偏移位置startOffset并設(shè)置到列表上

由于只是對可視區(qū)域內(nèi)的列表項(xiàng)進(jìn)行渲染,所以為了保持列表容器的高度并可正常的觸發(fā)滾動(dòng),將Html結(jié)構(gòu)設(shè)計(jì)成如下結(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)的占位,高度為總列表高度,用于形成滾動(dòng)條infinite-list為列表項(xiàng)的渲染區(qū)域
接著,監(jiān)聽infinite-list-container的scroll事件,獲取滾動(dòng)位置scrollTop
- 假定
可視區(qū)域高度固定,稱之為screenHeight - 假定
列表每項(xiàng)高度固定,稱之為itemSize - 假定
列表數(shù)據(jù)稱之為listData - 假定
當(dāng)前滾動(dòng)位置稱之為scrollTop
則可推算出:
- 列表總高度
listHeight= listData.length * itemSize - 可顯示的列表項(xiàng)數(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)
當(dāng)滾動(dòng)后,由于渲染區(qū)域相對于可視區(qū)域已經(jīng)發(fā)生了偏移,此時(shí)我需要獲取一個(gè)偏移量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:()=>[]
},
//每項(xiàng)高度
itemSize: {
type: Number,
default:200
}
},
computed:{
//列表總高度
listHeight(){
return this.listData.length * this.itemSize;
},
//可顯示的列表項(xiàng)數(shù)
visibleCount(){
return Math.ceil(this.screenHeight / this.itemSize)
},
//偏移量對應(yīng)的style
getTransform(){
return `translate3d(0,${this.startOffset}px,0)`;
},
//獲取真實(shí)顯示列表數(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() {
//當(dāng)前滾動(dòng)位置
let scrollTop = this.$refs.list.scrollTop;
//此時(shí)的開始索引
this.start = Math.floor(scrollTop / this.itemSize);
//此時(shí)的結(jié)束索引
this.end = this.start + this.visibleCount;
//此時(shí)的偏移量
this.startOffset = scrollTop - (scrollTop % this.itemSize);
}
}
};最終效果如下:

列表項(xiàng)動(dòng)態(tài)高度
在之前的實(shí)現(xiàn)中,列表項(xiàng)的高度是固定的,因?yàn)楦叨裙潭?,所以可以很輕易的獲取列表項(xiàng)的整體高度以及滾動(dòng)時(shí)的顯示數(shù)據(jù)與對應(yīng)的偏移量。而實(shí)際應(yīng)用的時(shí)候,當(dāng)列表中包含文本之類的可變內(nèi)容,會(huì)導(dǎo)致列表項(xiàng)的高度并不相同。
比如這種情況:

在虛擬列表中應(yīng)用動(dòng)態(tài)高度的解決方案一般有如下三種:
1.對組件屬性itemSize進(jìn)行擴(kuò)展,支持傳遞類型為數(shù)字、數(shù)組、函數(shù)
- 可以是一個(gè)固定值,如 100,此時(shí)列表項(xiàng)是固高的
- 可以是一個(gè)包含所有列表項(xiàng)高度的數(shù)據(jù),如 [50, 20, 100, 80, ...]
- 可以是一個(gè)根據(jù)列表項(xiàng)索引返回其高度的函數(shù):(index: number): number
這種方式雖然有比較好的靈活度,但僅適用于可以預(yù)先知道或可以通過計(jì)算得知列表項(xiàng)高度的情況,依然無法解決列表項(xiàng)高度由內(nèi)容撐開的情況。
2.將列表項(xiàng)渲染到屏幕外,對其高度進(jìn)行測量并緩存,然后再將其渲染至可視區(qū)域內(nèi)。
由于預(yù)先渲染至屏幕外,再渲染至屏幕內(nèi),這導(dǎo)致渲染成本增加一倍,這對于數(shù)百萬用戶在低端移動(dòng)設(shè)備上使用的產(chǎn)品來說是不切實(shí)際的。
3.以預(yù)估高度先行渲染,然后獲取真實(shí)高度并緩存。
這是我選擇的實(shí)現(xiàn)方式,可以避免前兩種方案的不足。
接下來,來看如何簡易的實(shí)現(xiàn):
定義組件屬性estimatedItemSize,用于接收預(yù)估高度
props: {
//預(yù)估高度
estimatedItemSize:{
type:Number
}
}定義positions,用于列表項(xiàng)渲染后存儲(chǔ)每一項(xiàng)的高度以及位置信息,
this.positions = [
// {
// top:0,
// bottom:100,
// height:100
// }
];并在初始時(shí)根據(jù)estimatedItemSize對positions進(jìn)行初始化。
initPositions(){
this.positions = this.listData.map((item,index)=>{
return {
index,
height:this.estimatedItemSize,
top:index * this.estimatedItemSize,
bottom:(index + 1) * this.estimatedItemSize
}
})
}由于列表項(xiàng)高度不定,并且我們維護(hù)了positions,用于記錄每一項(xiàng)的位置,而列表高度實(shí)際就等于列表中最后一項(xiàng)的底部距離列表頂部的位置。
//列表總高度
listHeight(){
return this.positions[this.positions.length - 1].bottom;
}由于需要在渲染完成后,獲取列表每項(xiàng)的位置信息并緩存,所以使用鉤子函數(shù)updated來實(shí)現(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;
}
}
})
}滾動(dòng)后獲取列表開始索引的方法修改為通過緩存獲?。?/p>
//獲取列表起始索引
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;
},滾動(dòng)后將偏移量的獲取方式變更:
scrollEvent() {
//...省略
if(this.start >= 1){
this.startOffset = this.positions[this.start - 1].bottom
}else{
this.startOffset = 0;
}
}通過faker.js 來創(chuàng)建一些隨機(jī)數(shù)據(jù)
let data = [];
for (let id = 0; id < 10000; id++) {
data.push({
id,
value: faker.lorem.sentences() // 長文本
})
}最終效果如下:

從演示效果上看,我們實(shí)現(xiàn)了基于文字內(nèi)容動(dòng)態(tài)撐高列表項(xiàng)情況下的虛擬列表,但是我們可能會(huì)發(fā)現(xiàn),當(dāng)滾動(dòng)過快時(shí),會(huì)出現(xiàn)短暫的白屏現(xiàn)象。
為了使頁面平滑滾動(dòng),我們還需要在可見區(qū)域的上方和下方渲染額外的項(xiàng)目,在滾動(dòng)時(shí)給予一些緩沖,所以將屏幕分為三個(gè)區(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í)渲染數(shù)據(jù)visibleData獲取方式如下:
visibleData(){
let start = this.start - this.aboveCount;
let end = this.end + this.belowCount;
return this._listData.slice(start, end);
}最終效果如下:

基于這個(gè)方案,個(gè)人開發(fā)了一個(gè)基于Vue2.x的虛擬列表組件:vue-virtual-listview,可點(diǎn)擊查看完整代碼。
面向未來
在前文中我們使用監(jiān)聽scroll事件的方式來觸發(fā)可視區(qū)域中數(shù)據(jù)的更新,當(dāng)滾動(dòng)發(fā)生后,scroll事件會(huì)頻繁觸發(fā),很多時(shí)候會(huì)造成重復(fù)計(jì)算的問題,從性能上來說無疑存在浪費(fèi)的情況。
可以使用IntersectionObserver替換監(jiān)聽scroll事件,IntersectionObserver可以監(jiān)聽目標(biāo)元素是否出現(xiàn)在可視區(qū)域內(nèi),在監(jiān)聽的回調(diào)事件中執(zhí)行可視區(qū)域數(shù)據(jù)的更新,并且IntersectionObserver的監(jiān)聽回調(diào)是異步觸發(fā),不隨著目標(biāo)元素的滾動(dòng)而觸發(fā),性能消耗極低。
遺留問題
我們雖然實(shí)現(xiàn)了根據(jù)列表項(xiàng)動(dòng)態(tài)高度下的虛擬列表,但如果列表項(xiàng)中包含圖片,并且列表高度由圖片撐開,由于圖片會(huì)發(fā)送網(wǎng)絡(luò)請求,此時(shí)無法保證我們在獲取列表項(xiàng)真實(shí)高度時(shí)圖片是否已經(jīng)加載完成,從而造成計(jì)算不準(zhǔn)確的情況。
這種情況下,如果我們能監(jiān)聽列表項(xiàng)的大小變化就能獲取其真正的高度了。我們可以使用ResizeObserver來監(jiān)聽列表項(xiàng)內(nèi)容區(qū)域的高度改變,從而實(shí)時(shí)獲取每一列表項(xiàng)的高度。
不過遺憾的是,在撰寫本文的時(shí)候,僅有少數(shù)瀏覽器支持ResizeObserver。
以上就是JavaScript利用虛擬列表實(shí)現(xiàn)高性能渲染數(shù)據(jù)詳解的詳細(xì)內(nèi)容,更多關(guān)于JavaScript渲染數(shù)據(jù)的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JavaScript+CSS實(shí)現(xiàn)唯美蝴蝶動(dòng)畫
這篇文章主要介紹了JavaScript+CSS實(shí)現(xiàn)唯美蝴蝶動(dòng)畫,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值,需要的小伙伴可以參考一下2022-07-07
微信小程序?qū)崿F(xiàn)動(dòng)態(tài)獲取元素寬高的方法分析
這篇文章主要介紹了微信小程序?qū)崿F(xiàn)動(dòng)態(tài)獲取元素寬高的方法,結(jié)合實(shí)例形式分析了微信小程序動(dòng)態(tài)獲取、設(shè)置元素寬高的相關(guān)操作技巧與注意事項(xiàng),需要的朋友可以參考下2018-12-12
高性能Javascript筆記 數(shù)據(jù)的存儲(chǔ)與訪問性能優(yōu)化
在JavaScript中,數(shù)據(jù)的存儲(chǔ)位置對代碼的整體性能有著重要的影響。有四種數(shù)據(jù)訪問類型:直接量,局部變量,數(shù)組項(xiàng),對象成員2012-08-08
js利用與或運(yùn)算符優(yōu)先級實(shí)現(xiàn)if else條件判斷表達(dá)式
利用與或運(yùn)算符優(yōu)先級實(shí)現(xiàn)if else運(yùn)算,讓你的代碼更精簡。2010-04-04
簡單實(shí)現(xiàn)js選項(xiàng)卡切換效果
這篇文章主要為大家介紹了簡單實(shí)現(xiàn)js選項(xiàng)卡切換效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-02-02
JavaScript計(jì)算值然后把值嵌入到html中的實(shí)現(xiàn)方法
下面小編就為大家?guī)硪黄狫avaScript計(jì)算值然后把值嵌入到html中的實(shí)現(xiàn)方法。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2016-10-10
javascript中簡單的進(jìn)制轉(zhuǎn)換代碼實(shí)例
這篇文章介紹了javascript中簡單的進(jìn)制轉(zhuǎn)換代碼實(shí)例,有需要的朋友可以參考一下2013-10-10

