從 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=' + lastIdChrome 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