分页与综合
分页策略与全栈小练习。
分页与综合
这一课你将能做到
- 实现符合 Relay Connection 规范的游标分页(first/after + edges/pageInfo)
- 说清 offset 分页与游标分页的取舍,并为不同页面选对策略
- 综合运用前 11 课知识,独立交付「课时列表 + 详情 + 提交 + 进度」的全栈闭环
为什么要学
「给我课时列表」这个需求,在数据量 50 条时用 lessons: [Lesson!]! 一把全返没问题;数据量 5 万条时,全量返回是灾难,offset 分页会漏数据(翻页间隙有新课时插入,第 2 页和第 1 页出现重复),只有游标分页能扛住。GraphQL 社区把这件事标准化成了 Relay Connection 规范——学会它,全世界的列表字段你都看得懂。
人话:offset 分页是「翻到第 3 页」,游标分页是「从我上次看到的那条往后给 20 条」——后者不怕中间插入新数据。
先建立图像
Connection 的结构(每个名字都是规范术语):
type LessonConnection {
edges: [LessonEdge!]! # 边:每条记录的包装
pageInfo: PageInfo! # 翻页元信息
totalCount: Int # 可选,总条数
}
type LessonEdge {
node: Lesson! # 真正的数据
cursor: String! # 这条记录的书签(不透明字符串)
}
type PageInfo {
hasNextPage: Boolean! # 还有没有下一页
hasPreviousPage: Boolean!
startCursor: String # 本页第一条的 cursor
endCursor: String # 本页最后一条的 cursor → 下次请求用它
}
一次翻页的对话:
# 第 1 页
{ lessons(first: 20) { edges { node { title } cursor } pageInfo { hasNextPage endCursor } } }
# 第 2 页:把上次的 endCursor 塞进 after
{ lessons(first: 20, after: "Y3Vyc29yOjIw") { ...同上 } }
跟着做
步骤 1:Schema 侧定义 Connection
type Query {
lessons(filter: LessonFilter, first: Int, after: String,
last: Int, before: String): LessonConnection!
}
input LessonFilter { trackSlug: String, difficulty: Difficulty }
first/after 向前翻、last/before 向后翻——双向都声明,实现可以只先支持向前。
步骤 2:服务端实现游标分页(以 ID 游标为例)
const resolvers = {
Query: {
lessons: async (_p, { filter, first = 20, after }, { db }) => {
const take = Math.min(first, 100) // ① 上限保护
const afterId = after ? decodeCursor(after) : null // ② 解码书签
const rows = await db.lesson.findMany({
where: {
...(filter?.trackSlug && { track: { slug: filter.trackSlug } }),
...(afterId && { id: { gt: afterId } }) // ③ 游标定位
},
orderBy: { id: 'asc' },
take: take + 1 // ④ 多取一条探路
})
const hasNextPage = rows.length > take
const items = rows.slice(0, take)
return {
edges: items.map(l => ({ node: l, cursor: encodeCursor(l.id) })),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: items[0] ? encodeCursor(items[0].id) : null,
endCursor: items.at(-1) ? encodeCursor(items.at(-1).id) : null
},
totalCount: await db.lesson.count({ where: filter?.trackSlug ? { track: { slug: filter.trackSlug } } : {} })
}
}
}
}
// cursor 是不透明字符串:base64("lesson:1042")
const encodeCursor = (id) => Buffer.from(`lesson:${id}`).toString('base64')
const decodeCursor = (c) => Buffer.from(c, 'base64').toString().split(':')[1]