Relay 连接(Connection)更新指南:使用 `ConnectionHandler` 与声明式指令向列表增删数据
Relay 连接Connection更新指南使用ConnectionHandler与声明式指令向列表增删数据【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay本文基于 Relay 仓库website/versioned_docs/version-v19.0.0/guided-tour/updating-data/updating-connections.md编写并结合packages/relay-runtime/handlers/connection/与compiler/crates/relay-transforms/src/中的源码实现进行深度补充。导读在基于 Relay 构建的 React 应用中当你渲染一个分页列表Connection时往往需要响应用户操作来向列表中新增或移除条目——例如发布一条评论、删除一条评论。Relay 在客户端维护一个按记录 ID 归一化的内存 Store本指南将讲解三种在 Store 中定位连接记录的方式、两种添加边appendEdge/prependEdge/appendNode/prependNode声明式指令与手写 updater以及两种删除边deleteEdge指令与ConnectionHandler.deleteNode的完整方案并深入讲解带过滤器filters的连接如何影响连接身份connection identity。读完本文你将能熟练地在 mutation、subscription 或本地数据更新中精确地增删连接条目并理解其底层原理。为什么需要 updaterRelay 的归一化存储模型Relay 在内存中维护一份归一化normalized的 GraphQL 数据 Store记录record按它们的 ID 存储。当你通过 Relay 发起 mutation、subscription 或本地数据更新时必须提供一个updater函数其参数类型为RecordSourceSelectorProxy。在 updater 内部你可以读取 Store 中的记录写入、修改记录当记录被更新时所有受影响的组件都会被通知并重新渲染。关键在于连接字段connection field是特殊的记录。凡是用connection指令标记的连接字段在 Store 中都以专门的记录形式存储并且会累积保存到目前为止为该连接抓取到的所有条目。要在连接上增删条目就需要先拿到这条连接记录。而访问连接记录最核心的途径就是在声明connection时提供的连接key配合relay-runtime导出的ConnectionHandlerAPI 来完成。以下面这段 fragment 为例它声明了一个名为StoryComponent_story_comments_connection的连接const {graphql} require(react-relay); const storyFragment graphql fragment StoryComponent_story on Story { comments connection(key: StoryComponent_story_comments_connection) { nodes { body { text } } } } ;下面三种方式都可以在 updater 中拿到这条连接记录。在 Store 中访问连接记录的三种方式方式一查询__id字段你可以在 fragment 中显式查询连接的__id字段然后用它去 Store 中取记录const fragmentData useFragment( graphql fragment StoryComponent_story on Story { comments connection(key: StoryComponent_story_comments_connection) { # Query for the __id field __id # ... } } , props.story, ); // Get the connection record id const connectionID fragmentData?.comments?.__id;然后在 updater 中使用这个 ID 访问记录function updater(store: RecordSourceSelectorProxy) { // connectionID is passed as input to the mutation/subscription const connection store.get(connectionID); // ... }注意__id字段不是你的 GraphQL API 需要暴露的字段。它是 Relay 自动添加、用于标识连接记录的客户端标识符。从源码看__id在查询编译阶段被插入到连接选择集中见 connections/connection_util.rs 中的build_connection_selections逻辑供运行时读取。方式二ConnectionHandler.getConnectionID如果你能拿到持有该连接的父记录 ID就可以用ConnectionHandler.getConnectionID直接计算出连接 IDconst {ConnectionHandler} require(relay-runtime); function updater(store: RecordSourceSelectorProxy) { // Get the connection ID const connectionID ConnectionHandler.getConnectionID( storyID, // passed as input to the mutation/subscription StoryComponent_story_comments_connection, ); // Get the connection record const connectionRecord store.get(connectionID); // ... }从实现看getConnectionID的内部逻辑是先把连接 key 通过getRelayHandleKey(connection, key, null)转换为 handle key再用getStableStorageKey(handleKey, filters)计算稳定存储键最后调用generateClientID(recordID, storageKey)生成客户端 ID见 ConnectionHandler.js。这意味着连接记录 ID 是由父记录 ID key 过滤器确定性推导出来的不依赖服务器返回值。方式三ConnectionHandler.getConnection如果你能直接拿到持有该连接的父记录就可以经由父记录获取连接记录const {ConnectionHandler} require(relay-runtime); function updater(store: RecordSourceSelectorProxy) { // Get parent story record // storyID is passed as input to the mutation/subscription const storyRecord store.get(storyID); // Get the connection record from the parent const connectionRecord ConnectionHandler.getConnection( storyRecord, StoryComponent_story_comments_connection, ); // ... }getConnection同样先用getRelayHandleKey(connection, key, null)得到 handle key然后调用record.getLinkedRecord(handleKey, filters)见 ConnectionHandler.js。当连接带过滤器时第三个参数filters就派上用场了详见下文连接身份与过滤器一节。三种方式的完整类型签名可参考 ConnectionHandler.d.tsexport function getConnection(record: ReadOnlyRecordProxy, key: string, filters?: Variables | null): RecordProxy | null | undefined; export function getConnectionID(recordID: DataID, key: string, filters?: Variables | null): DataID;向连接中添加边Edge添加边有两种方案使用声明式指令appendEdge/prependEdge/appendNode/prependNode或手写 updater。使用声明式指令Declarative Directives通常mutation 或 subscription 的 payload 会把服务器端新增的边暴露为单个边或边列表字段如果可以在响应中查询到这个边字段就可以在该字段上使用appendEdge或prependEdge指令把新创建的边添加到指定的连接中。同理如果 payload 暴露的是单个节点或节点列表字段则可以使用appendNode或prependNode指令把新节点包装进边edge后添加到指定的连接中。这两组指令同样适用于 query查询并不局限于 mutation/subscription。所有指令都接受connections参数它必须是一个包含连接 ID 数组的 GraphQL 变量。连接 ID 可以通过上文两种方式获得查询连接的__id字段或使用ConnectionHandler.getConnectionID。appendEdge/prependEdge这两个指令作用于单个边或边列表字段prependEdge把选中的边添加到connections数组中每个连接的开头appendEdge把选中的边添加到connections数组中每个连接的末尾。参数参数类型说明connections[ID!]!GraphQL 变量连接 ID 数组可通过__id字段或ConnectionHandler.getConnectionID获取完整示例// Get the connection ID using the __id field const connectionID fragmentData?.comments?.__id; // Or get it using ConnectionHandler.getConnectionID() const connectionID ConnectionHandler.getConnectionID( story-id, StoryComponent_story_comments_connection, ); // ... // Mutation commitMutationAppendCommentMutation(environment, { mutation: graphql mutation AppendCommentMutation( # Define a GraphQL variable for the connections array $connections: [ID!]! $input: CommentCreateInput ) { commentCreate(input: $input) { # Use appendEdge or prependEdge on the edge field feedbackCommentEdge appendEdge(connections: $connections) { cursor node { id } } } } , variables: { input, // Pass the connections array connections: [connectionID], }, });appendNode/prependNode这两个指令作用于单个节点或节点列表字段并通过edgeTypeName指定要创建的边的 GraphQL 类型prependNode把包含所选节点的边添加到每个连接的开头appendNode把包含所选节点的边添加到每个连接的末尾。参数参数类型说明connections[ID!]!GraphQL 变量连接 ID 数组获取方式同上edgeTypeNameString字符串字面量容纳节点的边类型名对应ConnectionHandler.createEdge中的边类型参数完整示例// Get the connection ID using the __id field const connectionID fragmentData?.comments?.__id; // Or get it using ConnectionHandler.getConnectionID() const connectionID ConnectionHandler.getConnectionID( story-id, StoryComponent_story_comments_connection, ); // ... // Mutation commitMutationAppendCommentMutation(environment, { mutation: graphql mutation AppendCommentMutation( # Define a GraphQL variable for the connections array $connections: [ID!]! $input: CommentCreateInput ) { commentCreate(input: $input) { # Use appendNode or prependNode on the node field feedbackCommentNode appendNode(connections: $connections, edgeTypeName: CommentsEdge) { id } } } , variables: { input, // Pass the connections array connections: [connectionID], }, });底层原理编译期这些声明式指令由编译器中的DeclarativeConnectionMutationTransform处理见 declarative_connection.rs。该 transform 会把指令转换为 handle field 指令同时执行严格的合法性校验appendEdge/prependEdge要求字段类型是包含cursor和node字段的边类型否则报EdgeDirectiveOnUnsupportedTypeappendNode/prependNode要求字段类型是 object、interface 或 union否则报NodeDirectiveOnUnsupportedType且edgeTypeName必须是 schema 中存在的 object 类型名否则报InvalidEdgeTypeName并给出did_you_mean拼写建议在标量字段上使用这些指令会报ConnectionMutationDirectiveOnScalarField在同一字段上同时使用边指令与节点指令会报ConflictingEdgeAndNodeDirectives缺少connections参数会报ConnectionsArgumentRequired。底层原理运行时转换后的 handle 指令由 MutationHandlers.js 中的处理器执行AppendEdgeHandler/PrependEdgeHandler从响应中读取服务端边调用ConnectionHandler.buildConnectionEdge为每个目标连接生成唯一的客户端边副本再分别调用insertEdgeAfter/insertEdgeBeforeAppendNodeHandler/PrependNodeHandler读取服务端节点调用ConnectionHandler.createEdge构造边后再插入两者都会做节点去重如果某连接中已存在指向同一nodeID 的边则跳过插入避免重复条目。指令的执行顺序Order of Execution依据updater函数的执行顺序这四条指令在 mutation/subscription 生命周期中的执行时机如下mutation 发起时在乐观响应optimistic response被处理、乐观 updater 执行之后prependEdge、appendEdge、prependNode、appendNode会应用到乐观响应上mutation 成功时在网络响应数据与 Store 中的既有值合并、updater 函数执行之后这些指令会应用到网络响应的数据上mutation 失败时由这些指令产生的更新会被回滚。手动添加边手写 updater声明式指令大大减少了手动增删条目的需要但它们提供的控制粒度不如手写 updater 精细可能无法满足所有场景。手写 updater 的前提是能拿到连接记录有一条新边记录通常来自 mutation/subscription 的 payload如果没有也可以从零构造。例如下面这个 mutation 在响应中查询了新创建的边const {graphql} require(react-relay); const createCommentMutation graphql mutation CreateCommentMutation($input: CommentCreateData!) { comment_create(input: $input) { comment_edge { cursor node { body { text } } } } } ;注意这里同时查询了新边的cursor。严格来说并非必需但如果你后续要基于该cursor做分页它就是必备信息。在 updater 中通过 Relay Store API 从 mutation 响应里取出这条边const {ConnectionHandler} require(relay-runtime); function updater(store: RecordSourceSelectorProxy) { const storyRecord store.get(storyID); const connectionRecord ConnectionHandler.getConnection( storyRecord, StoryComponent_story_comments_connection, ); // Get the payload returned from the server const payload store.getRootField(comment_create); // Get the edge inside the payload const serverEdge payload.getLinkedRecord(comment_edge); // Build edge for adding to the connection const newEdge ConnectionHandler.buildConnectionEdge( store, connectionRecord, serverEdge, ); // ... }这里有两点值得注意mutation 的 payload 以 Store 根字段root field的形式存在可用store.getRootField读取本例读取的是响应中的根字段comment_create服务端返回的边必须先用ConnectionHandler.buildConnectionEdge构造成客户端边才能加入连接。从源码看ConnectionHandler.jsbuildConnectionEdge会基于连接实例上递增的边索引__connection_next_edge_index生成唯一客户端边 ID 并复制字段从而避免多次抓取同一连接时边 ID 冲突。如果你需要从零构造一条新边例如本地创建的评论可以用ConnectionHandler.createEdgeconst {ConnectionHandler} require(relay-runtime); function updater(store: RecordSourceSelectorProxy) { const storyRecord store.get(storyID); const connectionRecord ConnectionHandler.getConnection( storyRecord, StoryComponent_story_comments_connection, ); // Create a new local Comment record const id client:new_comment:${randomID()}; const newCommentRecord store.create(id, Comment); // Create new edge const newEdge ConnectionHandler.createEdge( store, connectionRecord, newCommentRecord, CommentEdge, /* GraphQl Type for edge */ ); // ... }createEdge的实现会基于连接 ID 节点 ID生成确定性客户端边 ID并给cursor字段兜底写入null而非undefined避免被当作缺失数据见 ConnectionHandler.js。拿到新边记录后用insertEdgeAfter或insertEdgeBefore把它加入连接const {ConnectionHandler} require(relay-runtime); function updater(store: RecordSourceSelectorProxy) { const storyRecord store.get(storyID); const connectionRecord ConnectionHandler.getConnection( storyRecord, StoryComponent_story_comments_connection, ); const newEdge (...); // Add edge to the end of the connection ConnectionHandler.insertEdgeAfter( connectionRecord, newEdge, ); // Add edge to the beginning of the connection ConnectionHandler.insertEdgeBefore( connectionRecord, newEdge, ); }注意这两个 API 会就地修改mutate连接记录。insertEdgeAfter/insertEdgeBefore还支持可选的第三个参数cursor——若传入 cursor新边会被插入到指定 cursor 之后/之前若未找到该 cursor 则退化为追加到末尾/开头见 ConnectionHandler.js。完整的 Store 相关 API 可查阅 Relay Store API 参考。从连接中删除边Edge使用声明式删除指令deleteEdge与添加指令类似如果 mutation 或 subscription 的 payload 暴露了被删除节点的 ID 或 ID 列表字段就可以在该字段上使用deleteEdge指令删除对应连接中的边。该指令同样适用于 query。deleteEdge作用于返回ID或[ID]的 GraphQL 字段会从connections数组中每个连接里删除节点 ID 匹配的边。参数参数类型说明connections[ID!]!GraphQL 变量连接 ID 数组获取方式同上完整示例// Get the connection ID using the __id field const connectionID fragmentData?.comments?.__id; // Or get it using ConnectionHandler.getConnectionID() const connectionID ConnectionHandler.getConnectionID( story-id, StoryComponent_story_comments_connection, ); // ... // Mutation commitMutationDeleteCommentsMutation(environment, { mutation: graphql mutation DeleteCommentsMutation( # Define a GraphQL variable for the connections array $connections: [ID!]! $input: CommentsDeleteInput ) { commentsDelete(input: $input) { deletedCommentIds deleteEdge(connections: $connections) } } , variables: { input, // Pass the connections array connections: [connectionID], }, });底层原理编译期deleteEdge在 declarative_connection.rs 中被转换为 handle 字段指令且要求字段类型必须是ID标量——用在 LinkedField 上会报DeleteRecordDirectiveOnLinkedField用在非ID类型上会报DeleteRecordDirectiveOnUnsupportedType。运行时由 MutationHandlers.js 中的DeleteEdgeHandler执行它读取connections参数对每个连接调用ConnectionHandler.deleteNode(connection, id)删除所有匹配节点 ID 的边若某连接 ID 在 Store 中不存在会输出告警并跳过。手动删除边ConnectionHandler.deleteNodeConnectionHandler提供了对称的删除 APIdeleteNodeconst {ConnectionHandler} require(RelayModern); function updater(store: RecordSourceSelectorProxy) { const storyRecord store.get(storyID); const connectionRecord ConnectionHandler.getConnection( storyRecord, StoryComponent_story_comments_connection, ); // Remove edge from the connection, given the ID of the node ConnectionHandler.deleteNode( connectionRecord, commentIDToDelete, ); }deleteNode接收的是节点 ID它会遍历连接中的边找出nodeID 匹配的边并移除该 API 同样会就地修改连接记录。从实现看ConnectionHandler.jsdeleteNode遍历连接记录的edges收集所有节点 ID 不等于目标 ID 的边然后用新数组覆盖edges字段。请记住无论采用上述哪种方式修改连接任何渲染受影响连接的 fragment 或 query 组件都会被通知并以连接的最新版本重新渲染。带过滤器的连接身份Connection Identity前面的例子中连接都没有携带过滤器参数。如果你的连接声明了带参数的过滤器那么这些过滤器的取值会成为连接标识符的一部分——换句话说每次传入的过滤器值不同都会在 Relay Store 中形成不同的连接记录。注意这里排除分页参数即first、last、before、after不参与连接身份标识。例如假设comments字段接收以下参数且这些参数以 GraphQL 变量 形式传入const {graphql} require(RelayModern); const storyFragment graphql fragment StoryComponent_story on Story { comments( order_by: $orderBy, filter_mode: $filterMode, language: $language, ) connection(key: StoryComponent_story_comments_connection) { edges { nodes { body { text } } } } } ;这意味着查询时$orderBy、$filterMode、$language的取值都会参与连接标识访问 Store 中的连接记录时也必须带上这些值。做法是给ConnectionHandler.getConnection传入第三个参数——具体的过滤器值对象const {ConnectionHandler} require(RelayModern); function updater(store: RecordSourceSelectorProxy) { const storyRecord store.get(storyID); // Get the connection instance for the connection with comments sorted // by the date they were added const connectionRecordSortedByDate ConnectionHandler.getConnection( storyRecord, StoryComponent_story_comments_connection, {order_by: *DATE_ADDED*, filter_mode: null, language: null} ); // Get the connection instance for the connection that only contains // comments made by friends const connectionRecordFriendsOnly ConnectionHandler.getConnection( storyRecord, StoryComponent_story_comments_connection, {order_by: null, filter_mode: *FRIENDS_ONLY*, language: null} ); }这意味着默认情况下过滤器的每种取值组合都会生成一条不同的连接记录。更新连接时你必须更新所有受变更影响的记录。例如向示例连接添加一条新评论时如果这条评论并非好友所发就不应该把它加进FRIENDS_ONLY那条连接const {ConnectionHandler} require(relay-runtime); function updater(store: RecordSourceSelectorProxy) { const storyRecord store.get(storyID); // Get the connection instance for the connection with comments sorted // by the date they were added const connectionRecordSortedByDate ConnectionHandler.getConnection( storyRecord, StoryComponent_story_comments_connection, {order_by: *DATE_ADDED*, filter_mode: null, language: null} ); // Get the connection instance for the connection that only contains // comments made by friends const connectionRecordFriendsOnly ConnectionHandler.getConnection( storyRecord, StoryComponent_story_comments_connection, {order_by: null, filter_mode: *FRIENDS_ONLY*, language: null} ); const newComment (...); const newEdge (...); ConnectionHandler.insertEdgeAfter( connectionRecordSortedByDate, newEdge, ); if (isMadeByFriend(storyRecord, newComment) { // Only add new comment to friends-only connection if the comment // was made by a friend ConnectionHandler.insertEdgeAfter( connectionRecordFriendsOnly, newEdge, ); } }用filters精确控制连接身份管理多过滤器连接可以看到只要给连接加上几个过滤器需要管理的连接记录数量与复杂度就可能急剧膨胀。为此Relay 允许你在connection指令中显式指定哪些过滤器参与连接身份标识。默认情况下所有非分页过滤器都会参与连接身份。编译器中的get_default_filters逻辑见 connection_util.rs会取连接字段参数中不属于连接规范first/last/before/after等的所有参数作为默认 filters。而当你显式声明filters后编译器的 handle field transform 会只保留filters数组中列出的参数见 handle_field_transform.rs。在connection中指定精确的过滤器集合const {graphql} require(relay-runtime); const storyFragment graphql fragment StoryComponent_story on Story { comments( order_by: $orderBy filter_mode: $filterMode language: $language ) connection( key: StoryComponent_story_comments_connection filters: [order_by, filter_mode] ) { edges { nodes { body { text } } } } } ;通过指定filters你向 Relay 声明了精确的一组参与连接身份的过滤器值。本例中排除了language意味着只有order_by和filter_mode的取值会影响连接身份、产生新的连接记录。从概念上讲这是在声明哪些参数真正影响服务器返回的连接输出即哪些参数是真正的过滤器。如果某个参数并不会改变服务器返回的条目集合或其顺序那么它就不是真正的过滤器当其值变化时无需用不同的连接身份去标识它。在本例中改变请求的评论language并不会改变连接返回的评论集合因此把它从filters中排除是安全的。如果你知道某个连接参数在应用中永远不会变化把它排除在filters之外同样是安全的。小结与最佳实践场景推荐方案关键 API / 指令响应中直接返回新边appendEdge/prependEdgeconnections参数响应中返回新节点appendNode/prependNodeconnectionsedgeTypeName参数响应中返回被删节点 IDdeleteEdgeconnections参数需要精细控制插入位置/构造本地边手写 updatergetConnection/createEdge/buildConnectionEdge/insertEdgeAfter/insertEdgeBefore/deleteNode带过滤器的连接在connection上显式声明filtersfilters: [...]getConnection(record, key, filters)实践要点优先使用声明式指令它们由编译器 transform 运行时 handler 自动处理边的构造、去重与回滚代码更简洁、更不易出错声明式指令不满足需求如精确控制 cursor 插入位置、本地数据更新时再手写 updater带过滤器的连接务必在connection上显式声明filters并确保 updater 中传入与查询时一致的过滤器值手动更新多个连接实例时注意各过滤组合对应的记录都要正确处理避免把不符合条件的条目加进过滤后的连接。若想深入了解 Store 层的读取与写入 API请继续阅读 Relay Store API 参考关于 updater 的执行时机与乐观更新可参考 GraphQL Mutations 指南。运行时实现可进一步阅读 ConnectionHandler.js 与 MutationHandlers.js编译器端实现见 declarative_connection.rs。【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考