「前端長列表」開源庫解析及最佳實踐

前言

長列表通常也叫虛擬列表,是一種大數據量下只渲染可見節點避免頁面卡頓的優化方案html

長列表也有時間分片的作法,比較少用,感興趣的能夠看 高性能渲染十萬條數據(時間分片)前端

前端比較有名的有兩個項目:vue

  • react-window
  • vue-virtual-scroller

以及 Ant Design 4 的 virtual-listreact

本文將對這些開源庫進行剖析,分析實現原理,並進行各個指標的評估,最終實現一個高可用的長列表組件git

主要評估如下幾點:github

  1. 渲染:迴流, 渲染策略等
  2. 計算:起止項和偏移位置的計算,總高度的計算
  3. 功能:自適應高度,其餘
  4. 健壯:是否存在鼠標與滾動條不一樣步的 bug(計算時總高度增長了,則滾動條會相對鼠標向上)

而後說下看源碼的策略,主要看這幾點:chrome

  1. dom 結構
  2. 查找起始位置
  3. 計算偏移距離
  4. 計算總高度

長列表入門

若是還不清楚長列表是什麼,能夠先看下這篇文章「前端進階」高性能渲染十萬條數據(虛擬列表)apache

一張圖快速入門數組

下面咱們來看看其餘開源庫都怎麼作的瀏覽器

vue-virtual-scroller

項目地址

功能: 支持自適應高度,橫向滾動,圖片自適應高度

渲染

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 
複製代碼

舉個連續滾動的例子:

  1. 一開始查到 1~10 放入 views 中,進行滾動,查到 5~14
  2. 將 1~4 放入對應類型的 unusedPool 中,設置未使用,並從 views 中刪除
  3. 將原來的 5~10項 設置爲 已使用
  4. 查到 11 時,看 unusedPool 中有沒有和 11 同類型的,有的話 pop 出來複用,替換下內容和偏移位置,沒有的話新建一個 view,會放入 pool 中

對應的,非連續滾動定義爲 快速滾動,初始化一個空的 map -- unusedIndex, 做用是記錄同類型的 unusedPool 須要從哪一個索引開始取值。

若是 unusedPool 存在元素,拿來複用;若是同類型 pool 被用光了,addView

感受此處 v++ 的處理有點問題,沒有深究

和安卓的列表滾動相似,按類型進行回收,新找到的列表項會複用緩存中同類型的,能夠減小 layout 時間

相似的還有 weex 的 recycle-list

健壯

因爲進度條高度會變化,所以存在鼠標與滾動條不一樣步的 bug

總結

功能豐富,自適應高度方面的處理性能不行(嘗試考慮pr),項目結構較差不易維護

不建議使用

react-window

提供四種組件:

  • FixedSizeList
  • FixedSizeGrid
  • VariableSizeList
  • VariableSizeGrid

不支持自適應高度

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 ,若改變,則列表總高度跟着變

在線 Demo

本方案有兩個缺點:

  • 拓展差,作不了自適應高度
  • 前面的滾動較耗時間,把沒用到的也計算進去了

健壯

因爲進度條高度會變化,所以存在鼠標與滾動條不一樣步的 bug

總結

不支持自適應高度,性能不行,不是 react 長列表的首選方案

若是有用到 Grid 的話能夠用

virtual-list

項目地址

目前分析綜合評分最高的一個

支持自適應高度,支持動畫效果,支持滾動位置復原

渲染

<!-- 用戶可見的容器高度可能只有 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)

處理邏輯以下:

  1. 滾動,肯定定位項和起止項
  2. 渲染起止列表項
  3. 列表項渲染完畢,計算並調整起始項偏移位置
  4. 進行重渲染

注意:此處的渲染表示對 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 的列表項

③ 調整 offset

在列表項渲染完畢後,觸發 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 自適應高度的處理方式相似,只不過咱們僅須要從起始項開始處理

在線 Demo

引用自 「前端進階」高性能渲染十萬條數據(虛擬列表) ,更新偏移值那邊的時間複雜度爲 O(n*m)

缺點:更新 sizes 較爲耗時

③ 樹狀數組優化更新偏移值

本身想出來的一種解決方案,可以達到查詢和更新都是 O(logn) 時間複雜度

在未看 virtual-list 的方案前,我一度覺得這是最好的方案

效率上二者差很少,不過本方案會有 鼠標和滾動條不一樣步 的問題

咱們先創建數據模型,列表項的高度列表爲長度爲 len 的正數數組 nums ,有兩種操做:

  1. 更新數組中某項的值
  2. 找到一個最小的 n,前 n 項總和大於等於目標值 target , 1<= n <= len

很明顯這是一個樹狀數組模板題,兩個操做都是 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 ~

參考

  1. virtual-list-demo
  2. 「前端進階」高性能渲染十萬條數據(虛擬列表)
  3. 再談前端虛擬列表的實現
相關文章
相關標籤/搜索