skip to content
BlogZzz

[Web] 垂直虛擬列表:只渲染可視範圍的長清單

/ 9 min read

Updated:
Table of Contents

前言

清單裡只有幾十筆資料時,直接 items.map(...) 通常沒什麼問題

但如果是數千張圖片、聊天訊息或商品資料,瀏覽器得同時維護大量 DOM 節點,捲動時就很容易開始卡

虛擬列表(virtual list)是處理這類問題的經典解法

它不會真的把所有項目都畫出來,而是只畫目前看得到的範圍,以及前後幾筆緩衝資料,捲動時再換掉這小批內容

這篇是上一篇〈圖片輪播的虛擬渲染,不讓 DOM 隨資料增加〉的後續

前篇處理圖片輪播的水平移動加上手勢的部分,這篇則把相同概念延伸到垂直捲動的清單

兩者都是只保留目前需要的 DOM,差別在於可視範圍的計算方向

這篇會沿著元件的運作順序,拆解固定高度虛擬列表的核心概念

線上範例

可先操作完整範例,再對照後續的虛擬渲染實作

問題情境

假設有 10,000 筆資料,每一列固定高 50px

整個清單看起來應該有

10000 * 50 // 500000px

VirtualList 仍然會做出一個 500000px 高的內層容器,讓捲軸的長度和一般清單一樣

不過這個容器裡不會放 10,000 個項目,只放目前螢幕附近的十幾或幾十個

看起來像完整清單,實際上 DOM 很輕

元件需要的 props 如下:

<VirtualList
items={products}
itemHeight={72}
height={480}
overscan={5}
renderItem={(product, index) => (
<div>{index + 1}. {product.name}</div>
)}
/>
  • items:全部資料,不是目前可見的資料
  • itemHeight:每個項目的固定高度,這是這個版本最重要的前提
  • height:外層捲動區的高度
  • overscan:可視範圍前後多留幾筆資料,預設是 5
  • renderItem:把單筆資料變成 JSX 的函式

第一步:記住現在捲到哪裡

const [scrollTop, setScrollTop] = useState(0);

scrollTop 是捲動容器頂端離內容頂端的距離

例如往下捲了 550px,scrollTop 就是 550

外層 <div> 設了 overflowY: "auto",所以它自己能捲動,onScroll 再把最新位置存回 state

onScroll={(event) => {
setScrollTop(event.currentTarget.scrollTop);
}}

這裡使用 currentTarget,因為它就是綁定事件的捲動容器,不會被裡面的項目元素影響

第二步:算出真正要顯示的索引範圍

整份清單的視覺高度先算好:

const totalHeight = items.length * itemHeight;

接著,把目前的捲動距離換成資料索引:

const startIndex = Math.max(
0,
Math.floor(scrollTop / itemHeight) - overscan
);

以每列 50px、目前捲到 550px 為例,畫面頂端落在第 11 列附近,索引從 0 開始

假如 overscan 是 5,元件會從第 6 列開始渲染,先把畫面上方的幾列也留下來

可視範圍需要幾列,則由容器高度決定

const visibleCount = Math.ceil(height / itemHeight);

例如 height300px、每列 50px,至少要 6 列才能填滿畫面

最後的結束位置是

const endIndex = Math.min(
items.length,
startIndex + visibleCount + overscan * 2
);

前面已經往前多留 overscan 列,這裡再加上 overscan * 2,效果就是可視區前後各保留一段緩衝

Math.maxMath.min 也順便處理剛到頂端或快到底部時,索引不能超出陣列的情況

真正交給 React 的只有這段

const visibleItems = items.slice(startIndex, endIndex);

第三步:位置照原本的索引放回去

如果只渲染切出來的資料,它們會全部擠在內層容器最上面

所以每個項目都用絕對定位,放回它在完整清單中本來應該出現的位置

{visibleItems.map((item, offset) => {
const index = startIndex + offset;
return (
<div
key={index}
style={{
position: "absolute",
top: index * itemHeight,
left: 0,
right: 0,
height: itemHeight,
}}
>
{renderItem(item, index)}
</div>
);
})}

這裡的 offset 是切片後的相對位置,不一定等於原始資料的索引,因此要加回 startIndex

例如目前渲染的第一筆資料,offset 是 0,但它可能其實是原始陣列的第 120 筆

top: index * itemHeight 則是關鍵

即使第 120 筆前面的 119 個 DOM 節點根本不存在,它還是會被放在正確的垂直座標上

內層容器的 height: totalHeight 負責維持完整的可捲動空間,兩者搭起來就完成了看似完整清單的效果

為什麼需要 overscan?

如果嚴格只渲染眼前看得到的列,快速拖動捲軸時,下一批 DOM 可能還沒來得及更新,邊緣會短暫出現空白或跳動感

overscan 是一個實用的折衷:

  • 設小一點:DOM 更少,但快速捲動時比較容易感到切換
  • 設大一點:捲動更平順,但一次渲染的項目也更多

預設的 5 適合多數簡單列項

如果每個項目很複雜或含大量圖片,可以試著調小一些

這個實作的前提與小提醒

這份元件刻意保持精簡,也因此有幾個使用時要知道的條件

  • 每一列必須等高itemHeight 用來計算索引和座標,如果內容高度會自動變動,後面的項目就會對不準,動態高度需要額外量測每列尺寸,做法會複雜不少
  • 索引當 key 適合固定排序的清單:如果資料會在中間插入、刪除或重新排序,最好讓 items 有穩定的 id,並改用該 id 作為 key
  • 每次捲動都會更新 state:這是正常的第一版作法,若每列的內容非常重,可以再搭配 React.memo、節流,或直接採用成熟的虛擬列表套件
  • renderItem 保持單純會更順:複雜的資料整理可以先在外面完成,不要每次捲動渲染時都重算一大串東西

小結

VirtualList 的核心其實只有三件事

  • 用完整高度撐出正確的捲動距離
  • 根據 scrollTop 算出現在附近有哪些索引
  • 只渲染那些資料,並用絕對定位放到原本該在的位置

這種作法很適合固定高度的訊息列、搜尋結果、商品清單或圖片牆

它不是把資料變少,而是把同一時間交給瀏覽器處理的 DOM 變少,資料越多,這個差異就越明顯

如果資料需要分批取得,還可以搭配 IntersectionObserver 實作 load data 的行為

在清單底部放一個觀察目標,當它接近可視範圍時觸發下一頁資料的請求,再把新資料加入 items

這樣可以同時降低初次載入的資料量,並維持虛擬列表只渲染可視 DOM 的優點