与游标分页:从 GraphQL 连接模型到 usePaginationFragment 实践指南)
前端开发工具【免费下载链接】relayRelay is a JavaScript framework for building>项目地址https://gitcode.com/gh_mirrors/relay29/relay点击查看免费下载导读在构建数据驱动的 React 应用时几乎必然遇到查询一个列表的场景——而绝大多数情况下我们并不想一次性拉取全部数据而是按需、增量地获取列表的离散片段。Relay 通过 GraphQL 的Connections连接约定来实现这一点以cursor游标与count为输入进行游标分页cursor-based pagination服务端在返回所请求的切片的同时还会返回是否还有更多数据以及如何继续查询的元信息。读完本文你将掌握 Connections 的完整模型约定Connection / Edge / PageInfo、first/after/last/before四个分页参数的语义与算法以及在 Relay 中通过connection指令与usePaginationFragmentHook 落地分页列表的完整实战方案。一、为什么需要 Connections从一次性查全量到按切片增量加载假设我们要在页面上展示一个用户的好友列表。最朴素的做法是查询整个好友集合。但在真实产品中这往往不可行列表可能非常庞大例如数百个好友一次性拉取会浪费带宽并拖慢首屏渲染用户通常并不需要全部数据而是希望在滚动、点击加载更多等事件驱动下增量地获取列表的离散子部分。这种把列表切成离散片段、按需获取的做法就是分页Pagination。Relay 官方指南关联文档明确指出Relay 通过被称为Connections的 GraphQL 字段来完成分页。Connections 是带有特定参数集的 GraphQL 字段它接收一组参数来指定要查询列表的哪个切片slice它的响应中既包含被请求的切片数据也包含列表中是否还有更多数据以及如何继续查询这些数据的信息。正是这份附加信息让我们可以通过继续查询更多切片或页来实现分页。更具体地说Relay 执行的是游标分页用于查询列表切片的输入是cursor游标和count数量。游标本质上是不透明的令牌opaque token充当列表中某个位置的标记或指针——客户端把它原样交给服务端服务端据此定位从哪一条之后开始取。完整的约定细节可参见仓库中的 GraphQL Cursor Connections 规范。为什么是游标而不是 offset从源码与规范的命名看游标不依赖列表在两次请求之间可能变化的绝对序号且天然支持增量追加的语义——客户端无需维护已加载多少条的状态只需记住上一条数据的游标即可。二、Connection 模型的三层结构Connection、Edge 与 PageInfo在深入了解 Relay 如何使用 Connections 之前先理解服务端 Schema 上连接模型的约定。Relay 官方引导文档Why Connections? 同一主题的当前版讲解 connections.mdx通过设计推演解释了为什么连接模型长这样。有三点需要理解集合中某项数据的包含关系本身可能带有属性。例如好友列表里你可能想记录你们成为好友的日期。因此需要创建表示**边edge**的节点——即集合中条目与集合本身之间的关系。页本身有属性。例如是否存在下一页。这由一个表示当前页信息的节点来处理即PageInfo。分页依靠游标而非偏移量。游标是不透明符号指向结果集的下一页位置。把这三层落到 GraphQL Schema 上就得到了 Connections 规范website/spec/Connections.md定义的三种保留类型。2.1 Connection 类型名字以 Connection 结尾规范规定任何名字以Connection结尾的对象类型都被视为Connection 类型。一个连接类型必须包含以下两个字段此外可按需增加与连接相关的其他字段如totalCountedges返回一个包裹 edge 类型的列表pageInfo返回非空的PageInfo对象。2.2 Edge 类型edges列表中的元素由连接类型的edges字段以列表形式返回的类型是Edge 类型。每个 Edge 类型必须包含node连接所分页的真实记录注意规范明确要求该字段不能返回列表cursor序列化为字符串的不透明游标客户端应将其原样回传给服务端用于分页。Edge 的存在正是为了让关系本身拥有属性。例如好友连接中的FriendsEdge可以携带since字段记录建立好友关系的日期而node指向好友本人Friend。2.3 PageInfo 类型服务端必须提供名为PageInfo的类型它承载当前页的元信息字段类型含义hasNextPage非空Boolean是否还有下一页可加载hasPreviousPage非空Boolean是否还有上一页可加载startCursor可空Stringedges中第一个节点的游标无结果时为 nullendCursor可空Stringedges中最后一个节点的游标无结果时为 null规范还提到一个有趣的演进Relay ClassicLegacy时期并不定义startCursor/endCursor而是依赖选择每条边的cursorRelay Modern 开始改为选择startCursor和endCursor以节省带宽——因为它并不会用到中间各边的游标。一个符合规范的连接查询长这样来自规范文档{ user { id name friends(first: 10, after: opaqueCursor) { edges { cursor node { id name } } pageInfo { hasNextPage } } } }其中first参数用于切片请求 10 个好友after参数用于分页告诉服务端从该游标之后返回好友每条边的cursor恰好就是我们下一次要传给after的不透明字符串hasNextPage告诉我们是否已到达连接末尾。三、分页参数与算法first / after / last / before 的完整语义Connections 规范website/spec/Connections.md为连接字段定义了前后两个方向的分页参数。3.1 前向分页firstafterfirst非负整数指定返回的边数上限after游标类型指定从这个游标之后开始返回。服务端返回after游标之后的边且最多返回first条。通常的做法是把上一页最后一条边的cursor传给after。3.2 后向分页lastbeforelast非负整数指定返回的边数上限before游标类型指定到这个游标之前为止。服务端返回before游标之前的边且最多返回last条。通常的做法是把下一页第一条边的cursor传给before。3.3 边顺序约定规范对边的顺序有硬性要求无论使用first/after还是last/before边的整体顺序必须一致不得在使用last/before时反转。形式上使用before: cursor时最靠近cursor的边必须排在结果edges的最后使用after: cursor时最靠近cursor的边必须排在结果edges的最前。3.4 服务端的分页算法规范还给出了服务端确定返回哪些边的正式算法EdgesToReturn(allEdges, before, after, first, last): 1. edges ApplyCursorsToEdges(allEdges, before, after) 2. 若设置了 first - first 0 则报错 - 若 edges 长度大于 first则从末尾移除多余边 3. 若设置了 last - last 0 则报错 - 若 edges 长度大于 last则从开头移除多余边 4. 返回 edges ApplyCursorsToEdges(allEdges, before, after): 1. 若设置了 after找到 cursor 等于 after 的边移除它及其之前的所有元素 2. 若设置了 before找到 cursor 等于 before 的边移除它及其之后的所有元素规范的执行顺序是先用before/after过滤再用first切片最后用last切片。同时规范明确提示同时包含first与last强烈不鼓励因为会产生令人困惑的查询与结果——hasNextPage/hasPreviousPage的语义也会变得模糊。hasNextPage与hasPreviousPage的判定也遵循类似的算法例如当设置了first时对ApplyCursorsToEdges后的结果若多于first条则hasNextPage为true当设置了last时结果多于last条则hasPreviousPage为true。四、在 Relay 中声明一个 Connection 字段理解模型之后我们看看在 Relay 中如何声明一个连接。官方引导文档version-v17.0.0 的 connections 章节 与其配套的 pagination.md、rendering-connections.md给出了完整范式。一个典型的连接查询 fragment 如下以加载评论列表为例const StoryCommentsSectionFragment graphql fragment StoryCommentsSectionFragment on Story argumentDefinitions( cursor: { type: String } count: { type: Int, defaultValue: 3 } ) refetchable(queryName: StoryCommentsSectionPaginationQuery) { comments(after: $cursor, first: $count) connection(key: StoryCommentsSectionFragment_comments) { edges { node { ...CommentFragment } cursor } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } ;这个 fragment 实际上是一个加分页元数据的连接查询argumentDefinitions声明 fragment 参数$cursor类型String与$count类型Int默认值 3。这正是分页时 Relay 需要替换的变量refetchable(queryName: ...)让 fragment 可以被重新抓取refetchRelay 会据此生成一个分页查询这里是StoryCommentsSectionPaginationQuery以便用新的$cursor再次拉取connection(key: ...)告诉 Relayfragment 内的哪个字段是需要分页的连接。key必须是全局唯一的静态字符串官方推荐的命名约定是fragment_name_field_name如StoryCommentsSectionFragment_comments。这个 key 在后续通过 mutation 编辑连接内容updating-connections时也会用到。关于字段选型有一个重要提示Relay 的分页特性只作用于 fragment不作用于整个 query。因为查询通常在高层的路由组件中发起而真正展示分页列表的往往是更深的子组件。如果确实需要在 query 层分页官方建议把连接字段重构到定义在Query类型上的 fragment 中。五、用 usePaginationFragment 渲染连接并加载下一页5.1 渲染连接的 fragment在 React 组件中渲染连接字段使用usePaginationFragmentHook它等价于useFragment但额外提供分页能力。参考 rendering-connections.mdimport {graphql, usePaginationFragment} from react-relay; type Props { user: FriendsListComponent_user$key, }; function FriendsListComponent(props: Props) { const {data} usePaginationFragment( graphql fragment FriendsListComponent_user on User refetchable(queryName: FriendsListPaginationQuery) { name friends(first: $count, after: $cursor) connection(key: FriendsList_user_friends) { edges { node { ...FriendComponent } } } } , props.user, ); return ( {data.name ! null ? h1Friends of {data.name}:/h1 : null} div {(data.friends?.edges ?? []).map(edge { const node edge.node; return ( Suspense fallback{Glimmer /} FriendComponent user{node} / /Suspense ); })} /div / ); }要点数据访问路径与useFragment一致data.friends.edges.node使用usePaginationFragment的前提是fragment 内含被connection标记的连接字段且 fragment 本身带有refetchablerefetchable只能加在可重取的 fragment 上即定义在Viewer、Query、任何实现了Node的类型拥有id字段或fetchable类型上。5.2 触发加载loadNext、hasNext 与 isLoadingNext要真正分页从usePaginationFragment中取出loadNext、hasNext、isLoadingNext参考 pagination.mdfunction StoryCommentsSection({story}) { const { data, hasNext, loadNext, isLoadingNext, } usePaginationFragment(StoryCommentsSectionFragment, story); return ( {data.comments.edges.map(commentEdge Comment comment{commentEdge.node} / )} {hasNext ( LoadMoreCommentsButton onClick{() loadNext(3)} disabled{isLoadingNext} / )} {isLoadingNext SmallSpinner /} / ); }三个返回值各司其职返回值类型作用hasNextboolean连接中是否还有更多数据常用来决定是否渲染加载更多按钮loadNext函数触发加载下一页参数为本次要拉取的新条目数量如loadNext(10)表示再取 10 条isLoadingNextboolean下一页请求是否在途用于展示加载指示器关键行为当加载下一页的请求完成时连接会被自动更新组件自动重渲染为包含最新条目的状态——也就是说comments/friends字段始终包含目前已经加载到的全部条目。默认情况下Relay 会在分页请求完成后把新条目自动追加到连接的末尾。如果需要不同行为如前置插入、替换等可参考 Advanced Pagination Use Cases。5.3 省略 cursor 与 pageInfo一个很实用的细节当使用usePaginationFragment时你可以在 fragment 中省略cursor与pageInfo字段——Relay 会自动插入它们并用这些信息来驱动分页。前面的 fragment 因此可以简化成const StoryCommentsSectionFragment graphql fragment StoryCommentsSectionFragment on Story argumentDefinitions( cursor: { type: String } count: { type: Int, defaultValue: 3 } ) refetchable(queryName: StoryCommentsSectionPaginationQuery) { comments(after: $cursor, first: $count) connection(key: StoryCommentsSectionFragment_comments) { edges { node { ...CommentFragment } } } } ;5.4 与 Suspense 的配合loadNext可能让当前组件或新挂载的子组件进入 suspense 状态因此组件上方需要有Suspense边界包裹。为了获得最佳体验官方建议用startTransition包裹loadNext的调用从而在等待期间展示 pending/loading 状态而非直接回退到 fallbackonClick{() { startTransition(() { loadNext(3); }); }}六、Relay 底层是如何工作的从指令到运行时了解了用法之后再深入到仓库源码看看 Relay 内部如何把这些约定变成现实。以下实现事实均可在当前仓库源码中验证。6.1connection指令如何被消费getPaginationMetadata当组件调用usePaginationFragment时Relay 首先通过getPaginationMetadatapackages/relay-runtime/util/getPaginationMetadata.js从 fragment 节点提取分页所需的元信息通过getRefetchMetadata取得由refetchable生成的分页请求paginationRequest从refetchMetadata.connection读取连接元数据包含连接在 fragment 数据中的路径connectionPathInFragmentData从fragmentNode.metadata.connection读取方向信息forward/backward/bidirectional以及游标与数量对应的变量名如果 fragment 没有connection指令这里会抛出明确错误Did you forget to add a connection directive to the connection field in the fragment?。6.2 分页变量是如何组装的getPaginationVariables真正发起下一次请求前Relay 需要把新游标 新数量组装成分页查询的变量。核心实现在 packages/relay-runtime/util/getPaginationVariables.js前向分页时生成{[forwardMetadata.cursor]: cursor, [forwardMetadata.count]: count}并合并基础变量baseVariables与可选的UNSTABLE_extraVariables同时会把后向分页对应的 cursor/count 变量置为null反之亦然确保一次分页只朝一个方向若调用方试图在extraVariables里覆盖 cursor 或 count 变量会触发 warning——这两个变量由 Relay 全权决定。这个函数由useLoadMoreFunctionpackages/react-relay/relay-hooks/useLoadMoreFunction.js在用户点击加载更多时调用它取出当前 fragment 的 owner 变量与 fragment 变量合成baseVariables调用getPaginationVariables组装变量再通过createOperationDescriptor(..., {force: true})创建操作描述符并用fetchQuery发起请求期间通过startFetch/completeFetch维护isLoadingNext状态。6.3 新边如何并入已有连接ConnectionHandler请求返回后连接数据进入 Relay store 的更新环节。usePaginationFragment之所以能自动追加是因为 Relay 为连接字段注册了默认的运行时 handlerpackages/relay-runtime/handlers/connection/ConnectionHandler.js。其核心update函数为每个连接生成一个客户端连接记录client connection recordID 由generateClientID(record.getDataID(), payload.handleKey)生成——这正是connection的key在运行时的用途之一首次获取数据时从服务端连接复制字段把服务端edges逐条转换为客户端边buildConnectionEdge并创建客户端PageInfo记录后续分页请求返回时将新边与已有边合并追加到客户端连接记录的edges中并同步更新PageInfo的hasNextPage/endCursor等字段从而让组件拿到已加载的全部数据。结合 getConnectionState 之类的工具运行时用它来计算hasNext/hasPrevious状态可以清晰地看到整条链路fragment 声明 → 编译器生成元信息 → hook 组装变量并发起请求 → handler 把新边并入 store → 组件重渲染。6.4 测试与验证仓库中配套了大量测试来固化这些行为例如packages/relay-runtime/util/tests/getPaginationVariables-test.js验证前向/后向分页变量的组装逻辑packages/react-relay/relay-hooks/tests/usePaginationFragment-test.js验证 hook 的加载、追加与状态行为。需要更深入源码细节的读者可以从这些测试文件入手快速理解契约。七、总结与延伸阅读Connections 是 Relay 处理分页列表的基础设施服务端 Schema 遵循Connection / Edge / PageInfo的保留类型约定客户端通过cursor count参数做增量游标分页而 Relay 编译器与运行时connection指令、getPaginationVariables、ConnectionHandler替你完成了元信息提取、变量组装与数据合并的绝大部分工作——你在组件里要做的只是用usePaginationFragment渲染数据并调用loadNext加载下一页。基于本主题可以继续深入仓库中的相关章节Connections 官方规范完整的形式化约定含 introspective 查询示例Pagination with Connectionsv17 引导章节Rendering Connectionsv17 引导章节Advanced Pagination Use Cases追加之外的复杂分页行为Streaming Pagination流式分页场景Updating Connections通过 mutation 增删改连接内容时如何利用connection的 key当前版本对应的引导章节Why Connections?、Pagination赞分享前端开发工具【免费下载链接】relayRelay is a JavaScript framework for building>项目地址https://gitcode.com/gh_mirrors/relay29/relay点击查看免费下载相关推荐Relay 中的 Connections 与游标分页从 GraphQL 连接模型到 usePaginationFragment 实战指南Relay 中的 Connections 与游标分页从 GraphQL 连接模型到 usePaginationFragment 实战指南 Relay 将连接前端开发工具Relay 分页实战Connections 连接模型、usePaginationFragment 与无限滚动实现指南Relay 分页实战Connections 连接模型、usePaginationFragment 与无限滚动实现指南 Relay当前仓库 relay 在官前端开发工具Relay Connections 指南基于游标的分页、连接更新与连接身份管理Relay Connections 指南基于游标的分页、连接更新与连接身份管理 导读 本文以 Relay v14 官方文档《Connections》为骨架完前端开发工具创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考