從 Infinite Scroll 出發#

繼上一章談 Virtualized List 之後,留下了一個尚未解決的問題:資料還是要一次全部丟進元件。實務上常見的反例是 Facebook 的動態牆——你不太可能滑到底,每次快滑到底就會自動載入更多貼文,幾乎是無止盡的清單。

這種「無止盡捲動」的功能稱作 Infinite Scroll,而它本質上是 Lazy Loading 的一種具體實作。

Lazy Loading 是什麼#

Lazy Loading 直譯就是「延遲載入」,核心思維可以濃縮為一句話:

等到真的要用的時候再載入。

與其說它是某項特定技術,更像是一種優化思維。同一個目標可以用很多種方式達成。本章會聚焦在兩種常見形式:

  • 瀏覽器原生支援的圖片 Lazy Loading
  • 搭配 Infinite Scroll 的資料 Lazy Loading

元件層級的 Lazy Loading(例如 React.lazy)背後牽涉到 Code Splitting,會在後續章節說明。

圖片的原生 Lazy Loading#

圖片在網站資源中往往佔據相當大的比例。一次把所有圖片下載完是效能上的負擔,尤其是在類似 Imgur 的圖床網站上。可以做到「捲動到附近才開始載入圖片」,把不必要的下載延後或省略。

實作圖片 Lazy Loading 大致兩種路線:

  • 用 Intersection Observer Web API 自行偵測時機。
  • 直接使用瀏覽器原生屬性 loading="lazy"

原生屬性的用法極簡單:

<img src="image.png" loading="lazy" alt="image alt" width="200" height="200" />

loading 屬性的可選值有:

  • auto:等同於沒有指定,瀏覽器走預設行為。
  • lazy:圖片靠近 viewport 才開始下載。
  • eager:不論位置都立刻載入。

不是所有圖片都該被延遲載入#

一開始就在 viewport 內、或非常接近 viewport 的圖片不該做 Lazy Loading。瀏覽器決定要不要載入需要花費判斷時間,這段時間應該保留給真正關鍵、需要立即呈現的圖片。

留好 Placeholder 才不會破壞 CLS#

延遲載入的圖片應該預先撐開所需空間,避免實際載入後造成版面跳動,影響使用者體驗與 CLS 分數。簡單做法是用一個指定大小的容器把圖片包起來:

<div class="container">
  <img src="ironman.png" />
</div>

更進一步可以讓 placeholder 自己有設計感:

  • 使用原圖的縮小版(會帶模糊感)作為 placeholder,圖片下載完再切換。Medium 採用的就是這種手法。
  • 用 SVG 為基礎的 LQIP(Low-Quality Image Placeholder)方案,例如 SQIP。

提前一點開始載入更友善#

實際做圖片 Lazy Loading 時不會等到圖片真的進入可視區才下載。設想使用者網速差,他會看著一張一張圖緩慢呈現,體驗非常糟。比較合理的做法是設一個觸發距離(例如距離 viewport 500px 就開始載入),既能避開無效的浪費,又能讓使用者幾乎察覺不到延遲。

用 Intersection Observer 做資料 Lazy Loading#

Lazy Loading 的延遲標的不限於圖片,資料同樣可以。Facebook 動態牆、Dcard 文章列表都是經典範例。實作 Infinite Scroll 的關鍵在於回答一個問題:「什麼時候該抓下一批?」

合理的時機是「目前資料的最後幾筆出現在可視區時」,代表現有資料快要滑完了。如果太早抓,例如剛滑到一半就抓下一批,可能造成不必要的網路請求;而且使用者也會比較難理解資料正在更新(適度顯示載入中提示反而是更好的體驗)。

要判斷某個元素是否進入 viewport,可以用 Intersection Observer Web API。

Intersection Observer 介紹#

它的功能可以濃縮成:「監聽目標元素出現或離開可視區的時機,並執行我們提供的 callback。」

在還沒有這個 API 的年代,要做類似的偵測得用 scroll event 加上 Element.getBoundingClientRect() 不停查詢,會讓主執行緒(main thread)負擔很重。Intersection Observer 由瀏覽器底層處理,效能與寫法都更友善。

基本用法#

先在頁面放一個需要捲動才能看到的元素:

<div class="demo-box">DEMO BOX</div>
const intersectionObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    console.log("demo box 進入 viewport");
  } else {
    console.log("demo box 離開 viewport");
  }
});

intersectionObserver.observe(document.querySelector(".demo-box"));

new IntersectionObserver 可帶第二個參數 options

const options = {
  root: document.querySelector("#scrollArea"),
  rootMargin: "0px",
  threshold: 1.0,
};

const observer = new IntersectionObserver(callback, options);
  • root:自訂可視區範圍,預設是整個瀏覽器 viewport。
  • rootMargin:類似 CSS margin,可以擴張 / 縮小判定範圍。
  • threshold:目標元素出現多少比例時觸發 callback。預設 0 代表「一進入就觸發」,1.0 代表「整個元素全部進入才觸發」。

threshold 也支援陣列,這在需要追蹤元素「逐漸進入 viewport」的情境很實用:

const options = {
  threshold: [0, 0.25, 0.5, 0.75, 1],
};

const intersectionObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    console.log("demo box 進入");
  } else {
    console.log("demo box 離開");
  }
}, options);

intersectionObserver.observe(document.querySelector(".demo-box"));

與 event listener 一樣,用不到時記得透過 unobserve 釋放:intersectionObserver.unobserve(element)

Virtualized List + Lazy Loading 的組合拳#

把 Virtualized List 與 Lazy Loading 一起用是優化長列表最理想的做法。Virtualized List 只渲染畫面上真的看到的節點,避免 DOM 過多;Lazy Loading 則控制資料的載入節奏,避免一次抓太多資料浪費頻寬。

實作 Infinite Scroll 時,常見的做法是:用 Intersection Observer 監聽當前資料的最後一筆是否出現在畫面上,若有就更新 lastId 之類的 state,再用它去呼叫支援 pagination 的 API:

BASE_URL + '?limit=' + LIMIT + '&before=' + lastId

Chrome DevTool 的 FPS Meter 可以協助驗證滾動時的幀率(Frame Rate)是否穩定。實際比較有沒有 Windowing 技術的版本,差距通常很驚人:

  • 加上 Virtualized List 後,滾動時 frame rate 可以維持在接近 60 fps。
  • 記憶體用量明顯下降。
  • 初次渲染(Initial Render)時間可以差到接近兩個數量級。

一個小總結#

Lazy Loading 是優化長列表的另一塊拼圖。實作關鍵在於「決定載入時機」,Intersection Observer 是現代瀏覽器提供的優雅解法。把它與 Virtualized List 搭起來使用,才能把長列表體驗推到接近原生 App 的水準。

原文出處#

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