IntersectionObserver 無限捲動的可靠性,不在於把 callback 接上載入函式,而在於建立清楚的觀察契約:誰是滾動根、sentinel 何時存在、一次只能有幾個請求,以及新頁資料如何合併。只要其中一項含糊,就可能出現只載入一次、重複請求、舊頁被覆蓋,或畫面看似分頁但後端仍全量讀取的假象。

本文聚焦平面清單的資訊型搜尋意圖,說明瀏覽器交集判定、React ref 生命週期、資料狀態機與可重現測試。內容不假設特定後端、外部身分資料或既有系統;所有驗證都能在本機測試頁完成。

核心答案:可靠的無限捲動需要四層契約

可靠實作必須同時固定滾動根、觀察目標生命週期、請求狀態機與追加合併規則;IntersectionObserver 只負責提供交集訊號,不能替應用程式保證分頁正確、請求去重或排序完整。

第一層是幾何契約。root: null 代表以頂層 viewport 為交集根;若真正滾動的是固定高度且有 overflow: auto 的容器,就應把該容器傳給 root。sentinel 必須位於清單尾端,而且在相關裁切鏈與交集根內具有可判定的位置。祖先容器的裁切會影響可見區域,這不是錯誤,而是規格的一部分。

第二層是生命週期契約。條件渲染可能讓元件第一次執行 effect 時還沒有 sentinel。若程式只在 mount 讀一次 ref.current,之後才出現的節點永遠不會被 observe。穩定做法是使用 callback ref 或讓 effect 依賴實際 DOM 節點;節點更換時先停止觀察舊節點,再觀察新節點。

第三層是請求契約。交集 callback 可能在初始版面、捲動、尺寸變更或內容追加後再次排程。載入函式必須以 isLoading、hasNextPage 與目前游標防止重入,而不是假設一次捲動只會收到一次通知。

第四層是資料契約。第一頁或條件改變採 replace;後續頁採 append;合併時依穩定識別欄位去重。若定時更新只帶少量狀態,應合併到既有項目,不應用小批資料覆蓋整份已載入清單。

root、裁切鏈與 sentinel 應如何配置

當頁面內層容器負責捲動時,請把該容器設為 root,並將 sentinel 放在同一容器的清單末端;若由整頁捲動,才使用 null,避免以錯誤座標系解讀「可見」。

以下配置是最容易檢查的基線:滾動容器有確定高度,清單項目依序排列,sentinel 是清單最後一個正常流元素。rootMargin 可以提早觸發預載,但它是根邊界的外擴或內縮,不是修復錯誤 DOM 階層的工具。threshold: 0 表示只要跨越零交集門檻就可能排程通知;實務上仍應檢查 entry.isIntersecting。

不要把 sentinel 放在滾動容器外,卻期待它反映內層內容的捲動進度。若外部 sentinel 一開始就位於 viewport,它可能只在初始狀態回報一次;內層清單如何移動並不會讓這個外部節點穿越交集邊界。也不要以增加 threshold 陣列來掩蓋位置錯誤,因為更多門檻只會增加可能的通知點,不會改變根與目標的空間關係。

固定表頭不會自動破壞無限捲動。真正重要的是表頭、內容、sentinel 與 root 的裁切關係。若 sticky 元素遮住視覺區域,交集比例與使用者實際可閱讀範圍可能不同;這時應以測試確認預載距離,而不是聲稱某個通用數值在所有版面都最佳。

除錯時先在開發工具確認三件事:root 是否為預期節點、target 是否真的存在、target 的 bounding rectangle 是否會隨捲動穿越 root 邊界。這三項比反覆調整 callback 更快定位問題。

React 條件渲染與 observer 生命週期

React 實作應把「目前可觀察的 DOM 節點」當成狀態,而不是只保存一個永不重新檢查的 ref;節點尚未建立時不觀察,建立或替換後立即更新觀察關係,卸載時確實停止觀察。

可採 callback ref:收到新節點時更新 sentinelNode,effect 依賴 sentinelNode、rootNode 與必要選項。effect 內建立 observer,若兩個節點條件成立就呼叫 observe,cleanup 則呼叫 disconnect。這個模式能處理載入骨架切換為表格、搜尋結果從空集合變成有資料,以及分頁後尾端 sentinel 被重新建立等情況。

callback 本身要保持窄責任。它只判斷是否交集、是否仍有下一頁、目前是否正在載入,然後發出單一載入事件。資料抓取、游標更新與錯誤呈現留在資料層。若 callback 每次 render 都換成新函式,observer 可能頻繁重建;使用穩定函式或把最新狀態放在受控 ref,可降低不必要的生命週期震盪。

清理並非形式工作。舊 observer 若仍持有舊 target,可能在元件已改變後排入 callback,造成重複載入或過期狀態判斷。disconnect() 會停止該 observer 對所有目標的觀察;若共用一個 observer 管理多個目標,則可針對單一節點使用 unobserve()。選擇哪一個取決於元件是否真的共享 observer,不應混用而不記錄所有權。

開發模式可能更容易暴露 effect 建立與清理不對稱。正確方向不是關閉檢查,而是確保每次建立都對應一次清理,並讓請求層具備冪等防護。這樣即使通知時機不同,也不會把幾何事件直接放大成重複資料。

分頁狀態機、去重與排序邊界

無限捲動應以明確狀態機管理 fresh search、load next、success、empty、failure 與 exhausted;第一頁替換集合,後續頁追加且依穩定鍵去重,任何條件改變都重設游標與下一頁判定。

游標分頁比單純頁碼更適合持續變動的清單,但游標必須與後端排序條件綁定。前端不能只拿最後一筆識別值,卻忽略後端主要排序欄位。請求條件應由單一建構函式產生,讓搜尋、下一頁與必要的匯出流程共享相同過濾與排序定義,避免各自演化。

去重 reducer 應先定義穩定鍵,再決定新舊資料衝突時誰優先。若後頁可能帶回已存在項目的較新狀態,就以新值覆蓋對應欄位並保留順序;若回應是不可變快照,則可忽略重複鍵。不要用陣列索引當鍵,因為追加、排序或刪除後索引會改變。

定時更新與分頁最常見的衝突,是小批更新採 replace,導致先前追加的頁面消失。安全模式是讀取目前集合,依穩定鍵把狀態差異合併進既有項目;未出現在更新批次的項目保留。輔助選項資料也要遵守同一原則:fresh search 可重建,load next 則合併去重。

排序邊界必須向讀者說清楚。若前端只載入部分資料,client-side sort 只能重排已載入項目,不能代表完整集合的全域順序。需要跨頁一致排序時,排序欄位與方向要送到後端,並納入游標契約。無限捲動不是把完整查詢搬到瀏覽器後切片;後端仍全量查詢再切頁,只是把成本藏起來。

驗證與重現:在本機瀏覽器測試三個契約

以下測試在隔離的本機 HTML 頁面建立固定高度 root,先確認 sentinel 從不相交轉為相交,再測試節點延後建立後才 observe,最後確認 disconnect 後捲動不再產生相交通知。

前置條件固定為 Node.js 22.23.2、playwright-core 1.62.1 與 Chromium 151;不連接後端、不寫入外部資料,也不使用真實資料。將下列內容存成 verify-io.cjs:

const { chromium } = require('playwright-core');
const assert = require('node:assert/strict');

async function makeCase(page, delayed = false, disconnectEarly = false) {
  return page.evaluate(async ({ delayed, disconnectEarly }) => {
    const wait = ms => new Promise(r => setTimeout(r, ms));
    const waitUntil = async (predicate, timeout = 2000) => {
      const deadline = performance.now() + timeout;
      while (!predicate()) {
        if (performance.now() >= deadline) {
          throw new Error('Timed out waiting for observer state');
        }
        await wait(20);
      }
    };
    const root = document.createElement('div');
    root.style.cssText = 'height:120px;overflow:auto;width:300px';
    document.body.append(root);
    const states = [];
    const observer = new IntersectionObserver(
      entries => states.push(...entries.map(e => e.isIntersecting)),
      { root }
    );
    if (delayed) await wait(50);
    const spacer = document.createElement('div');
    spacer.style.height = '500px';
    const target = document.createElement('div');
    target.style.height = '10px';
    root.append(spacer, target);
    observer.observe(target);
    await waitUntil(() => states.length > 0);
    if (disconnectEarly) observer.disconnect();
    root.scrollTop = 500;
    if (disconnectEarly) await wait(300);
    else await waitUntil(() => states.includes(true));
    observer.disconnect();
    return states;
  }, { delayed, disconnectEarly });
}

(async () => {
  const executablePath = process.env.CHROME_PATH;
  assert(executablePath, 'Set CHROME_PATH to a compatible Chromium or Chrome executable.');
  const browser = await chromium.launch({ headless: true, executablePath });
  const page = await browser.newPage();
  await page.setContent('');
  const normal = await makeCase(page);
  assert(normal.includes(false) && normal.includes(true));
  const delayed = await makeCase(page, true, false);
  assert(delayed.includes(true));
  const stopped = await makeCase(page, false, true);
  assert(!stopped.includes(true));
  console.log('PASS rooted-scroll, delayed-target, disconnect');
  await browser.close();
})().catch(error => { console.error(error); process.exit(1); });
  1. 建立空白測試資料夾,執行 npm init -y,再執行 npm install --save-dev playwright-core@1.62.1,並將環境變數 CHROME_PATH 設為相容的 Chromium 151 或 Chrome 151 可執行檔。
  2. 執行 node verify-io.cjs。通過條件是程序結束碼為 0,且輸出包含 PASS rooted-scroll, delayed-target, disconnect;任何 assertion、逾時或瀏覽器啟動錯誤都算失敗。
  3. 將 observer.observe(target) 改成 if (!delayed) observer.observe(target) 後重新執行。預期一般捲動案例仍會通過,但 delayed-target assertion 必須失敗,藉此證明條件渲染後必須對實際節點建立觀察。

最終校稿已在排程環境實際執行修正後的非破壞性測試:Chrome 151.0.7922.174、Node.js 22.23.2、playwright-core 1.62.1。為排除固定等待時間造成的偶發失敗,正向案例改為在兩秒上限內等待指定 observer 狀態;連續五次執行皆得到一般與延後案例的 [false, true],停止觀察案例則為 [false],總結果為通過。這是功能測試,不是效能量測,因此不推導速度、資源節省或成功率。

不適用情境、風險與實作清單

這個模式適合可線性排序的平面清單,不應直接套到依賴完整結構的遞迴樹、拖放階層或需要全域聚合的畫面;先判斷資料不變量,再決定是否分頁。

遞迴樹的 root 數量不等於節點總數,任意對 root 截斷可能破壞父子關係、移動驗證與關係圖。若使用者任務需要同時看見完整結構,應另行設計按層展開、子樹載入或伺服器端查詢,而不是把平面清單的 sentinel 契約硬套上去。

上線前逐項檢查:

  • root 與真正滾動容器一致,sentinel 位於清單尾端且沒有被錯誤條件永久移除。
  • effect 或 callback ref 能處理 target 延後建立與節點替換,cleanup 與建立對稱。
  • callback 同時檢查 isIntersecting、isLoading 與 hasNextPage,錯誤後可以受控重試。
  • fresh search 採 replace 並重設游標,load next 採 append 且依穩定鍵去重。
  • 定時更新與輔助資料使用 merge,不會覆蓋已載入頁面。
  • 跨頁排序由後端負責,游標與排序欄位形成同一契約。
  • 測試至少涵蓋初始不相交、捲動後相交、條件渲染、停止觀察與最後一頁。

結論很直接:把 IntersectionObserver 視為幾何訊號來源,把分頁視為資料狀態機。兩者各自可測試、透過明確介面連接,才能讓無限捲動在版面、生命週期與資料變動下仍維持可預測行為。