列表加载了两条记录,点“下一页”,上一页最后一条又出现了。另一次翻页,却有一条记录凭空消失。假设这是一个浏览期间仍在变化的任务列表:要解释这些现象,得先看代码怎样定义变化之后的“下一页”。

把筛选、排序和截取连起来读

示例先按 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 中,可以打开相关文件并沿引用阅读,逐步确认代码实际做了什么。