1209 字
6 分鐘

IntersectionObserver 無限捲動重複請求?先鎖定載入與分頁狀態

無限捲動的頁尾明明只出現一次,Network 卻多出兩筆相同 cursor 的請求。把 threshold 調大有時能暫時減少觸發,但資料載入的併發狀態仍沒有被管理。

IntersectionObserver 只負責通知交集變化;是否可以發下一頁,應由同一個載入器管理。 observer 與「載入更多」按鈕共用入口,成功附加資料後才推進 cursor,失敗時保留原 cursor 等待明確重試。

callback 出現,不代表使用者剛捲了一次#

MDN 的 observe() 文件說明,開始觀察後就會有初始通知,目標在畫面外也可能得到 isIntersecting: false 的 entry。一個 callback 還可能包含多個目標的變化。

先篩選自己關心的 sentinel,再檢查 isIntersecting。不要在 callback 裡對每個 entry 無條件 fetch,也不要假設第一次通知一定是「讀者已滑到底」。rootMargin 可以提早載入,但不會替網路請求提供互斥鎖。

把請求與 observer 拆開,才能測失敗路徑#

以下載入器約定 API 回傳 { items: [], nextCursor: string | null },null 代表沒有下一頁。初始 cursor 也是 null,由 API 將它解讀成第一頁。空字串不作有效 cursor。

function createPageLoader(fetchPage, appendItems) {
let cursor = null;
let inFlight = false;
let hasMore = true;
let disposed = false;
const controller = new AbortController();
return {
async loadMore() {
if (inFlight || !hasMore || disposed) return false;
inFlight = true; // 必須在第一個 await 之前鎖定。
try {
const page = await fetchPage(cursor, controller.signal);
if (disposed) return false;
if (!page || !Array.isArray(page.items) ||
!(page.nextCursor === null ||
(typeof page.nextCursor === 'string' && page.nextCursor.length > 0))) {
throw new TypeError('分頁回應格式錯誤');
}
if (page.nextCursor !== null && page.nextCursor === cursor) {
throw new Error('cursor 沒有前進');
}
appendItems(page.items);
cursor = page.nextCursor;
hasMore = cursor !== null;
return true;
} finally {
inFlight = false;
}
},
dispose() {
disposed = true;
controller.abort();
},
};
}

inFlight 在發出請求前就設定,所以同一輪通知和按鈕連點都不會同時發送。請求或回應驗證失敗時,finally 會解除鎖,cursor 保持原值;成功完成附加後才前進。

appendItems 在此約定為同步且整批完成。正式元件若可能只插入一半就拋錯,應先建好 fragment 再提交,或用資料 ID 做去重與狀態復原;這個 helper 沒有替畫面提供交易。相同 cursor 檢查也只攔截緊接的停滯,無法偵測 API 在多個舊 cursor 間循環或跨頁回傳相同 item。

observer 與按鈕共用 loadMore#

下面是瀏覽器端接線示意,假設已建立 loader、頁尾 sentinel、moreButton 與 status 文字節點。fetchPage 應自行檢查 HTTP 狀態、解析 JSON,並把 signal 傳給 fetch。錯誤分流可參考fetch JSON 解析失敗的檢查順序。

let stopped = false;
let observer;
async function requestMore() {
try {
const loaded = await loader.loadMore();
if (!stopped && loaded) status.textContent = '已載入';
} catch (error) {
if (stopped) return;
observer?.unobserve(sentinel);
status.textContent = '載入失敗,請按「載入更多」重試';
}
}
moreButton.addEventListener('click', requestMore);
if ('IntersectionObserver' in window) {
observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.target === sentinel && entry.isIntersecting)) {
void requestMore();
}
}, { rootMargin: '0px 0px 200px 0px' });
observer.observe(sentinel);
}
function cleanup() {
stopped = true;
observer?.disconnect();
moreButton.removeEventListener('click', requestMore);
loader.dispose();
}

發生錯誤後停止自動觀察,讓讀者透過按鈕重試,避免一個故障 API 被交集變化持續重送。此簡化接線在重試成功後仍維持手動模式;若產品要恢復自動載入,應在成功後明確重新 observe(),並測試 sentinel 仍在交集內的情況。

最後一頁之後,loader 會拒絕後續請求,但 UI 仍應另行顯示「已到最後一頁」並停用按鈕。示意接線未提供完整可存取 UI;正式介面還需要可見狀態、適當的 live region、穩定焦點與不會突然跳動的版面。

Sentinel 一直在畫面內,也不保證一直自動補頁#

IntersectionObserver 通知的是交集狀態變化,不能把它當成「持續可見就每隔一段時間再叫一次」的排程器。若回傳資料很少、頁尾一直留在畫面內,可能沒有新的 threshold crossing。保留按鈕可讓讀者繼續載入;若產品要求自動補滿 viewport,需設計有頁數上限的補頁流程,不能寫一個沒有停止條件的遞迴 fetch。

這與scrollIntoView 的捲動容器選擇是不同層次的問題。容器內的列表,observer 的 root 也應選擇該容器,並驗證 sentinel 的位置及祖先裁切;調整 API 鎖不會修正錯誤的觀察範圍。

本文維護流程於 2026-10-20 在 Node.js 24.13.0 驗證 loader 的同時呼叫、失敗後重試、最後一頁、停滯 cursor、錯誤格式及卸載後晚到結果。observer 接線僅檢閱,沒有執行真實頁面捲動、觸控或輔助科技測試;Node 的邏輯檢查不能當作瀏覽器幾何與可存取性驗收。

參考資料:

MDN:IntersectionObserver.observe()

MDN:IntersectionObserver

W3C:Intersection Observer

IntersectionObserver 無限捲動重複請求?先鎖定載入與分頁狀態
https://laplusda.com/posts/intersectionobserver-infinite-scroll-duplicate-requests/
作者
Zero
發佈於
2026-10-20
許可協議
CC BY-NC-SA 4.0