大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
分页与综合
这一课你将能做到
- 实现符合 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]
三个工程细节:take+1 探路判断 hasNextPage(免去 count 全表);cursor 不透明(客户端不许解析,服务端想换实现随时换);first 设上限防 first: 100000 打爆。
步骤 3:客户端无限滚动(Apollo)
const { data, fetchMore } = useQuery(LESSONS, { variables: { first: 20 } })
const loadMore = () => fetchMore({
variables: { after: data.lessons.pageInfo.endCursor }
})
// Apollo 缓存里配置合并策略,新页自动拼接:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
lessons: relayStylePagination() // 按 Connection 规范自动合并分页
}
}
}
})
步骤 4:什么时候用 offset
后台管理列表「跳到第 5 页」这种需求,游标做不到(无法直接定位),用 offset:
type Query {
adminLessons(page: Int!, pageSize: Int!): LessonPage!
}
type LessonPage {
items: [Lesson!]!
totalCount: Int!
page: Int!
}
取舍一句话:面向用户的无限滚动用游标,面向管理的页码跳转用 offset。同一张表可以提供两种字段,不冲突。
步骤 5:综合实战——课时学习闭环
把 11 课的知识串成一个可交付需求「课时详情页 + 提交判题 + 进度更新」:
# ① 查询(Query 课:变量 + 片段 + 指令)
query LessonPage($slug: String!, $loggedIn: Boolean!) {
lesson(slug: $slug) {
...LessonCore
myProgress @include(if: $loggedIn) { status percent } # 授权课:字段可空
}
}
fragment LessonCore on Lesson { id title content difficulty }
# ② 提交(Mutation 课:input/payload/userErrors + 幂等)
mutation Submit($input: SubmitCodeInput!) {
submitCode(input: $input) {
submission { id status }
progress { id percent } # 客户端课:带 id 自动更新归一化缓存
userErrors { field message }
}
}
# ③ 判题结果实时推送(Subscription 课)
subscription OnJudge($id: ID!) {
submissionStatusChanged(submissionId: $id) { id status }
}
服务端配套(执行课 + 授权课 + 库课):resolver 薄、Service 厚;DataLoader 批取作者;@BatchMapping 或 loader 治 N+1;深度限制 ≤ 10;敏感字段可空 + FORBIDDEN 错误码。上线前用规范课的知识自测:校验错误 400、执行错误 200 + errors、extensions.code 齐全。
讲清楚要点
- Connection 五件套:edges/node/cursor + pageInfo/startCursor/endCursor,命名照抄规范,客户端库(relayStylePagination)才能自动识别。
- cursor 是不透明的:base64 编码只为传输,客户端解析 cursor 内容属于踩私约,服务端改实现就会崩。
- take+1 探路法:多取一条判断 hasNextPage,避免额外 COUNT 查询;totalCount 单独算且可选。
- first 必须有服务端上限:分页参数是资源消耗阀门,
min(first, 100)是底线。 - 排序字段与游标同源:按 id 排序就用 id 游标;按 createdAt 排序要用 (createdAt, id) 复合游标防同时间戳漏数据。
- 游标 vs offset 是场景题:插入频繁的流式列表游标唯一正确;需要跳页/总页数的后台管理 offset 更直白。
- 分页一致性:翻页期间数据变动,游标保证不重复不遗漏(按定位点),offset 不保证——这是面试的分水岭答案。
你可能会懵的地方
- 为什么 edges 要包一层,不直接 nodes: [Lesson!]!? 边承载「这条记录在这个列表里的位置信息」(cursor)。规范也允许同时提供 nodes 便捷字段,但 cursor 必须在边上。
- hasPreviousPage 怎么算?
向前翻场景近似为
!!after即可;精确实现要反向探路。多数产品只暴露向前翻。 - 排序换了(比如按热度)游标怎么办? 游标内容跟着排序键走:复合游标编码 (score, id)。Schema 层面 cursor 不变——这就是不透明的红利。
- totalCount 为什么可选? 大表 COUNT 很贵。无限滚动根本不需要总数,需要页码的 offset 场景才算。
- last/before 必须实现吗? 规范声明了双向签名,但实现可以只支持向前;客户端发起 last 查询时返回错误或降级,文档里写清。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
列表直接 [Lesson!]! 全量返 | 数据量上来即崩 | Connection 游标分页 |
| 无限滚动用 offset | 插入新数据后重复/漏项 | 游标分页 |
| cursor 用明文 id 且客户端解析 | 私约耦合 | base64 不透明 cursor |
| first 不设上限 | 一次拉 10 万条 | min(first, 100) |
| 用 COUNT 判断 hasNextPage | 每页一次全表 COUNT | take+1 探路 |
| 按 createdAt 排序却用 id 游标 | 同时间戳数据错乱 | 复合游标 (createdAt, id) |
卡点急救
- 翻页后出现重复课时:用了 offset 且期间有新数据插入;或游标排序键不唯一——换游标 / 加 id 做 tiebreaker。
- Apollo fetchMore 后列表变乱:typePolicies 没配 relayStylePagination,各页数据互相覆盖。
- endCursor 传回去报「无效游标」:cursor 被客户端 URL 编码搞坏(base64 含
=),或游标过期——cursor 里带时间戳的版本要处理过期语义。
面试常问
- 游标分页和 offset 分页的区别与取舍? offset 按位置翻页,实现简单可跳页,但翻页间隙数据增删会导致重复/遗漏,且深翻页性能差(OFFSET 100000)。游标按「定位点之后取 N 条」,对增删免疫、索引友好,代价是不能随机跳页。用户侧流式列表用游标,管理侧跳页用 offset。
- Relay Connection 规范的核心结构? edges(node + cursor)+ pageInfo(hasNextPage/hasPreviousPage/startCursor/endCursor),查询参数 first/after 向前、last/before 向后。cursor 是不透明字符串,标准化后客户端库可自动处理分页合并。
- hasNextPage 怎么高效判断? 查询时 take = first + 1,实际返回 first 条;若查到 first+1 条则 hasNextPage 为 true,丢弃多取的那条。避免对全表做 COUNT。
自测三问
- cursor 为什么必须对客户端不透明?
- 按热度排序的列表,游标应该怎么设计?会踩什么坑?
- 本课的「take+1」技巧省掉了什么查询?
小结一句话
游标分页 + Connection 规范是 GraphQL 列表的标准答案:edges/pageInfo 照抄规范、cursor 不透明、take+1 探路、first 设上限;管理后台跳页场景再退回 offset。至此,从类型到执行、从安全到分页,你已具备交付生产级 GraphQL 服务的完整拼图。
延伸
- Relay Connection 规范
- 官方文档:Pagination
- 恭喜完成本 track!下一步:在生产项目中落地一门课程,或研读 GraphQL 联邦与网关治理。