主題: Web platform

IntersectionObserver 入門:進入畫面、載入資料與虛擬化是三件事

從 root、rootMargin、threshold 到初始通知與生命週期,用短小 JavaScript 範例理解 IntersectionObserver。釐清無限捲動的載入控制,以及虛擬化為什麼要限制 DOM,而不是刪掉資料。

動態迷因(展開/收合)
收到「可以開始」的通知,不代表工作已完成。元素交會之後,載入與呈現仍要由程式決定。 · 來源:GIPHY

商品列表快捲到底時,先載入下一批;卡片進入畫面時,才啟動一次效果。這些需求看起來都在問:「它現在到了嗎?」

IntersectionObserver 適合回答元素和指定區域是否交會,以及交會比例跨過了哪些門檻。它不會替你下載商品,也不會把一萬張卡片變成十張。

先把這個分工想清楚,再調參數,程式會好理解很多。

1. 不必每次 scroll 都自己算位置

常見做法是監聽 scroll,反覆呼叫 getBoundingClientRect() 判斷每張卡片的位置。IntersectionObserver 則讓瀏覽器管理交會偵測,再非同步通知你的 callback。

這不是把 callback 搬到背景執行緒。裡面的 JavaScript 仍在主執行緒執行;塞入昂貴的排序、大量 DOM 更新,一樣可能卡住互動。API 解決的是偵測方式,不保證後續工作便宜。MDN:概念與 callback

適合的問題是「接近列表尾端了嗎?」或「卡片跨過一半面積的門檻了嗎?」;需要每一個像素都精準同步的捲動效果,不是它的主要用途。

2. 先認識三個參數

參數 白話意思 範例
root 跟哪個區域比較? null 是一般頁面的 viewport;捲動面板可指定其元素。
rootMargin 比較前,將 root 的計算範圍放大或縮小多少? "0px 0px 300px 0px" 向下多算 300px。
threshold 目標的交會比例跨過多少時要通知? 0.5 是目標面積的一半,不是畫面高度的一半。

若指定元素作為 root,它必須是目標的祖先。root 是捲動面板時,不能期待瀏覽器仍以整個視窗作比較。MDN:建立 observer

rootMargin 的正值擴大、負值縮小計算範圍,不會改變 CSS 排版。底部加 300px,可以在目標接近 viewport 前收到通知,但實際結果仍受祖先裁切等幾何條件影響;它也沒有承諾「提前幾秒」。MDN:rootMargin

intersectionRatio 是交會面積除以目標面積。對一般有面積的卡片,0.5 表示約一半面積交會;高到放不進 root 的卡片,不適合拿 threshold: 1 當必定發生的啟動條件。MDN:intersectionRatio

還有一個容易打錯的地方:建構選項叫 threshold,讀取 observer 的門檻清單才叫 thresholds。要跨過多個門檻時,用 threshold: [0, 0.5, 1]。同一組門檻會偵測兩個方向的跨越,不只進入。MDN:thresholds

3. 第一個可跑的例子:卡片只標記一次

把以下 HTML 放進空白頁面,再將 JavaScript 放在它後面。文字原本就可讀;沒有 API 時也不把內容藏起來。

<p style="min-height: 120vh">往下捲動,看通知何時出現。</p>
<article data-card>第一張卡片</article>
<article data-card>第二張卡片</article>
const cards = document.querySelectorAll("[data-card]");

if ("IntersectionObserver" in window) {
  const observer = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.dataset.seen = "true";
      console.log("Reached:", entry.target.textContent);
      observer.unobserve(entry.target);
    }
  }, {
    root: null,
    rootMargin: "0px 0px 300px 0px",
    threshold: 0,
  });

  cards.forEach((card) => observer.observe(card));
}

一個 observer 可以觀察多個目標,callback 也可能一次收到多筆 entries,所以這裡逐筆處理,不只看 entries[0]。MDN:observe()

observe() 之後會有初始通知,即使元素還沒移動、還在畫面外;因此不能把「callback 有跑」當成「已經進入」。這裡先檢查 isIntersecting。它代表交會狀態,不是圖片下載完畢,更不是使用者真的讀過文字。MDN:isIntersecting

此例故意提前擴大 root,seen 只是示範標記,不是閱讀紀錄。預設的交會偵測也不能證明元素沒有被其他內容遮住。若要做曝光統計,還有時間、遮擋、同意與業務定義要處理。

4. 用完怎麼停?新卡片又怎麼辦?

unobserve(target) 只停止觀察那個目標,所以例子中的卡片不會反覆記錄。disconnect() 停止該 observer 的所有目標,適合頁面元件卸載時使用;兩者都不會刪掉 DOM。MDN:disconnect()

observer 的設定建立後就固定了。改原本 options 物件,不會更新既有 observer;需要另一組 root 或門檻,就建立新的。後來加入的卡片也不會自動被觀察,必須再呼叫 observe()。MDN:IntersectionObserver

停止觀察不等於取消請求。callback 若已經啟動 fetch,要另外管理 AbortController,並防止元件卸載後的結果更新 UI。

5. 無限捲動:通知只是載入入口

在列表尾端放一個小元素,通常稱為 sentinel。當它交會時呼叫載入函式;同一函式也可以綁在「載入更多」按鈕上,保留手動操作與重試入口。

但 observer 不會等待你的 async callback 完成,也不替你排隊。先把重複請求擋住:

let loading = false;
let hasMore = true;

async function loadNextPage() {
  if (loading || !hasMore) return;
  loading = true;

  try {
    const page = await fetchNextPage();
    appendRows(page.items);
    hasMore = page.hasMore;
    status.textContent = hasMore ? "More available" : "End of list";
  } catch (error) {
    status.textContent = "Could not load. Use Load more to retry.";
    console.error(error);
  } finally {
    loading = false;
  }
}

這段只示範載入狀態,不是一整套 API client。應用程式要提供 fetchNextPage()、appendRows() 與 status 元素。前者必須檢查 HTTP 狀態、回應格式並維護游標,回傳 { items, hasMore };appendRows() 使用安全的 DOM/框架呈現,不把未信任文字直接塞進 innerHTML。狀態文字可放在 aria-live="polite" 區域。

重點是 loading = true 放在第一個 await 之前。若等回應回來才設定,等待期間的另一個通知或按鈕操作仍能啟動第二次請求。失敗不代表列表結束,因此不要在 catch 直接把 hasMore 設成 false。MDN:使用 fetch

sentinel 一直留在交會範圍時,不保證每次資料回來都產生新通知。內容還沒填滿畫面、需要繼續載入時,要另外設計補頁策略;不要把載入函式寫成沒有上限的自動重試。

6. 虛擬化:留下資料,只呈現附近的列

無限捲動解決「下一批何時取」。若每批都一直 append,資料越看越多,DOM 也越來越大。IntersectionObserver 不會自動回收那些列。

虛擬化(virtualization,也稱 windowing)則讓長列表主要呈現目前可見範圍,加上前後緩衝;離開範圍的列可被移除或重用。底層資料仍可保留,不是把資料庫記錄刪掉。web.dev:長列表虛擬化

動態迷因(展開/收合)
取下一批和維持列表順暢是不同問題。這是等待的比喻,不是 DOM 效能量測。 · 來源:GIPHY

用一個純粹的假設算算:資料有一萬列,每列固定高 80px,容器高 480px。剛好對齊時可顯示六列,前後各保留三列緩衝,大約呈現十二列,而不是一萬列。

捲到中段時,前面的空間不能直接消失。實作通常透過保留總高度與列位置,讓 scrollbar 仍代表整份列表;捲動後換一批列呈現。實際 DOM 數量會受邊界、列高與緩衝策略影響,十二列不是套件預設值,也不是效能保證。

機制 負責什麼 不會自動解決什麼
IntersectionObserver 通知幾何交會變化 資料請求、請求排序、DOM 上限
無限捲動載入 分批取得更多資料 已加入 DOM 的列持續累積
虛擬化 限制呈現範圍與 DOM 數量 網路請求、資料記憶體上限

三者可以合作,也可以分開使用。管理介面有一萬筆結果時,可能先需要分頁與可預測的導航;不必直接改成無限捲動。真的需要虛擬化,還要檢查鍵盤焦點、螢幕閱讀器、列高度、頁面內搜尋與返回捲動位置。

web.dev 的上述文章發表於 2019 年。這裡採用它的 windowing 原理,不把其中的 react-window 範例當成現行套件 API 或安裝建議。

7. 圖片懶載入,先用更簡單的選項

只是想延後載入離畫面很遠的普通圖片,通常先考慮原生 loading="lazy",並提供 width、height 保留空間。需要自己的觸發條件或其他工作時,再使用 observer。首屏重要圖片,尤其可能成為 LCP 的圖片,不應一律 lazy-load。web.dev:瀏覽器原生圖片懶載入

我學到什麼

  • 我會先決定「跟哪個區域比較、提前多遠、跨過什麼比例」,而不是看到 callback 就認定內容被看過。
  • 我會將交會通知、取資料與呈現分開處理,並在 await 前設定載入狀態;停止 observer 不代替取消請求。
  • 我會分別估算資料量與 DOM 量。虛擬化省的是呈現範圍,不能代替分頁、快取或可用性設計。

開始練習時,用兩張卡片與一個 observer 就夠了:觀察初始通知、改一次 rootMargin,再比較一次 threshold。先能解釋通知為什麼出現,再把它接到真正的載入流程。

外部參考資料與延伸學習