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 的邏輯檢查不能當作瀏覽器幾何與可存取性驗收。
參考資料: