Relay Typesafe Updaters 全面指南:用 `readUpdatableQuery` 与 `readUpdatableFragment` 安全地命令式修改 Store 数据
2026/9/24 16:35:39 网站建设 项目流程

Relay 的 Typesafe Updaters(类型安全更新器)是一套在 store 上命令式(imperatively)修改本地数据的类型安全且更符合人体工程学的 API 体系,核心由readUpdatableQueryreadUpdatableFragment两个入口构成。本文以官方 FAQ 为骨架,结合仓库源码与实战示例,讲清它解决了什么问题、底层如何工作、有哪些使用约束,以及在哪里拿到store来调用这两类 API,帮助你安全地管理客户端本地状态(例如 client schema extension 中的字段)。

Typesafe Updaters 是什么

项目背景与命名由来

Typesafe updaters(类型安全更新器)是一个项目的名字,目标是提供一套类型安全(typesafe)且符合人体工程学(ergonomic)的替代 API,用于在 Relay store 上命令式地更新数据。readUpdatableFragmentreadUpdatableQuery就是 store 暴露出的两个核心 typesafe updater 入口,它们的完整签名定义在 store API 参考文档 中:

readUpdatableFragment<TFragmentType: FragmentType, TData>( fragment: UpdatableFragment<TFragmentType, TData>, fragmentReference: HasUpdatableSpread<TFragmentType>, ): UpdatableData<TData>; readUpdatableQuery<TVariables: Variables, TData>( query: UpdatableQuery<TVariables, TData>, variables: TVariables, ): UpdatableData<TData>;

为什么需要它(Why)

Relay 在“获取和管理来自服务端的数据”这一侧提供了类型安全且易用的 API;同时,Relay 也支持在client schema extensions中定义仅存在于客户端的字段。然而,过去用于修改这些字段数据的 API 冗长且不友好,以至于官方无法将 Relay 推荐为管理本地状态的方案。Typesafe updaters 正是为了补上这块短板。

旧 API 的问题在哪里

旧有的命令式更新 API 存在两个明显的缺陷:

  1. 冗长(verbose):需要开发者写出大量样板代码。
  2. 非类型安全(not typesafe):极易犯下各类低级错误,例如字段名拼错、类型不匹配等。

更关键的是,旧 API 要求开发者只有在编写 updater 时才需要去学习一套全新的 API 集合(例如setValuesetLinkedRecordgetLinkedRecordRecordProxy方法),学习成本高且与日常开发模式割裂。

Typesafe updaters 的优势在于:

  • 复用 Relay 早已为人熟知的习惯用法:query、fragment、类型收窄(type refinement)
  • getter 与 setter(属性读写)取代需要单独记忆的方法集——updatableData.name = "Godzilla"这种写法对任何 JavaScript 开发者都直观易懂;
  • 赋值操作在底层仍然会被转译为对旧 API 的调用,但被类型系统严格约束,错误会在编译期暴露。

开发者如何使用 Typesafe Updaters

使用流程可以概括为三步:

  1. 声明:编写一个 updatable query 或 fragment,显式指定要命令式更新的数据;
  2. 读取:从 store 中读出这些数据,得到一个所谓的updatable proxy(可更新代理对象);
  3. 修改:通过 setter 修改这个 updatable proxy,例如updatableData.name = "Godzilla"

第 3 步的赋值动作最终会转译为对旧 API 的调用(详见下文源码分析),但整个过程有了类型安全保证。

什么是 updatable query 或 fragment

所谓 updatable query 或 fragment,就是带有@updatable指令的 query 或 fragment。例如:

# updatable fragment fragment StoryLikeButton_updatable on Story @updatable { likeCount doesViewerLike } # updatable query query NameUpdaterUpdateQuery @updatable { viewer { name } }

@updatable指令会在编译期被 Relay 编译器识别并特殊处理(详见下文“编译器如何对待 updatable 操作”一节)。

关键认知:updatable queries / fragments 不会被真正抓取

这是 Typesafe Updaters 最核心、也最容易误解的一点。

updatable query / fragment 中选择的字段会从服务端抓取吗?

不会!服务端根本不知道 updatable queries 和 fragments 的存在,它们的字段永远不会被发送网络请求抓取。

即使在普通 query / fragment 中 spread 了一个 updatable fragment,该 updatable fragment 所选中的字段也不会作为那次请求的一部分被抓取。updatable 操作本质上只是“对 store 中已有数据的读写描述”,而非网络请求描述。

如果我想同时抓取并修改某个字段怎么办?

你需要在普通 query/fragmentupdatable query/fragment中分别选择该字段:

# 1) 在普通 fragment 中抓取字段用于渲染 fragment StoryLikeButton on Story { id likeCount doesViewerLike ...StoryLikeButton_updatable # 2) 同时 spread updatable fragment } # 3) updatable fragment 只描述“要修改哪些字段” fragment StoryLikeButton_updatable on Story @updatable { likeCount doesViewerLike }

两个 fragment 各自负责各自的职责:普通 fragment 负责抓取与渲染,updatable fragment 负责允许命令式修改。

由此带来的一系列后果

FAQ 明确列出了这一设计带来的一系列约束,理解它们能避免踩坑:

  • 读取 updatable 数据时可能缺失:当从 store 中读出 updatable 数据时,如果该数据当前不在 store 中,结果可能是缺失的(需要做空值检查);
  • 不能在 updatable query/fragment 中 spread 普通 fragment:普通 fragment 依赖服务端抓取的数据,与 updatable 的语义冲突;
  • 生成的 artifact 不包含 query ID,也不包含 normalization AST:normalization AST 原本用于把网络数据写入 store,而 updatable 操作根本不参与网络抓取,自然不需要它;
  • @defer等指令在此上下文中没有意义,会被禁止:这些指令都服务于网络数据的渐进式交付,与纯本地读写场景无关。

编译器如何对待 updatable 操作(源码佐证)

上述行为可以从编译器源码中得到印证。在 apply_transforms.rs 中,编译管线会对 updatable 操作执行专门的变换。

其中 skip_updatable_queries.rs 里的SkipUpdatableQueriesTransform会遍历整个 program,凡是带@updatable指令的操作定义都会执行Transformed::Delete,也就是直接把 updatable query 从生成网络中剔除

fn transform_operation(&mut self, operation: &OperationDefinition) -> Transformed<OperationDefinition> { if operation .directives .iter() .any(|directive| directive.name.item == *UPDATABLE_DIRECTIVE) { Transformed::Delete } else { Transformed::Keep } }

与此同时,annotate_updatable_fragment_spreads.rs 等变换会把 updatable fragment spread 标注为内部指令__updatable。这两点共同说明了 FAQ 所述事实的底层机制:updatable 操作不参与网络抓取管线,自然也不会产生 query ID 与 normalization AST。

updatable proxy 的底层实现

运行时入口

readUpdatableQuery的运行时实现在 packages/relay-runtime/mutations/readUpdatableQuery.js 中。它从 store 的根记录(proxy.getRoot())出发,结合 variables 与updatableQuery.fragment.selections调用createUpdatableProxy构建代理对象:

function readUpdatableQuery(query, variables, proxy, missingFieldHandlers) { const updatableQuery = getUpdatableQuery(query); return { updatableData: createUpdatableProxy( proxy.getRoot(), variables, updatableQuery.fragment.selections, proxy, missingFieldHandlers, ), }; }

readUpdatableFragment的实现位于 packages/relay-runtime/mutations/readUpdatableFragment.js。它首先通过fragmentReference[ID_KEY]拿到目标记录 id,再从 store 中取出对应记录作为代理根,同时通过getVariablesFromFragment解析 fragment 变量:

const fragmentRoot = proxy.get(id); invariant(fragmentRoot != null, `No record with ${id} was found. ...`);

注意源码中的注释:复数形式的 fragment references 目前不被支持("plural fragment references are currently not supported")。

getter/setter 如何生成

真正生成 updatable proxy 的核心逻辑在 packages/relay-runtime/mutations/createUpdatableProxy.js 中,它逐条遍历 selections,用Object.defineProperty为每个字段挂上 getter 与 setter:

  • 标量字段(ScalarField):getter 调用updatableProxyRootRecord.getValue(...)读取;setter 调用setValue__UNSAFE(...)写回。值得注意的是源码中有一个nonUpdatableKeys = ['id', '__id', '__typename', 'js']数组——这些键的 setter 被显式置为undefined,也就是说像id__typename这类元数据字段是不可赋值的;
  • 关联字段(LinkedField,单数与复数):getter 使用getLinkedRecord/getLinkedRecords并递归为关联记录构建子代理;setter 则通过setLinkedRecord/setLinkedRecords建立关联,要求传入的对象必须携带__id字段;
  • 内联 fragment(InlineFragment):仅当记录的getType()selection.type匹配时才递归展开——这正是 FAQ 提到的type refinement(类型收窄)在底层的体现;
  • ClientExtension:直接递归展开,天然支持 client schema extension 中的字段;
  • FragmentSpread:被显式忽略;
  • 其余变体(DeferStreamRelayResolver等)会抛出错误,因为它们在 updatable 上下文中没有意义。

赋值语义上还有一些值得注意的细节:

  • 复数关联字段null会抛错,提示“应该赋空数组而不是 null”;
  • 复数关联字段的数组中不允许出现 null 或 undefined 元素
  • 关联字段 setter 要求目标记录已存在于 store 中,否则抛错(Did not find item with data id ... in the store.)。

当字段缺失时,getter 还会尝试调用missingFieldHandlers(如getLinkedRecordUsingMissingFieldHandlersgetScalarUsingMissingFieldHandlers)来兜底解析缺失数据。在__DEV__环境下,生成的代理对象会被Object.freeze冻结,帮助尽早发现误用。

在哪里拿到store并调用这些 API

FAQ 的 Misc 部分回答了“store从哪里来”这一高频问题。包含readUpdatableQueryreadUpdatableFragment方法的类包括:RelayRecordSourceSelectorProxyRecordSourceProxyRelayRecordSourceProxy。你可以通过以下途径获取其实例:

  1. mutation / subscription 的 updater 函数中
  2. mutation 的 optimistic updater 中
  3. 使用RelayModernEnvironmentcommitUpdateapplyUpdate等方法时;
  4. 使用独立的commitLocalUpdate方法时。

commitLocalUpdate的实现很轻量,见 packages/relay-runtime/mutations/commitLocalUpdate.js,它只是把环境与 updater 转发给environment.commitUpdate(updater)

function commitLocalUpdate(environment, updater) { environment.commitUpdate(updater); }

实战示例一:在 mutation updater 中初始化客户端字段

下面这个完整示例来自 imperatively-modifying-store-data 指南。场景:通过 client schema extension 给Feedback类型新增一个is_new_comment字段,并在创建 Feedback 的 mutation 完成后将其设为true

先定义 schema extension:

# Feedback.graphql extend type Feedback { is_new_comment: Boolean }

再在 mutation 的updater中通过readUpdatableFragment完成更新:

// CreateFeedback.js function commitCreateFeedbackMutation(environment, input) { return commitMutation(environment, { mutation: graphql` mutation CreateFeedbackMutation($input: FeedbackCreateData!) { feedback_create(input: $input) { feedback { id # Step 1: 在 mutation 响应中 spread updatable fragment ...CreateFeedback_updatable_feedback } } } `, variables: {input}, // Step 2: 定义 updater updater: (store, response) => { // Step 3: 取回并空值检查 feedback 对象 const feedbackRef = response?.feedback_create?.feedback; if (feedbackRef == null) { return; } // Step 4: 调用 readUpdatableFragment 得到 updatable proxy const {updatableData} = store.readUpdatableFragment( graphql` fragment CreateFeedback_updatable_feedback on Feedback @updatable { is_new_comment } `, feedbackRef, ); // Step 5: 直接给属性赋值! updatableData.is_new_comment = true; }, }); }

这个例子完整展示了三步走的流程:先在 mutation 响应中 spread updatable fragment(目的是拿到 fragment reference 并确保记录已写入 store),再调用readUpdatableFragment读取代理,最后通过 setter 赋值。updater 执行完后,所有记录下来的更新会被写入 store,所有受影响的组件都会重新渲染

实战示例二:在用户交互中切换本地状态

再看一个更贴近日常的场景——点击按钮切换is_selected字段(同样定义在 client schema extension 中):

# User.graphql extend type User { is_selected: Boolean }
// UserSelectToggle.react.js function UserSelectToggle({userId, viewerRef}) { const viewer = useFragment( graphql` fragment UserSelectToggle_viewer on Viewer { user(user_id: $user_id) { id name is_selected ...UserSelectToggle_updatable_user } } `, viewerRef, ); const environment = useRelayEnvironment(); return ( <button onClick={() => { commitLocalUpdate(environment, (store) => { const userRef = viewer.user; if (userRef == null) { return; } const {updatableData} = store.readUpdatableFragment( graphql` fragment UserSelectToggle_updatable_user on User @updatable { is_selected } `, userRef, ); updatableData.is_selected = !viewer?.user?.is_selected; }); }} > {viewer?.user?.is_selected ? 'Deselect' : 'Select'} {viewer?.user?.name} </button> ); }

与上一个例子的区别在于:commitLocalUpdate的 updater不接受第二个参数(没有关联的网络 payload)。指南中还提到,这个例子可以用environment.commitPayloadAPI 改写,但那样会失去类型安全

何时该用readUpdatableQuery而非readUpdatableFragment

readUpdatableQueryreadUpdatableFragment的核心区别是:前者不需要传 fragment reference,只需要你从根(Query类型)到目标记录之间有一条已知的路径。官方指南明确列出推荐使用readUpdatableQuery的几种场景:

  • 手头没有现成的 fragment reference,例如commitLocalUpdate的调用与某个组件并无直接关联;
  • 拿不到选择“父记录”的 fragment——由于 Relay 存在一个已知的类型空洞(known type hole),updatable fragments 不能 spread 在顶层
  • 希望在 updatable fragment 中使用变量:目前 updatable fragments 会复用传入 query 的变量,这意味着你无法让 updatable fragment 拥有 fragment-local 变量,也无法多次调用readUpdatableFragment并每次传入不同变量。

一个使用readUpdatableQuery的完整例子(改写自 imperatively-modifying-store-data 指南):

// NameUpdater.react.js const onSubmit = () => { commitLocalUpdate(environment, (store) => { const {updatableData} = store.readUpdatableQuery( graphql` query NameUpdaterUpdateQuery @updatable { viewer { name } } `, {}, ); const viewer = updatableData.viewer; if (viewer != null) { viewer.name = newName; } }); };

注意这里通过readUpdatableQuery直接以{}作为 variables 调用,无需任何 fragment reference,updatableData.viewer仍是一个可为空的代理对象,需要空值检查后再赋值。

使用建议与边界总结

综合 FAQ、指南与源码,可以把 Typesafe Updaters 的正确使用姿势归纳为以下几点:

  • 用途:命令式修改 store 中的本地数据,尤其适合 client schema extensions 字段的初始化与更新、复杂客户端更新,以及invalidateStore、删除节点、查找连接等只有 updater 才能做到的操作;
  • 不要用它触发副作用:需要触发副作用时请使用onCompleted回调——它保证只调用一次,而 updater / optimistic updater 可能被重复调用
  • 读写分离:要展示的数据用普通 query/fragment 抓取,要修改的数据在 updatable query/fragment 中声明,两者各自选择所需字段;
  • 注意空值:由于 updatable 数据依赖 store 中已有的记录,读取结果可能缺失,务必做空值检查(或用@required指令);
  • 理解执行时机:optimistic updater 在 mutation 触发时执行、完成或失败后回滚;普通 updater 在 mutation 成功完成后执行。若两个 optimistic response 都修改同一值,第一个回滚时第二个不会被重新计算,该值会保持“叠加后”的结果。

如果你还想了解更底层的RecordProxyRecordSourceProxy方法(如setValuesetLinkedRecord等),可以进一步阅读 store API 参考 与旧式命令式更新指南 imperatively-modifying-store-data-legacy.md,对比新旧两套 API 的差异后,你会更深刻地体会 Typesafe Updaters 在类型安全与开发体验上的改进。

  • 前端
  • 开发工具

【免费下载链接】relay

Relay is a JavaScript framework for building>项目地址:https://gitcode.com/gh_mirrors/relay29/relay

点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询