列表加载了两条记录,点“下一页”,上一页最后一条又出现了。另一次翻页,却有一条记录凭空消失。假设这是一个浏览期间仍在变化的任务列表:要解释这些现象,得先看代码怎样定义变化之后的“下一页”。
把筛选、排序和截取连起来读
示例先按 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 中,可以打开相关文件并沿引用阅读,逐步确认代码实际做了什么。