GraphQL 分页实战:用 Connection、Edge、Node 拆解游标翻页的每一步
1. 从 offset 翻页到游标翻页为什么 GraphQL 分页总在第二页出问题如果你是从 RESTful 转过来的第一反应大概率是给查询加pageNum和pageSize两个参数然后后端LIMIT offset, size一拼就完事。我最早做 GraphQL 接口时也这么干直到线上出现一个诡异现象用户翻到第二页看到的内容和第一页有重复刷新一下又变了。原因很简单——两次查询之间列表头部插入了新数据offset 整体后移原本第一页的最后一条被挤到了第二页。GraphQL 官方推荐的方案是Connection / Edge / Node三件套配合pageInfo里的hasNextPage、endCursor做游标翻页。游标不是数字下标而是一个指向「某条记录之后」的位置标记数据增删不会让已翻过的页错位。这篇内容解决三个具体问题第一把 offset 分页迁移到 cursor 分页时schema 该怎么定义第二Connection、Edge、Node各自负责什么pageInfo四个字段怎么协作第三本地怎么发请求验证翻页边界和空页行为。适合正在写 GraphQL 服务端、或者对接 GitHub GraphQL API 这类游标分页接口的同学。核心检索词先摆出来GraphQL 分页、Connection、Edge、Node、游标翻页、pageInfo、hasNextPage、endCursor。下面从 schema 定义一路走到本地验证。2. TaoToken 前置准备给 GraphQL 分页调试配一个稳定的模型对话入口写 GraphQL 分页逻辑时我经常需要一边查文档一边让模型帮我解释pageInfo的边界行为或者让它根据报错反推 schema 哪里写错了。这时候一个能稳定对话、支持长上下文的技术模型入口就很有用。TaoToken 提供的就是这类模型调用能力官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。需要说清楚TaoToken 不是 GraphQL 服务器也不替代你的后端。它的定位是模型对话与编码辅助入口你可以把它理解成一个「随时能问 GraphQL 分页细节」的助手。比如你不确定hasPreviousPage在first查询里为什么总是false可以直接把 schema 片段贴进去问。接入方式上如果你用的是兼容 OpenAI 协议的客户端把 Base URL 指向https://taotoken.net/api再配上在控制台生成的 API Key 即可。控制台地址是 https://taotoken.net/console API Key 管理在 https://taotoken.net/api-keys 。模型对话入口在 https://taotoken.net/chat 。对于长期写 GraphQL 服务、需要反复调试分页逻辑的场景Coding Plan 会更合适地址是 https://taotoken.net/coding-plan 。如果你用 Claude Code 做开发接入文档在 https://taotoken.net/doc Claude Code 相关配置参考 https://taotoken.net/claudecode 。这里要提醒一句模型对话适合用来解释概念、生成 schema 草稿、分析报错但分页的最终正确性必须靠本地真实请求验证。下面第三节开始就是可复制的配置和查询。3. 可复制配置Connection / Edge / Node 的 schema 与查询片段先给一份最小可用的 schema。假设我们要分页的是「文章列表」用 SDL 写type Query { articles(first: Int, after: String, last: Int, before: String): ArticleConnection! } type ArticleConnection { edges: [ArticleEdge!]! pageInfo: PageInfo! totalCount: Int! } type ArticleEdge { cursor: String! node: Article! } type Article implements Node { id: ID! title: String! createdAt: String! } interface Node { id: ID! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String }三个角色的分工要记牢Connection是「被分页的整个集合」Edge是「集合里的一条边」Node是「边指向的实体」。cursor挂在 Edge 上而不是 Node 上因为同一条记录在不同排序下位置不同游标描述的是位置不是实体本身。查询第一页取 3 条query FirstPage { articles(first: 3) { edges { cursor node { id title } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }拿到结果后pageInfo.endCursor就是下一页的起点。查询第二页query NextPage($after: String!) { articles(first: 3, after: $after) { edges { cursor node { id title } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }变量{ after: Y3Vyc29yOnYyOpHOEkqDlw }如果你用 Apollo Serverresolver 里first和after的解析逻辑大致是先按createdAt排序找到after对应游标的那条记录取它之后的first条再多取一条用来判断hasNextPage。多取的那条不返回只用来置位。async function resolveArticles(_, { first 10, after }) { const limit first 1; let rows; if (after) { const decoded decodeCursor(after); rows await db.article.findMany({ where: { createdAt: { lt: decoded.createdAt } }, orderBy: { createdAt: desc }, take: limit, }); } else { rows await db.article.findMany({ orderBy: { createdAt: desc }, take: limit, }); } const hasNextPage rows.length first; const edges rows.slice(0, first).map((row) ({ cursor: encodeCursor(row), node: row, })); return { edges, pageInfo: { hasNextPage, hasPreviousPage: Boolean(after), startCursor: edges[0]?.cursor ?? null, endCursor: edges[edges.length - 1]?.cursor ?? null, }, totalCount: await db.article.count(), }; }这段 resolver 的关键点take: first 1多取一条hasNextPage靠它判断hasPreviousPage在传了after时为true因为前面必然有数据。空页时edges为空数组startCursor和endCursor都返回null前端要能处理这两个null。4. 验证请求与成功结果翻页边界和空页行为怎么测配置写完后必须用真实请求验证。我用 curl 发 POST 请求先测第一页curl -X POST http://localhost:4000/graphql \ -H Content-Type: application/json \ -d {query:query { articles(first: 3) { edges { cursor node { id title } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }}预期结果结构{ data: { articles: { edges: [ { cursor: Y3Vyc29yOnYyOpHOEnC_Ug, node: { id: 1, title: 第一篇 } }, { cursor: Y3Vyc29yOnYyOpHOEocLDg, node: { id: 2, title: 第二篇 } }, { cursor: Y3Vyc29yOnYyOpHOF1taJw, node: { id: 3, title: 第三篇 } } ], pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: Y3Vyc29yOnYyOpHOEnC_Ug, endCursor: Y3Vyc29yOnYyOpHOF1taJw } } } }注意hasPreviousPage是false因为这是第一页前面没有数据。把endCursor的值填进第二页请求curl -X POST http://localhost:4000/graphql \ -H Content-Type: application/json \ -d {query:query($after: String!) { articles(first: 3, after: $after) { edges { cursor node { id title } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } },variables:{after:Y3Vyc29yOnYyOpHOF1taJw}}第二页的hasPreviousPage应该变成true。继续翻到最后一页hasNextPage会变成falseendCursor指向最后一条。空页行为要单独测把after设成一个不存在的游标或者设成最后一条的游标再取first: 3。预期返回{ data: { articles: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: true, startCursor: null, endCursor: null } } } }这里最容易踩的坑是edges为空时startCursor和endCursor必须是null不能是空字符串。前端如果直接拿endCursor去请求下一页null会被序列化成null字符串导致后端解析游标失败。所以前端翻页逻辑里要加判断hasNextPage false或endCursor null时停止请求。再测一个边界first: 0。有些实现会返回空edges但hasNextPage: true这取决于你的 resolver 怎么处理。建议在 resolver 里对first 0直接返回空连接避免多取一条的逻辑出错。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth 报错对照调试 GraphQL 分页时报错往往不在分页逻辑本身而在请求链路。下面按真实报错对照排查。401 Unauthorized如果你在请求头里带了Authorization: Bearer key但 key 无效或过期服务端直接拒绝。检查 API Key 是否从 https://taotoken.net/api-keys 正确复制注意不要有多余空格。如果是本地 GraphQL 服务返回 401检查你的鉴权中间件是否把 GraphQL 端点也拦截了。local proxy failed这个报错通常出现在客户端配置了本地代理但代理没启动时。如果你在用某个客户端连模型接口Base URL 要填https://taotoken.net/api不要填localhost或带端口的本地地址。本地 GraphQL 服务调试时curl 不要走系统代理加--noproxy *试试。reading choices这个报错一般出现在解析模型返回结构时代码期望choices字段但实际返回结构不同。如果你用兼容 OpenAI 协议的客户端确认请求体里的model参数和响应结构匹配。GraphQL 这边如果报类似「reading edges」的错检查你的查询里edges是否被正确嵌套在Connection下别把edges直接挂在Query上。OAuth 相关报错如果你用 Claude Code 接入配置里涉及 OAuth 流程参考 https://taotoken.net/claudecode 的说明。常见问题是回调地址不匹配或 token 过期。GraphQL 服务端如果用 OAuth 做鉴权检查Authorization头是否在翻页请求里也带上了——很多人第一页带了第二页用变量请求时忘了带。游标解析失败报错类似Invalid cursor或Cannot decode cursor。原因通常是前端把endCursor的null当字符串传了或者游标在 URL 传输中被转义。游标是 base64 字符串可能包含、/、放进 URL query 时要 encodeURIComponent。hasNextPage 永远为 true检查 resolver 里是不是忘了take: first 1或者多取的那条被错误地返回了。如果rows.length始终大于first说明你的查询没有正确限制数量。第二页和第一页重复排序字段不唯一。如果按createdAt排序但有多条记录时间相同游标定位会不稳定。解决办法是排序里加一个唯一字段做 tiebreaker比如orderBy: [{ createdAt: desc }, { id: desc }]。6. 语义一致的 CTA把分页调试和模型辅助串起来分页逻辑写完后我习惯把 schema、resolver 和报错一起丢给模型让它帮我检查pageInfo四个字段的赋值有没有遗漏。这个动作在 TaoToken 的模型对话入口就能做地址是 https://taotoken.net/chat 。如果你更习惯在编辑器里让模型补全 resolverCoding Plan 的长期编码场景更合适https://taotoken.net/coding-plan 。接入配置本身不复杂Base URL 用https://taotoken.net/apiKey 在 https://taotoken.net/api-keys 生成模型 ID 按你客户端支持的填。文档在 https://taotoken.net/doc 。需要提醒的是模型可以帮你生成 schema 草稿和排查思路但hasNextPage、endCursor这些边界值必须用第 4 节的 curl 请求在本地跑一遍才算数。最后留一个我实际踩过的坑空页时endCursor返回null前端如果没判断就直接拼进下一次请求会得到一个after: null的查询后端解析游标时报错。加一行if (!pageInfo.hasNextPage) return;就能避免。分页的稳定性往往就藏在这些边界判断里。