這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 1. 介紹 什麼是虛擬滾動?虛擬滾動就是通過js控制大列表中的dom創建與銷毀,只創建可視區域dom,非可視區域的dom不創建。這樣在渲染大列表中的數據時,只創建少數的dom,提高性能。 2. 分類 在虛擬滾動技術中,虛擬滾動可以分為定高 ...
這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助
1. 介紹
什麼是虛擬滾動
?虛擬滾動
就是通過js控制大列表中的dom創建與銷毀,只創建可視區域dom
,非可視區域的dom
不創建。這樣在渲染大列表中的數據時,只創建少數的dom,提高性能。
2. 分類
在虛擬滾動技術中,虛擬滾動可以分為定高虛擬滾動
和非定高虛擬滾動
。定高指的是每一個列表元素都是高度固定的,非定高指的是每一個列表元素的高度是動態變化的。定高虛擬滾動的實現比較容易,而且性能高;非定高虛擬滾動的失效稍微複雜,而且性能比定高虛擬滾動要差一些。無論是定高虛擬滾動還是非定高虛擬滾動,都是虛擬滾動技術的分類,對於大數據渲染都有很大的性能提升
。
下麵我們逐步分析兩種虛擬滾動技術的實現,並且封裝成常用的組件。選用技術棧是vue,改成react或angular也是十分方便的。
3. 定高虛擬滾動
3.1 封裝思路
虛擬滾動的結構如下圖:
虛擬滾動由圖三部分組成,渲染容器container
,渲染數據list
和撐開滾動條的容器clientHeightRef
。渲染容器就是我們需要渲染的區域,渲染數據就是可視區域的數據,撐開滾動條的容器代表所有數據渲染出來後的高度。
由於我們只渲染可視區域的數據,那麼渲染容器的滾動條高度是不正確的,需要撐開滾動條的容器來撐開實際的高度
。
html結構如下:
<div class="virtual-list"> <!-- 這裡是用於撐開高度,出現滾動條用 --> <div class="list-view-phantom" ref="clientHeightRef" :style="{ height: list.length*itemHeight + 'px' }"></div> <ul v-if="list.length > 0" class="option-warp" ref="contentRef"> <li :style="{ height: itemHeight + 'px' }" class="option" v-for="(item, index) in virtualRenderData" :key="index" > {{item}} </li> </ul> </div>
每一條數據的高度是this.itemHeight=10
,渲染容器的高度是this.containerHeight=300
,那麼一屏需要渲染的數據是count=Math.ceil(this.containerHeight / this.itemHeight)
。
假設我們我們的需要的數據list如下:
const list = [ {id:1,name:1}, {id:2,name:3}, .... ]
那麼撐開滾動條的容器的高度是this.list.length*this.itemHeight
。
我們給渲染容器加一個監聽滾動的事件,主要是獲取當前滾動的scrollTop
,用來更新渲染可視區域的數據。如下,我們封裝一個更新渲染可視區域的數據函數:
const update = function(scrollTop = 0){ this.$nextTick(() => { // 獲取當前可展示數量 const count = Math.ceil(this.containerHeight / this.itemHeight) const start = Math.floor(scrollTop / this.itemHeight) // 取得可見區域的結束數據索引 const end = start + count // 計算出可見區域對應的數據,讓 Vue.js 更新 this.virtualRenderData = this.list.slice(start, end) }) }
當滾動條滾動的時候,我們需要從list
中截取當前渲染容器剛剛好可以渲染的數據,達到像真的滾動的了一樣。上面的滾動函數雖然已經更新了渲染可視區域的數據,但是當我們滾動的時候會發現內容塊被滾動到了上面,再次滾動的時候直接就不見了。這是由於滾動條是由撐開滾動條的容器撐開的,渲染的內容高度只有容器的高度,所以它只會在頂部出現,滾動的時候自然就不會動,效果如下:
所以當我們滾動滾動的時候,還需要將渲染內容往對應的方向偏移
。比如偏移的y方向距離就是scrollTop的距離,
this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${scrollTop * this.itemHeight}px, 0)`
這樣就能達到滾動的時候,渲染內容始終保持在渲染容器的頂部,好像真的隨著滾動條滾動而滾動。
但是實際上這樣的效果是不好的,因為我們更新的顆粒度
是按每一條數據來分的,而不是按scrollTop
來進行的,所以渲染內容的偏移量也需要按照每一條數據的顆粒度
來進行更新,代碼如下:
const update = function(scrollTop = 0){ this.$nextTick(() => { // 獲取當前可展示數量 const count = Math.ceil(this.containerHeight / this.itemHeight) const start = Math.floor(scrollTop / this.itemHeight) // 取得可見區域的結束數據索引 const end = start + count // 計算出可見區域對應的數據,讓 Vue.js 更新 this.virtualRenderData = this.list.slice(start, end) + this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${start * this.itemHeight}px, 0)` }) }
上面的代碼基本可以滿足基本的使用,但是當我們滾動比較快的時候,渲染區域底部會出現瞬間留白,是因為dom沒有及時的渲染,原因是我們只渲染剛剛好一屏的數據。
為了減少留白的出現,我們應該預渲染幾條數據bufferCount
,增加渲染緩存區間
:
const update = function(scrollTop = 0){ this.$nextTick(() => { // 獲取當前可展示數量 const count = Math.ceil(this.containerHeight / this.itemHeight) const start = Math.floor(scrollTop / this.itemHeight) // 取得可見區域的結束數據索引 + const end = start + count + bufferCount // 計算出可見區域對應的數據,讓 Vue.js 更新 this.virtualRenderData = this.list.slice(start, end) this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${start * this.itemHeight}px, 0)` }) }
3.2 完整代碼和演示地址
定高虛擬滾動演示地址:atdow.github.io/learning-co…
定高虛擬滾動代碼地址:github.com/atdow/learn…
4. 非定高虛擬滾動
4.1 封裝思路
看了上面的定高虛擬滾動,我們對虛擬滾動技術已經有了基本的瞭解。對於非定高虛擬滾動
,需要解決的最大問題就是每一條需要渲染的數據的高度是不確定
,這樣我們就很難確定一屏需要渲染多少條數據。
為了確定一屏需要渲染多少條數據,我們需要假設每條需要渲染數據的高度為一個假設值estimatedItemHeight=40
,定義一個用於存儲每一條渲染數據高度的數組itemHeightCache=[]
,定義一個用於存儲每一條渲染數據距離頂部距離的數組itemTopCache=[]
(用於提升性能用,後面會做解釋),以及定義撐開滾動條滾動容器高度的變數scrollBarHeight
。
假設我們我們的需要的數據list如下:
const list = [ {id:1,name:1}, {id:2,name:3}, .... ]
我們先初始化itemHeightCache、itemTopCache和scrollBarHeight
:
const estimatedTotalHeight = this.list.reduce((pre, current, index) => { // 給每一項一個虛擬高度 this.itemHeightCache[index] = { isEstimated: true, height: this.estimatedItemHeight } // 給每一項距頂部的虛擬高度 this.itemTopCache[index] = index === 0 ? 0 : this.itemTopCache[index - 1] + this.estimatedItemHeight return pre + this.estimatedItemHeight }, 0) // 列表總高 this.scrollBarHeight = estimatedTotalHeight
有了上面的初始化數據,我們就可以進行第一次假設渲染
了:
// 更新數據函數 const update = function() { const startIndex = this.getStartIndex() // 如果是奇數開始,就取其前一位偶數 if (startIndex % 2 !== 0) { this.startIndex = startIndex - 1 } else { this.startIndex = startIndex } this.endIndex = this.getEndIndex() this.visibleList = this.list.slice(this.startIndex, this.endIndex) // 移動渲染區域 if (this.$refs.contentRef) { this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${this.itemTopCache[this.startIndex]}px, 0)` } } // 獲取開始索引 cont getStartIndex = function() { const scrollTop = this.scrollTop // 每一項距頂部的距離 const arr = this.itemTopCache let index = -1 let left = 0, right = arr.length - 1, mid = Math.floor((left + right) / 2) // 判斷 有可迴圈項時進入 while (right - left > 1) { /* 二分法:拿每一次獲得到的 距頂部距離 scrollTop 同 獲得到的模擬每個列表據頂部的距離作比較。 arr[mid] 為虛擬列高度的中間項 不斷while 迴圈,利用二分之一將數組分割,減小搜索範圍 直到最終定位到 目標index 值 */ // 目標數在左側 if (scrollTop < arr[mid]) { right = mid mid = Math.floor((left + right) / 2) } else if (scrollTop > arr[mid]) { // 目標數在右側 left = mid mid = Math.floor((left + right) / 2) } else { index = mid return index } } index = left return index } // 獲取結束索引 const getEndIndex = function() { const clientHeight = this.$refs.scrollbarRef?.clientHeight //渲染容器高度 let itemHeightTotal = 0 let endIndex = 0 for (let i = this.startIndex; i < this.dataList.length; i++) { if (itemHeightTotal < clientHeight) { itemHeightTotal += this.itemHeightCache[i].height endIndex = i } else { break } } endIndex = endIndex return endIndex }
update函數
是用來更新需要渲染的數據的,核心邏輯就是獲取截取數據的開始索引getStartIndex
和結束索引getEndIndex
以及移動被渲染數據容器
。
當滾動條滾動的時候,我們就將scrollTop
存起來,這個時候從itemTopCache
中獲取距離scrollTop
最近的索引,就是我們需要截取數據的開始索引。因為itemTopCache
存儲的就是每一條數據距離頂部的距離,所以直接取就行了,這也是為什麼我們要先存儲itemTopCache
。因為滾動的時候,我們都要從itemTopCache
中使用二分法查找,不然就得從itemHeightCache
中從頭到尾一個一個遍歷去對比查找,在數據量大的時候容易造成卡頓。
getEndIndex
核心就是從itemHeightCache
(存儲每一條渲染數據高度的數組)中一條一條拿數據,從startIndex
開始拿,一直拿到剛好填滿渲染容器高度即可,就可以得到我們的截取數據的最後索引 (實際上這樣是不夠完美的,後面繼續講解)。
移動被渲染數據容器的技巧和上面定高虛擬滾動類似,這裡不做太多解釋。
在初始化完itemHeightCache、itemTopCache和scrollBarHeight
後,我們就可以手動調一次update
函數進行第一次渲染了(this.update()),使用的都是預設的假定值。
在說更新之前,我們需要先定義一下子組件,也就是每一條被渲染數據的容器。這樣當數據被更新渲染之後(需要通知暴露index
和height
參數),就可以得到真實的dom的高度
,通知我們去更新itemHeightCache、itemTopCache和scrollBarHeight
,更新邏輯如下:
const updateItemHeight = function({ index, height }) { // 每次創建的時候都會拋出事件,因為沒有處理非同步的情況,所以必須每次高度變化都需要更新 // dom元素載入後得到實際高度 重新賦值回去 this.itemHeightCache[index] = { isEstimated: false, height: height } // 重新確定列表的實際總高度 this.scrollBarHeight = this.itemHeightCache.reduce((pre, current) => { return pre + current.height }, 0) // 更新itemTopCache const newItemTopCache = [0] for (let i = 1, l = this.itemHeightCache.length; i < l; i++) { // 虛擬每項距頂部高度 + 實際每項高度 newItemTopCache[i] = this.itemTopCache[i - 1] + this.itemHeightCache[i - 1].height } // 獲得每一項距頂部的實際高度 this.itemTopCache = newItemTopCache }
dom更新完之後,初始化預定值計算出來需要的渲染數據就真的被渲染了,我們這個時候就可以再次調用update
函數再次更新數據,自動更新彌補到渲染真實一屏需要渲染的數據了。
const updateItemHeight = function({ index, height }) { // 每次創建的時候都會拋出事件,因為沒有處理非同步的情況,所以必須每次高度變化都需要更新 // dom元素載入後得到實際高度 重新賦值回去 this.itemHeightCache[index] = { isEstimated: false, height: height } // 重新確定列表的實際總高度 this.scrollBarHeight = this.itemHeightCache.reduce((pre, current) => { return pre + current.height }, 0) // 更新itemTopCache const newItemTopCache = [0] for (let i = 1, l = this.itemHeightCache.length; i < l; i++) { // 虛擬每項距頂部高度 + 實際每項高度 newItemTopCache[i] = this.itemTopCache[i - 1] + this.itemHeightCache[i - 1].height } // 獲得每一項距頂部的實際高度 this.itemTopCache = newItemTopCache + this.update() // 自動更新 }
當滾動的時候,存儲scrollTop
,手動調用update
函數,將會自動更新,整個過程如下:
html結構如下:
<div class="virtual-list-dynamic-height" ref="scrollbarRef" @scroll="onScroll"> <div class="list-view-phantom" :style="{ height: scrollBarHeight + 'px' }"></div> <!-- 列表總高 --> <ul ref="contentRef"> <Item v-for="item in visibleList" :data="item.data" :index="item.index" :key="item.index" @update-height="updateItemHeight" > {{item}} </Item> </ul> </div>
跟定高虛擬滾動不同點就是,需要定義子組件,同時傳遞給子組件index
索引。visibleList
需要定義為[{index:xxx,data:xxx}]
的數據格式,將index
給儲存起來,這樣在子組件更新的時候才能獲取到index
。
4.2 調優
在上面的代碼中,基本可以實現基礎的非定高虛擬滾動了,但是還是無法應對複雜的情況。
我們舉一個極端的例子:當一條數據的真實高度是200
,其他數據的真實高度高度是10
,渲染容器的高度是300
。在第一次假設渲染並且更新後我們的itemHeightCache、itemTopCache和scrollBarHeight
後,我們將會得到這樣的結果。渲染容器中渲染的是數據是第一條數據和剩下的9
條數據,剛剛好渲染一屏數據,這樣是沒有任何問題的。
當滾動條滾動的時候,我們滾動了20px
的距離,獲取到的startIndex
應該是0
,因為距離頂部最近的數據是第一條數據,這個就會造成下部空白20px
的區域。當滾動了80px
的時候,獲取到的startIndex
也是0
,原理同上,下部造成了空白區域將會是恐怖的80px
。
為瞭解決空白局域,靠緩衝渲染bufferCount
是不夠的,就算bufferCount
給了4
,多四條數據也無法填充滿空白區域。調大bufferCount
容易造成性能問題,也不能確定bufferCount
到底給多少才能合適。所以需要調整getEndIndex
的邏輯,不再是從startIndex
獲取到剛好填充滿渲染區域,而是從startIndex
獲取到剛好填充滿渲染區域+statIndex的高度
。這樣無論startIndex的高度
是多少,我們都能填充滿整個渲染容器,因為空白區域最大高度就是startIndex的高度
。同時我們在endIndex
上加上bufferCount
,就可以達到完美的效果。
// 獲取結束索引 const getEndIndex = function() { + const whiteHeight = this.scrollTop - this.itemTopCache[this.startIndex] // 出現留白的高度 const clientHeight = this.$refs.scrollbarRef?.clientHeight //渲染容器高度 let itemHeightTotal = 0 let endIndex = 0 for (let i = this.startIndex; i < this.dataList.length; i++) { + if (itemHeightTotal < clientHeight+whiteHeight) { itemHeightTotal += this.itemHeightCache[i].height endIndex = i } else { break } } + endIndex = endIndex + bufferCount return endIndex }
3.3 完整代碼和演示地址
非定高虛擬滾動演示地址:atdow.github.io/learning-co…
非定高虛擬滾動代碼地址:github.com/atdow/learn…
4 總結
有了非定高虛擬滾動組件,不就是可以應對各種情況了,為什麼還需要做定高虛擬滾動組件?
在上面的封裝思路中,我們能清晰知道非定高虛擬滾動組件是用假定值進行渲染的,在真實渲染過後才會彌補更新,而定高虛擬滾動所有東西都是確定的。所以定高虛擬滾動的優勢就是比非定高虛擬滾動性能高,缺點就是只能應對每一條渲染數據是固定的情況。
定高虛擬滾動:
- 優點:性能比非定高虛擬滾動高
- 缺點:只能應用於每一條渲染數據高度是固定的場景
非定高虛擬滾動:
- 優點:性能比定高虛擬滾動低
- 缺點:能應用於每一條渲染數據高度是動態的場景