主題: Web platform
IntersectionObserver 入門:進入畫面、載入資料與虛擬化是三件事
從 root、rootMargin、threshold 到初始通知與生命週期,用短小 JavaScript 範例理解 IntersectionObserver。釐清無限捲動的載入控制,以及虛擬化為什麼要限制 DOM,而不是刪掉資料。
動態迷因(展開/收合)
商品列表快捲到底時,先載入下一批;卡片進入畫面時,才啟動一次效果。這些需求看起來都在問:「它現在到了嗎?」
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:長列表虛擬化
動態迷因(展開/收合)
用一個純粹的假設算算:資料有一萬列,每列固定高 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。先能解釋通知為什麼出現,再把它接到真正的載入流程。
外部參考資料與延伸學習
- MDN Intersection Observer API:完整概念與相容性;本文只使用基本功能,不涵蓋所有擴充選項。
- Web Dev Simplified 原作者入門文章:用具體範例熟悉通知與設定。
- Learn Intersection Observer In 15 Minutes:同作者的入門影片,適合搭配空白頁面操作;API 語意仍以現行文件為準。
- web.dev 長列表虛擬化:理解 windowing,注意文章與套件範例的年代。