長列表通常也叫虛擬列表,是一種大數據量下只渲染可見節點避免頁面卡頓的優化方案html
長列表也有時間分片的作法,比較少用,感興趣的能夠看 高性能渲染十萬條數據(時間分片)前端
前端比較有名的有兩個項目:vue
以及 Ant Design 4 的 virtual-listreact
本文將對這些開源庫進行剖析,分析實現原理,並進行各個指標的評估,最終實現一個高可用的長列表組件git
主要評估如下幾點:github
而後說下看源碼的策略,主要看這幾點:chrome
若是還不清楚長列表是什麼,能夠先看下這篇文章「前端進階」高性能渲染十萬條數據(虛擬列表)apache
一張圖快速入門數組
下面咱們來看看其餘開源庫都怎麼作的瀏覽器
功能: 支持自適應高度,橫向滾動,圖片自適應高度
dom 結構以下
<!-- position: relative;overflow-y: auto; -->
<div class="vue-recycle-scroller">
<div class="vue-recycle-scroller__item-wrapper" :style="{ 'minHeight': totalSize + 'px' }">
<div v-for="view of pool" :key="view.nr.id" :style="{ transform: `translateY(${view.position}px)` }" class="vue-recycle-scroller__item-view" >
<slot :item="view.item" :index="view.nr.index" :active="view.nr.used" />
</div>
</div>
</div>
複製代碼
一個相對定位的列表,由 min-height
撐開 wrapper 以產生滾動條
每一個列表項進行平移(translateY),這個偏移值爲該項在列表中的高度累加值
還有一些 -9999px
的不可見元素,這些實際上是緩存池列表項,這個在 節點回收復用 一節會講到
列表項數據結構:
listItem = {
// 數據項內容
item:Object,
// 非響應式數據
nr:{
id,// 惟一標識
index,// 數據項中的索引
used: Boolean,// 是否已用來顯示在可視區域
key,// 數據項中的key
type,// 在對應類型的緩衝池中存取
}
// translateY 值
position: Number,
}
複製代碼
不會產生迴流(非自適應高度的狀況)
瀏覽器渲染速度快:進行滾動時產生變化的列表項會盡量的使用緩存池元素並修改 translateY ,其餘沒有變更的列表項在dom結構中的位置不變
有三種場景
最簡單的狀況,須要設置 itemSize
起始項索引能夠經過 ~~(scrollTop / itemSize)
獲得
列表總高度 = itemSize * itemCount
itemSize 設爲 null,經過數據項中的 height 字段定義每一個列表項的高度
因爲全部列表項高度是肯定的,一開始會計算每一項的高度和偏移值
O(n) 時間複雜度
同時肯定了列表總高度,固定爲 最後一項的偏移值+高度
當進行滾動時,因爲每一項的偏移位置是肯定的,則查找起始項索引能夠採用二分法
O(logn) 時間複雜度
須要配置數據項最小高度,並根據這個值初始化每一個數據項的高度和偏移值 -- sizes
O(n) 時間複雜度
列表總高度爲 最後一項的偏移值+高度
sizes 計算
for (let i = 0, l = items.length; i < l; i++) {
current = items[i][field] || minItemSize
accumulator += current
sizes[i] = { accumulator, size: current }
}
複製代碼
進行滾動,經過二分 sizes 獲得起始項索引,結束項索引爲起始項索引加上 可視高度/最小列高
O(logn) 時間複雜度
以後進行節點渲染,渲染完畢時獲取實際高度,並從新計算 sizes 以及列表總高度
O(n) 時間複雜度
整體來講,性能較差,有優化的空間
連續滾動(continuous): 先後兩次查找的數據範圍有重疊,好比第一次爲 1~10 第二次爲 5~14 或者相反
已使用的列表項(views): 記錄已使用項的 Map ,key 爲列表項的 key
pool: 頁面中全部渲染的列表項,包括未使用的
類型-緩存池 Map (unusedViews): 記錄類型和緩存池的 Map
緩存池(unusedPool): 某種類型的緩存池,其中的列表項在頁面中偏移位置爲 -9999px
複製代碼
舉個連續滾動的例子:
對應的,非連續滾動定義爲 快速滾動,初始化一個空的 map -- unusedIndex, 做用是記錄同類型的 unusedPool 須要從哪一個索引開始取值。
若是 unusedPool 存在元素,拿來複用;若是同類型 pool 被用光了,addView
感受此處
v++
的處理有點問題,沒有深究
和安卓的列表滾動相似,按類型進行回收,新找到的列表項會複用緩存中同類型的,能夠減小 layout 時間
相似的還有 weex 的 recycle-list
因爲進度條高度會變化,所以存在鼠標與滾動條不一樣步的 bug
功能豐富,自適應高度方面的處理性能不行(嘗試考慮pr),項目結構較差不易維護
不建議使用
提供四種組件:
不支持自適應高度
grid 不分析了, FixedSizeList 就是最簡單的固定高度的情景,作法都同樣,咱們直接分析 VariableSizeList
先吐個槽, react-window 爲了複用,代碼封裝了一層又一層,render 仍是用的 createElement ... 看源碼的時候實在難受,並且仍是用 flow 寫的,編輯器各類報錯
不知道什麼緣由,依賴安裝的時候一直失敗,本地沒有啓動起來,此次是直接看的源碼
<div style="{{position: 'relative', height: `${height}px`, overflow: 'auto'}}">
<div style="{{height: `${totalSize}px`, width: '100%'}}">
<div style="position: absolute; left: 0px; top: 38px; height: 30px; width: 100%;">
Row 1
</div>
<div style="position: absolute; left: 0px; top: 68px; height: 65px; width: 100%;">
Row 2
</div>
....
<div style="position: absolute; left: 0px; top: 133px; height: 70px; width: 100%;">
Row 3
</div>
</div>
</div>
複製代碼
依然是用一個 totalSize 高度的容器去產生進度條,每一個列表項經過絕對定位進行偏移
這裏其實用 transform: translateY
效果同樣的,固然渲染上的性能差別我就不知道了
和這篇文章的實現一致 -- 再談前端虛擬列表的實現
貌似對指數搜索有誤解?
設 lastMeasureItem 爲已測量的最遠元素,
lastMeasureItem.offset 爲該元素的偏移值
lastMeasureItem.index 爲該元素的索引
列表總高度 = lastMeasureItem.offset + 未測量元素 * 默認高度
初次滾動,從第0項開始測量並緩存已滾動過的元素的偏移和索引,更新 lastMeasureItem
O(n) 時間複雜度
當滾動偏移值小於 lastMeasureItem.offset
時,表示起始項在 0~lastMeasureItem.index 範圍之間,因爲該範圍全部列表項偏移值都計算過了,此時採用二分便可快速獲得起始項索引
O(logn)
當滾動偏移值小於 lastMeasureItem.offset
時,則以 lastMeasureItem.index
開始測量並緩存已滾動過的元素的偏移和索引,更新 lastMeasureItem
O(n) 時間複雜度
Watch 觀察 lastMeasureItem.index ,若改變,則列表總高度跟着變
本方案有兩個缺點:
因爲進度條高度會變化,所以存在鼠標與滾動條不一樣步的 bug
不支持自適應高度,性能不行,不是 react 長列表的首選方案
若是有用到 Grid 的話能夠用
目前分析綜合評分最高的一個
支持自適應高度,支持動畫效果,支持滾動位置復原
<!-- 用戶可見的容器高度可能只有 300px -->
<div class="container" style="width: 200px; height: 300px;" @scroll.passive="handleScroll" >
<!-- 總的列表 div ,用於撐起列表的高度 -->
<div class="total-list" :style="{ height: `${itemHeight * data.length}px`, }" >
<div class="visible-list" :style="{ transform: `translateY(${topHeight}px)`, }" >
<div v-for="item in visibleList" :key="item.id" class="visible-list-item" :style="{ height: `${itemHeight}px`, }" >{{ item.value }}</div>
</div>
</div>
<!-- 此處只需渲染可見列表便可,無需渲染所有數據 -->
</div>
複製代碼
和上面的方案不同,這裏是建立了一個 total-list 的容器,直接對這個容器進行 translateY 偏移
總高度始終固定,等於 列表項個數(itemCount) * 列表項最小高度(itemHeight)
處理邏輯以下:
注意:此處的渲染表示對 dom 進行操做,還未到瀏覽器實際渲染階段
核心思想是任意高度的列表項都佔據相同的滾動條範圍
定位項與滾動條位置對應,能夠理解爲滾動條水平方向指向的那個列表項。
當滾動條爲0時,指向第0項,此時定位項爲第0項
當滾動條處於最大值時,指向最後一項,此時定位項爲最後一項
const itemCount = this.data.length
const scrollTopMax = scrollHeight - clientHeight
/** 進度條滾動百分比 */
const scrollPtg = scrollTop / scrollTopMax
/** 肯定定位項 */
const itemIndex = Math.floor(scrollPtg * itemCount);
/** 可見列表項個數 = 可見容器高度 / 每一個列表項高度 ,記得向上取整 */
const visibleCount = Math.ceil(this.$el.clientHeight / this.itemHeight)
/** 肯定起始項和結束項 */
const startIndex = Math.max(0, itemIndex - Math.ceil(scrollPtg * visibleCount))
const endIndex = Math.min(itemCount - 1, itemIndex + Math.ceil((1 - scrollPtg) * visibleCount))
複製代碼
渲染 startIndex ~ endIndex 的列表項
在列表項渲染完畢後,觸發 update 回調
獲取並統計 startIndex ~ itemIndex 列表項的實際總高度 s2iHeight
計算起始項偏移高度 startItemTop ,以下:
const startItemTop = 定位項絕對高度(itemAbsoluteTop) - 起始項至定位項的高度(s2iHeight)
const itemAbsoluteTop = scrollTop + 定位項相對視口高度(itemRelativeTop)
const itemRelativeTop = 滾動過的視口高度(scrollPtg * clientHeight) - 定位項偏移高度(itemOffsetPtg * itemHeight)
複製代碼
如圖所示:
因爲總高度固定,不存在鼠標和滾動條不一樣步的問題
性能優異,經過幾個數學公式便可肯定起止位置(還有優化的空間)
若須要自適應高度,則須要進行2次render,不然第一次render便可計算偏移位置
目前惟一一種不產生鼠標和滾動條不一樣步問題的方案
拓展性強,畢竟後面是 Ant Design 4 的核心組件之一
react 長列表首選方案
vue 能夠嘗試造個輪子
本節爲其餘一些長列表的處理方案,主要表如今起始項查找和更新列表高度方面的不一樣
這裏提到的幾種方案,都會出現 鼠標和滾動條不一樣步 的問題
性能較差的一種方案
定義數組 itemHeightRecord 記錄列表項實際高度,能夠是自適應計算出來的高度,也能夠是定義高度方法計算獲得的高度
一開始,列表總高度 = 列表項個數 * 默認高度
查找起始項,因爲沒有記錄偏移值,只能採用順序疊加的方式判斷, itemHeightRecord 中有值的取值,沒值的取默認高度
時間複雜度 O(n)
渲染列表項,並將取得的高度與默認值之間的差更新到 列表總高度 上,並對 itemHeightRecord 進行賦值
缺點:查找起始項的效率過低
思路來源於 「前端進階」高性能渲染十萬條數據(虛擬列表) ,並對更新偏移值進行優化
須要配置數據項最小高度,並根據這個值初始化每一個數據項的高度和偏移值 -- sizes
列表總高度 = sizes[length-1].offset + sizes[length-1].height
查找起始項,根據 sizes 進行二分
時間複雜度 O(logn)
渲染列表項(假設有m項),並將取得的高度替換 sizes 中的 height,並將 height 與默認值之間的差更新到其後每一項的 offset
時間複雜度 O(n)
與 vue-virtual-scroller 自適應高度的處理方式相似,只不過咱們僅須要從起始項開始處理
引用自 「前端進階」高性能渲染十萬條數據(虛擬列表) ,更新偏移值那邊的時間複雜度爲 O(n*m)
缺點:更新 sizes 較爲耗時
本身想出來的一種解決方案,可以達到查詢和更新都是 O(logn)
時間複雜度
在未看 virtual-list 的方案前,我一度覺得這是最好的方案
效率上二者差很少,不過本方案會有 鼠標和滾動條不一樣步 的問題
咱們先創建數據模型,列表項的高度列表爲長度爲 len 的正數數組 nums ,有兩種操做:
很明顯這是一個樹狀數組模板題,兩個操做都是 O(logn) 的時間複雜度,模板能夠參考我寫的 BinaryIndexedTree
關於樹狀數組原理能夠參考文章 -- 樹狀數組(Binary Indexed Trees)
提供了幾個方法
function findGe(target) {} //找到最小的一個n,其前n項和大於等於 target
function update (i, val) {} // 第 i 項增長差值val, 1<=i<=len
function prefixSum (n = this.tree.length - 1) {} //計算前 n 項的和 , 1<=n<=len
複製代碼
查找起始項能夠採用 findGe, 查找起始項偏移位置以及列表總高度能夠用 prefixSum, 更新偏移值採用 update
測試效果:10W 條數據滾動時處理的計算時間在 1ms 左右
感興趣的能夠看個人開源庫 virtual-list-demo
可視區域的列表高度,通常不變,不必每次都經過 $el.clientHeight
獲取(會形成迴流),在 resize 時再改變
若是是自適應高度,那本操做無關緊要
原本打算單獨寫一節的,最後決定採用 virtual-list 的設計方式
列表項採用 Render Props
的形式,用 cloneElement 生成實際列表項。
不管是自適應高度仍是固定高度,都是經過參數配置,對外僅提供一個組件
我的方案僅測試了 chrome ,還沒測試過其餘瀏覽器,看開源庫的時候有看到其作了一些兼容處理
好比火狐滾動白屏問題,Safari scrollTop 可能爲負的問題,移動端卡頓的問題
篇幅有限,這些不在本文的研究範圍,建議就是生產環境儘可能用開源庫
若列表項高度是依賴於圖片高度的,因爲圖片加載較慢,在初次渲染結束時(update生命週期中)並獲取不到真實列表高度,須要等待圖片加載完畢後計算
具體作法就是採用 ResizeObserver API ,不過這個兼容性有點問題,
vue-virtual-scroller 中其實有用到了,感興趣的能夠參考一下
經過方向鍵切換列表項,須要攔截鍵盤默認事件,並賦值 scrollTop
這個更多的是基於長列表的 Select,Tree 中會用到,到時候用到再說
原文地址,感興趣的給個 star ~