在工做中,有時會遇到須要一些不能使用分頁方式來加載列表數據的業務狀況,對於此,咱們稱這種列表叫作長列表
。好比,在一些外匯交易系統中,前端會實時的展現用戶的持倉狀況(收益、虧損、手數等),此時對於用戶的持倉列表通常是不能分頁的。javascript
在高性能渲染十萬條數據(時間分片)一文中,提到了可使用時間分片
的方式來對長列表進行渲染,但這種方式更適用於列表項的DOM結構十分簡單的狀況。本文會介紹使用虛擬列表
的方式,來同時加載大量數據。css
假設咱們的長列表須要展現10000條記錄,咱們同時將10000條記錄渲染到頁面中,先來看看須要花費多長時間:html
<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運行時間
和總渲染時間
:vue
Event Loop
中,當JS引擎所管理的執行棧中的事件以及全部微任務事件所有執行完後,纔會觸發渲染線程對頁面進行渲染console.log
的觸發時間是在頁面進行渲染以前,此時獲得的間隔時間爲JS運行所須要的時間console.log
是放到 setTimeout 中的,它的觸發時間是在渲染完成,在下一次Event Loop
中執行的關於Event Loop的詳細內容請參見這篇文章-->java
而後,咱們經過Chrome
的Performance
工具來詳細的分析這段代碼的性能瓶頸在哪裏:node
從Performance
能夠看出,代碼從執行到渲染結束,共消耗了960.8ms
,其中的主要時間消耗以下:react
40.84ms
105.08ms
731.56ms
58.87ms
15.32ms
從這裏咱們能夠看出,咱們的代碼的執行過程當中,消耗時間最多的兩個階段是Recalculate Style
和Layout
。git
Recalculate Style
:樣式計算,瀏覽器根據css選擇器計算哪些元素應該應用哪些規則,肯定每一個元素具體的樣式。Layout
:佈局,知道元素應用哪些規則以後,瀏覽器開始計算它要佔據的空間大小及其在屏幕的位置。在實際的工做中,列表項必然不會像例子中僅僅只由一個li標籤組成,必然是由複雜DOM節點組成的。github
那麼能夠想象的是,當列表項數過多而且列表項結構複雜的時候,同時渲染時,會在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 * itemSizevisibleCount
= Math.ceil(screenHeight / itemSize)startIndex
= Math.floor(scrollTop / itemSize)endIndex
= startIndex + visibleCountvisibleData
= 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);
}
}
};
複製代碼
最終效果以下:
在以前的實現中,列表項的高度是固定的,由於高度固定,因此能夠很輕易的獲取列表項的總體高度以及滾動時的顯示數據與對應的偏移量。而實際應用的時候,當列表中包含文本之類的可變內容,會致使列表項的高度並不相同。
好比這種狀況:
在虛擬列表中應用動態高度的解決方案通常有以下三種:
1.對組件屬性
itemSize
進行擴展,支持傳遞類型爲數字
、數組
、函數
這種方式雖然有比較好的靈活度,但僅適用於能夠預先知道或能夠經過計算得知列表項高度的狀況,依然沒法解決列表項高度由內容撐開的狀況。
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() // 長文本
})
}
複製代碼
最終效果以下:
從演示效果上看,咱們實現了基於文字內容動態撐高列表項
狀況下的虛擬列表
,可是咱們可能會發現,當滾動過快時,會出現短暫的白屏現象
。
爲了使頁面平滑滾動,咱們還須要在可見區域
的上方和下方渲染額外的項目,在滾動時給予一些緩衝
,因此將屏幕分爲三個區域:
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);
}
複製代碼
最終效果以下:
基於這個方案,我的開發了一個基於Vue2.x的虛擬列表組件:vue-virtual-listview,可點擊查看完整代碼。
在前文中咱們使用監聽scroll事件
的方式來觸發可視區域中數據的更新,當滾動發生後,scroll事件會頻繁觸發,不少時候會形成重複計算
的問題,從性能上來講無疑存在浪費的狀況。
可使用IntersectionObserver替換監聽scroll事件,IntersectionObserver
能夠監聽目標元素是否出如今可視區域內,在監聽的回調事件中執行可視區域數據的更新,而且IntersectionObserver
的監聽回調是異步觸發,不隨着目標元素的滾動而觸發,性能消耗極低。
咱們雖然實現了根據列表項動態高度下的虛擬列表,但若是列表項中包含圖片,而且列表高度由圖片撐開,因爲圖片會發送網絡請求,此時沒法保證咱們在獲取列表項真實高度時圖片是否已經加載完成,從而形成計算不許確的狀況。
這種狀況下,若是咱們能監聽列表項的大小變化就能獲取其真正的高度了。咱們可使用ResizeObserver來監聽列表項內容區域的高度改變,從而實時獲取每一列表項的高度。
不過遺憾的是,在撰寫本文的時候,僅有少數瀏覽器支持ResizeObserver
。
歡迎關注微信公衆號
【前端小黑屋】
,每週1-3篇精品優質文章推送,助你走上進階之旅
同時歡迎加我好友,回覆
加羣
,拉你入羣,和我一塊兒學前端~