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:可視範圍前後多留幾筆資料,預設是 5renderItem:把單筆資料變成 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);例如 height 是 300px、每列 50px,至少要 6 列才能填滿畫面
最後的結束位置是
const endIndex = Math.min( items.length, startIndex + visibleCount + overscan * 2);前面已經往前多留 overscan 列,這裡再加上 overscan * 2,效果就是可視區前後各保留一段緩衝
Math.max 和 Math.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 的優點