Relay 的 @catch 指令实战指南:把 GraphQL 字段错误内联进响应数据
2026/9/23 9:02:32 网站建设 项目流程

Relay 的 @catch 指令实战指南:把 GraphQL 字段错误内联进响应数据

【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay

@catch是 Relay 提供的显式错误处理指令,它改变了「字段出错时一律返回 null」的默认行为,让异常和意外值以{ ok: true, value } | { ok: false, errors }的结构直接出现在你的查询、Fragment 或 Mutation 的响应数据中。本文以 Relay v18 官方指南为主线,结合本仓库的编译器源码(relay-transforms)与运行时实现(RelayReader),完整讲解@catch的语法、错误冒泡规则、与@required/@throwOnFieldError的协同方式,以及to参数的两种取值,读完即可在实际项目中对字段级错误做显式、细粒度的处理。

@catch 是什么:从「静默 null」到「显式错误」

在 GraphQL 中,当服务端执行某个字段的 resolver 时抛出了异常,规范要求服务端在该字段位置返回null,同时在响应顶层的errors数组中单独记录这条 field error。这意味着默认情况下,Relay 读到的是「数据变空了」,而真正的原因(异常本身)被隐藏起来,开发者在组件里往往无法区分「这真的是 null」还是「出错导致的 null」。

@catch指令正是为解决这个问题而生:把它加到 Relay 的 query / fragment / mutation 中的字段上,即可声明「这个字段的异常和意外值应该如何在运行时被处理」。加上@catch之后,Relay 会在响应数据中把异常直接呈现给你,而不是静默地给一个null

具体来说,当 GraphQL 响应中包含 field errors 时,Relay 会主动查找这些错误;如果错误所在的字段、或其祖先字段上带有@catch指令,Relay 就会把该字段的响应数据替换为下面两种形状之一:

  • 成功:{ ok: true, value: "your value" }
  • 失败:{ ok: false, errors: [...] }

从当前仓库源码看,@catch的作用范围不止于普通字段——compiler/crates/relay-transforms/src/catch_directive/catchable_node.rs中定义了CatchableNodetrait,并为ScalarField(标量字段)、LinkedField(对象字段)、FragmentDefinition(Fragment 定义)、OperationDefinition(操作定义)以及InlineFragment(内联片段)统一实现了该 trait,意味着这些节点上都可以挂载@catch。而 最新版本文档 也明确指出,@catch可以添加到字段、fragment/operation 定义或带别名的内联片段展开(aliased inline fragment spreads)上。

两种捕获模式:字段直接捕获与祖先冒泡

直接捕获:错误停留在出错字段本身

@catch直接标注在出错的字段上时,错误就停留在该字段,例如:

query MyQuery { viewer { name @catch age } }

如果name字段在服务端执行时出错,Relay 会把它捕获到name字段自身的数据上:

{ viewer: { name: { ok: false, errors: [ { message: "Couldn't get name", path: ['viewer', 'name'] } ] } age: 39 } }

注意:age字段不受影响,仍然正常返回 39。这就是「字段粒度(field-granular)的错误处理」——你可以在组件里只针对name的错误做降级展示,而其余数据照常使用。

祖先捕获:错误冒泡到最近的@catch祖先

如果@catch位于某个字段的祖先节点上,错误不会停留在子字段,而是沿选择集向上冒泡到最近的@catch祖先:

query MyQuery { viewer @catch { name age } }

此时name出错,错误会冒泡到viewer

{ viewer: { ok: false, errors: [ { message: "Couldn't get name", path: ['viewer', 'name'] } ] } }

冒泡时,错误对象中的path依然精确记录错误真正的来源(['viewer', 'name']),而ok: false出现在捕获它的祖先(viewer)上。这种模式非常适合「整块数据要么整体可用、要么整体给出原因」的场景,比如卡片、列表项这类以对象为单位的 UI。

三类可以被 @catch 捕获的错误

Payload Field Errors:服务端字段执行异常

Payload errors 是服务端执行某个字段的 resolver 时发生异常而产生的错误。这种情况下,GraphQL 服务端会在本应返回值的位置放一个null,并在独立的errors对象中记录详情。当你在字段上使用@catch时,Relay 会把这些错误从「隐藏在顶层 errors 中」转变为「内联在该字段的数据中」,让它们不再不可见、也更容易被处理。

这里有一个重要的副产品:可空(nullable)字段现在可以区分「真 null」和「异常 null」了。因为捕获后的形状要么是{ ok: true }valuenull(真 null),要么是{ ok: false }并携带真实错误(异常导致的 null),两者的数据形态完全不同,不再混淆。

@required(action: THROW) 位于 @catch 之下:从抛异常改为冒泡

@required用于声明字段不可缺失。当@required(action: THROW)与一个带有@catch的祖先同时出现时,原本「抛出 JavaScript 异常」的行为会被改写——@required的错误不再抛出,而是像普通错误一样向上冒泡,被@catch捕获并提供在数据中:

query MyQuery { viewer @catch { name @required(action: THROW) age } }

此时如果viewer.name缺失,响应数据会是:

{ viewer: { ok: false, errors: [ { message: "Relay: Missing @required value at path 'viewer.name' in 'MyQuery'.", } ] } }

注意错误消息直接来自 Relay 运行时:Relay: Missing @required value at path 'viewer.name' in 'MyQuery'.

需要特别区分的是:@required可以出现在@catch子孙位置(错误冒泡进@catch),但两者不能标注在同一个字段上。这一点在编译器源码中有明确的校验:compiler/crates/relay-transforms/src/catch_directive.rsassert_not_with_required方法会在同一节点同时携带@catch@required时抛出诊断错误,对应catch_directive/validation_message.rs中的CatchDirectiveWithRequiredDirective(消息为 "@catchand@requireddirectives cannot be on the same field")。

Missing Data:响应数据缺失

还有一种意外状态也会被@catch捕获:字段本应返回一个值,但响应中该字段是undefined(例如 schema 中的对象关系发生了图结构变化,客户端对不上号,详见 Relay 文档中「why null」关于 graph relationship change 的说明)。当这种缺失发生且存在@catch祖先时,它同样会被捕获:

{ viewer: { ok: false, errors: [ { message: "Relay: Missing data for one or more fields in MyQuery", } ] } }

此时 Relay 给出的错误消息为Relay: Missing data for one or more fields in MyQuery

@catch 与 @throwOnFieldError 如何协同

@throwOnFieldError是一个让字段在发生 field error 时直接抛出 JavaScript 异常的指令(全局开启后所有字段都会 throw)。而@catch的作用正好相反——它明确告诉 Relay:「这个位置不要抛 JavaScript 异常,请把错误放进数据对象里」,并且遵循上面列出的全部规则(包括冒泡到父字段)。

两个要点值得牢记:

  1. @catch不依赖@throwOnFieldError也能生效。即使没有开启@throwOnFieldError@catch依然会把错误提供到数据对象中。区别在于:此时@catch之外的其他字段在出错时依然不会 throw——因为缺少@throwOnFieldError的全局开启,它们仍走「返回 null」的默认路径。
  2. 作用范围是局部的。无论@catch还是@throwOnFieldError,都只处理它们所在的 query / fragment / mutation 之内的字段错误,不处理任何通过 fragment spread 引入的字段错误——这是当前仓库 官方指南 中特别提示的行为边界。

to 参数:RESULT 与 NULL

@catch接受一个可选的to参数,用于选择错误呈现方式,共有两种取值。

to: RESULT(默认值)

@catch(to: RESULT)启用本文前述的全部行为:为自身及子字段中出错的位置提供内联错误,即{ ok: true, value: T } | { ok: false, errors: [error] }。由于 RESULT 是默认值,@catch@catch(to: RESULT)写法完全等价。这也是源码中的默认兜底逻辑——catch_directive.rscatch_to_with_fallback函数明确指出:@catch不带参数时恒为RESULT

to: NULL

@catch(to: NULL)则恢复@catch出现之前的行为:字段出错时,该字段的值就是null。它保留了「错误仍然会被观察到并被标记为已处理」的内部语义,但对应用层暴露的形态与默认行为一致——字段出错即置空。

编译器源码中,to参数被建模为CatchTo枚举(Null/Result两个变体),并通过From<StringKey> for CatchTo把 GraphQL 枚举字面量"NULL"/"RESULT"映射到对应变体;如果遇到其它取值,会直接 panic 提示「UseNULLorRESULT(default) instead」。因此实际项目中请只使用这两个合法取值。

编译器如何实现 @catch:源码级拆解

@catch的编译期处理位于compiler/crates/relay-transforms/src/catch_directive.rs,是整个转换管线的独立一环。核心逻辑如下:

  • 指令名与参数名CATCH_DIRECTIVE_NAME = "catch"TO_ARGUMENT = "to"、合法枚举值NULL_TO = "NULL"RESULT_TO = "RESULT"
  • 转换器CatchDirective:实现Transformertrait,逐节点遍历程序。对OperationDefinitionFragmentDefinitionScalarFieldLinkedField、带别名的InlineFragment,只要检测到@catch,就会在保留原指令的同时追加一条内部元数据指令CatchMetadataDirective(携带解析后的CatchTo),供后续 codegen 阶段读取。
  • 错误累积:转换过程中的非法用法不会直接中止编译,而是收集进errors列表;只有存在错误时才返回Err(diagnostics),由上层统一报告。
  • 编译期校验规则
    • 同一字段同时使用@catch@required→ 报错(CatchDirectiveWithRequiredDirective)。
    • 未带别名的内联片段上使用@catch→ 报错(CatchNotValidOnUnaliasedInlineFragment,提示需配合... @alias使用)。因为未别名化的内联片段会原样合入父级选择集,无法独立承载错误边界;而带别名(alias)的内联片段则可以成为独立的@catch边界。
  • 嵌套捕获:转换会递归处理子选择集,因此内层@catch会先于外层生效,错误被最近的@catch边界截获,不会无限向外传播。

值得补充的是,编译器还提供了「client schema 扩展中使用@catch」的专项校验(见compiler/crates/relay-transforms/src/validations/validate_client_schema_extensions_use_catch.rs),说明@catch在客户端扩展字段上也受到支持与约束。

运行时如何处理 @catch:RelayReader 与特性开关

编译产物到达运行时后,由packages/relay-runtime/store/RelayReader.js负责实际的错误捕获。核心方法是_catchErrors(约在RelayReader.jsL446 起):

  • 读取字段/Fragment/操作上的metadata.catchTo(由编译期CatchMetadataDirective写入);
  • 在进入标注了@catch的选择集之前先记录现场,遍历完成后收集该范围内发生的 field errors;
  • 将错误标记为「已处理(handled)」,避免它们向上继续触发 reader 抛出或影响外层边界;
  • 把错误与捕获位置合并,最终按CatchTo的取值组装成{ ok, value }{ ok, errors },或直接置null

此外,packages/relay-runtime/util/RelayFeatureFlags.js中还有一个相关特性开关ENABLE_CATCH_IGNORE_HANDLED_FIELD_ERRORS(默认关闭)。从其注释可以读到设计意图:开启后,外层@catch边界会忽略已经被内层@catch处理过的错误;而在未开启时,即使错误已被内层@catch接住,外层@catch(to: NULL)仍可能把字段置空、外层@catch(to: RESULT)仍会报告{ok: false}。这个开关用于收紧嵌套@catch场景下的传播语义。

用编译器的测试用例验证 @catch 行为

仓库为@catch提供了丰富的 fixture 测试,位于compiler/crates/relay-transforms/tests/catch_directive/fixtures/,每对.graphql输入与.expected输出都验证了一次完整的转换结果,覆盖:

  • 操作与 Fragment 级别catch-usage-query.graphqlcatch-usage-query-mutation.graphqlcatch-usage-fragment.graphqlcatch-usage-on-query.graphql——验证@catch挂在 query、mutation、fragment 定义上的转换输出;
  • 字段级别catch-usage-linked.graphqlcatch-usage-linked-with-linked-sibling.graphql——验证对象(linked)字段上的@catch,以及同级字段并存时的行为;
  • 嵌套捕获catch-usage-nested-catches.graphql——验证多层@catch嵌套时的边界与传播;
  • 别名内联片段catch-usage-inline-fragment-with-alias.graphqlcatch-to-default-usage-inline-fragment-with-alias.graphql——验证@catch@alias内联片段的合法组合;
  • 默认参数catch-to-default-usage-query.graphql——验证不带to参数时等价于to: RESULT

同时还有一批.invalid用例专门验证编译期校验,例如catch-usage-on-query-with-required.invalid.graphql(同节点@catch+@required冲突)、catch-usage-inline-fragment-no-alias.invalid.graphql(未别名内联片段)、catch-usage-fragment-spread-alias.invalid.graphql/catch-usage-fragment-spread-no-alias.invalid.graphql(fragment spread 上的@catch不被支持)——这些用例印证了「@catch不能用于 fragment spread」的限制。

运行时侧也有对应的行为测试,例如packages/relay-runtime/store/__tests__/RelayReader-CatchFields-test.js,其中包含CatchToResultFragmentCatchToNullFragmentCatchMissingInQueryToResultErrorQuery等生成用例,分别覆盖to: RESULTto: NULL以及「缺失数据」的读取行为。

实践建议与注意事项

  1. 选择合适的捕获粒度:错误只影响单个字段时,把@catch放在该字段上,实现字段级降级;整块 UI 需要整体兜底时,放在祖先对象上利用冒泡机制,用path定位具体出错字段。
  2. 善用ok判别可空语义:捕获后{ ok: true, value: null }{ ok: false, errors }形态分明,可以放心区分「真正的空值」与「异常」。
  3. 不要在同一字段混用@catch@required:编译器会直接报错;请把@required放在@catch的子孙字段上,让其错误冒泡进@catch边界。
  4. 认清作用边界@catch只覆盖它所在操作内直接书写的字段,不覆盖 spread fragment 内部的字段错误;若项目使用@throwOnFieldError全局抛出,用@catch显式声明「此处不抛出」的例外。
  5. 理解嵌套传播:默认特性开关下,内层@catch已处理的错误仍可能影响外层@catch的判定;如需「内层已接住、外层不再受影响」的语义,可关注ENABLE_CATCH_IGNORE_HANDLED_FIELD_ERRORS特性开关。

参考阅读

  • 最新版@catch指南:website/docs/guides/catch-directive.mdx
  • 编译器转换实现:compiler/crates/relay-transforms/src/catch_directive.rs、catchable_node.rs、validation_message.rs
  • 编译器测试 fixtures:compiler/crates/relay-transforms/tests/catch_directive/fixtures
  • 运行时错误捕获:packages/relay-runtime/store/RelayReader.js、packages/relay-runtime/store/tests/RelayReader-CatchFields-test.js
  • 相关特性开关:packages/relay-runtime/util/RelayFeatureFlags.js

【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay

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

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

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

立即咨询