清單載入了兩筆資料,點「下一頁」,上一頁最後一筆又出現了。另一次翻頁,卻有一筆資料憑空消失。假設這是一個瀏覽期間仍在變動的工作清單:要解釋這些現象,得先看程式碼怎樣定義變動之後的「下一頁」。

把篩選、排序和擷取連起來讀

範例先按 createdAt 遞減,再按唯一的數字 id 遞減排列。這裡用小整數表示時間。六筆 open 資料的順序是 6、5、4、3、2、1,其中 5 和 4 的時間都是 50。另外還有一筆時間為 70、狀態為 done 的資料 0。

讀哪裡 找什麼規則
selected 先篩選狀態,再取一頁
compare 時間較新的在前,同時間時 ID 較大的在前
offsetPage / cursorPage 哪些資料屬於下一頁
呼叫端 保存 nextCursor、附加有效結果,查詢改變時重設

從「下一頁」的事件處理開始,沿參數找到篩選函式。只看最後的陣列擷取,會漏掉決定位置意義的篩選和排序。

同一個 offset 為什麼指向不同資料

這個實作略過目前符合條件的若干筆資料:

const compare = (a, b) => b.createdAt - a.createdAt || b.id - a.id;
const selected = (rows, status) =>
  rows.filter((row) => row.status === status).sort(compare);

function offsetPage(rows, offset, take = 2, status = 'open') {
  return selected(rows, status).slice(offset, offset + take);
}

第一頁傳回 6 和 5。之後只發生一次變動,再用 offset = 2 讀取第二頁,結果就不同了:

第一頁之後的變動 目前符合條件的順序 第二頁 發生了什麼
無 6、5、4、3、2、1 4、3 按預期繼續
插入時間為 70 的資料 7 7、6、5、4、3、2、1 5、4 5 重複
刪除 6 5、4、3、2、1 3、2 略過了 4

表中各列都從原始資料重新開始。略過的數量沒變,資料的位置變了。介面按 ID 去重能遮住重複,卻補不回已經漏掉的資料。

游標必須比較完整排序鍵

游標保存上一頁最後一筆資料的排序鍵。只用時間判斷,例如 createdAt < cursor.createdAt,會在讀到 5 之後漏掉 4,因為它們的時間都是 50。改成 <=,上一頁的資料又會被選中。

保留 (createdAt, id) 兩個值,並重用排序時的比較:

function cursorPage(rows, after = null, take = 2, status = 'open') {
  const items = selected(rows, status)
    .filter((row) => after === null || compare(row, after) > 0)
    .slice(0, take);
  const last = items.at(-1);
  return {
    items,
    nextCursor: last ? { createdAt: last.createdAt, id: last.id } : null,
  };
}

在這裡的遞減規則下,compare(row, after) > 0 表示資料嚴格排在保存的位置之後;時間相同則由 id 決定先後。前面的插入或刪除發生後,第二頁都會傳回 4、3。

nextCursor 物件複製排序鍵的值,而不是再次查找上一筆資料,因此刪除 5 之後仍能從保存的位置繼續。空頁傳回 null。這個範例可能需要額外請求一次,才知道清單已經結束。

分別執行這些變動

下載 pagination-reading.mjs,用 Node.js 22 或更新版本執行:

node --test pagination-reading.mjs

十個測試全部通過。三個名稱以 reproduce: 開頭的測試特意斷言錯誤結果,分別重現 offset 重複、offset 漏項,以及只比較時間的游標漏項。其餘七個檢查前方插入、前方刪除、刪除游標來源資料、含相同時間與末尾空頁的完整走訪、游標之後新增資料、先篩選再限制筆數,以及切換篩選後的重設。

測試每次建立新陣列,在兩次呼叫之間同步修改資料,不需要服務、帳號、計時器或安裝相依套件。

別漏看呼叫端

保留舊游標,把篩選改成 done,範例會傳回空頁:資料 0 排在舊游標之前。新查詢從 after = null 開始,才能傳回資料 0。篩選或排序變動時,呼叫端應同時重設累積資料和游標。

接著檢查 nextCursor 存在哪裡、何時被再次使用。這裡空頁傳回的 null 表示停止;把它直接傳回函式,會重新讀取第一頁。還要查重複發出請求、回應順序顛倒、同一回應被附加兩次。這些介面行為不在同步範例的測試範圍內;非同步選取變動的追蹤範例另外說明了過時回應的問題。

決定資料變動時讀者應該看到什麼

游標不提供快照。讀完第一頁再插入時間為 49 的資料 8,下一頁會變成 4、8。新資料排在保存的位置之後,因此會進入已經開始的走訪;排在游標之前的新資料則要等重新整理才能看到。

這裡能連續翻頁的前提是 ID 唯一,且仍存在的資料沒有改變排序鍵。修改排序欄位、改變狀態或刪除尚未讀到的資料,都需要另外確定產品規則;內容固定的匯出可能需要另行設計快照。這個陣列範例沒有驗證資料庫隔離、索引、傳輸或游標編碼,每次完整篩選和排序也不適合作為大量資料的效能方案。

讀自己的實作時,把篩選、比較、保存的排序鍵和呼叫端重設放在一起核對。在 Yenpo 中,可以開啟相關檔案並沿參照閱讀,逐步確認程式碼實際做了什麼。