為什麼長列表需要特別處理#

社群媒體例如 Facebook、Instagram 的動態牆,是日常生活中最常見的長列表(Long List)情境之一。實際使用時你會發現它們不會一次載入所有貼文,而是先載一批,等使用者捲到底再追加更多。這背後的優化通常由 Virtualized List 搭配 Lazy Loading 一起完成,本章節先談前者。

如果一個列表有上千筆資料一次全部渲染,就會生出數量同樣龐大的 DOM 節點。再加上每筆貼文本身結構通常不單純,這種做法的代價是顯而易見的:

  • 首次載入的白屏時間被拉長。
  • 滾動時記憶體用量大幅膨脹。
  • 渲染速度跟不上瀏覽器的幀率,畫面卡頓、容易掉幀。
  • 嚴重時整個頁面甚至會失去響應。

桌面瀏覽器尚且如此,手機瀏覽器只會更糟。長列表的優化幾乎是必修課題。

Virtualized List 的核心觀念#

Virtualized List(虛擬滾動,Virtual Scrolling)的概念聽起來很玄,其實一句話就講得完:

紀錄所有列表項目的位置,只渲染目前可視區(viewport)內的項目。當捲動發生時,根據捲動偏移量重新計算應該顯示哪些項目。這個技巧也常被稱作「Windowing」。

舉例來說,假設可視區一次只能容納 6 個 item,那即使整個列表有 1000 筆,畫面上實際存在的 DOM 也只會是當下可視區的那 6 個。當原本被渲染的 item 滑出可視區,就會被卸載;新進入可視區的 item 才會被渲染。React 官方文件本身也建議大型列表採用 Windowing 技術。

既有的解決方案#

主流框架幾乎都有現成的 Virtualized List 套件可用:

  • React:react-window、react-virtualized
  • Vue:vue-virtual-scroll-list、vue-virtual-scroller
  • Svelte:svelte-virtual-list
  • React Native:built-in 的 VirtualizedList component

除非有特殊客製需求,沒必要從零打造一個 Virtualized List。但動手做一遍對理解原理非常有幫助,以下會帶讀者實作最簡化的版本。

動手實作極簡版#

為了把焦點放在原理,我們先定下幾個簡化條件:

  • 每個 list item 的高度固定。
  • 可視區視窗的高度固定。
  • 因為兩者都固定,可以用 position: absolute 直接根據 index 算出每個 item 在整個長列表中的絕對位置。

實作前需要先掌握三個關鍵數值:

  • Inner Height:整個長列表的總高度,等於 itemHeight * itemCount
  • Window Height:可視區的高度,由外部傳入。
  • Scroll Top:當前捲動的距離,也就是長列表頂端到可視區頂端的偏移量。

Component 的介面#

我們會做一個叫做 SimpleVirtualizedList 的元件,接受四個屬性:

  • itemHeight:每個 item 的高度。
  • itemCount:總 item 數量。
  • windowHeight:可視區高度。
  • renderItem:渲染單一 item 的 callback function。

itemHeight * itemCount 可以推算出 Inner Height,windowHeight 由外部給定,scrollTop 則靠 container 的 scroll event 動態取得。

計算可視區的 index 範圍#

有了這些前置資訊,就可以推算可視區內第一個 item 的 index 與最後一個 item 的 index:

const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
  itemCount - 1,
  Math.floor((scrollTop + windowHeight) / itemHeight)
);

接著對 [startIndex, endIndex] 範圍內的每個 index 渲染 item,並利用 position: absolute 加上 top = index * itemHeight 把它放到正確位置。

const items = [];
for (let i = startIndex; i <= endIndex; i++) {
  items.push(
    renderItem({
      index: i,
      style: {
        position: "absolute",
        top: i * itemHeight,
        height: itemHeight,
      },
    })
  );
}

跑起來會怎樣#

假設我們建一個有 2000 個 item 的假資料丟進去,把 itemHeight 設為 40px、windowHeight 設為 400px,那麼可視區同時顯示的 item 永遠是 10 個。從 DevTool 觀察 DOM,會發現不論怎麼滾動,畫面上始終只有那 10 個 item element 存在。

還可以怎麼優化#

最簡版本只是入門,實際的開源套件還處理了非常多細節:

  • 支援高度不一致的 item,計算可視區範圍會比較複雜,通常需要建立位置快取。
  • 在可視區邊界附近做預渲染,避免使用者快速捲動時看到短暫空白。
  • 改用 ResizeObserver 動態追蹤可視區高度的變化。
  • transform: translate 取代 top 排版,讓動畫走 GPU 加速、得到更平順的捲動體驗。

Virtualized List 解決了一次渲染大量 DOM 元素的問題,但資料本身仍然得一次性丟給元件。當資料量極大時,光是這份資料本身就會對記憶體造成負擔,這正是下一章 Lazy Loading 要處理的問題。

一個小總結#

Virtualized List 不論是在電商商品列表、社群平台動態牆,或新聞平台都是優化長列表必備的技巧。今天的極簡實作只是讓讀者掌握背後的核心:「不要渲染看不到的東西」。要把它發揮到極致,還需要與 Lazy Loading 搭配使用。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10271764