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 }且value为null(真 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.rs的assert_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 异常,请把错误放进数据对象里」,并且遵循上面列出的全部规则(包括冒泡到父字段)。
两个要点值得牢记:
@catch不依赖@throwOnFieldError也能生效。即使没有开启@throwOnFieldError,@catch依然会把错误提供到数据对象中。区别在于:此时@catch之外的其他字段在出错时依然不会 throw——因为缺少@throwOnFieldError的全局开启,它们仍走「返回 null」的默认路径。- 作用范围是局部的。无论
@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.rs的catch_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,逐节点遍历程序。对OperationDefinition、FragmentDefinition、ScalarField、LinkedField、带别名的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.graphql、catch-usage-query-mutation.graphql、catch-usage-fragment.graphql、catch-usage-on-query.graphql——验证@catch挂在 query、mutation、fragment 定义上的转换输出; - 字段级别:
catch-usage-linked.graphql、catch-usage-linked-with-linked-sibling.graphql——验证对象(linked)字段上的@catch,以及同级字段并存时的行为; - 嵌套捕获:
catch-usage-nested-catches.graphql——验证多层@catch嵌套时的边界与传播; - 别名内联片段:
catch-usage-inline-fragment-with-alias.graphql、catch-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,其中包含CatchToResultFragment、CatchToNullFragment、CatchMissingInQueryToResultErrorQuery等生成用例,分别覆盖to: RESULT、to: NULL以及「缺失数据」的读取行为。
实践建议与注意事项
- 选择合适的捕获粒度:错误只影响单个字段时,把
@catch放在该字段上,实现字段级降级;整块 UI 需要整体兜底时,放在祖先对象上利用冒泡机制,用path定位具体出错字段。 - 善用
ok判别可空语义:捕获后{ ok: true, value: null }与{ ok: false, errors }形态分明,可以放心区分「真正的空值」与「异常」。 - 不要在同一字段混用
@catch与@required:编译器会直接报错;请把@required放在@catch的子孙字段上,让其错误冒泡进@catch边界。 - 认清作用边界:
@catch只覆盖它所在操作内直接书写的字段,不覆盖 spread fragment 内部的字段错误;若项目使用@throwOnFieldError全局抛出,用@catch显式声明「此处不抛出」的例外。 - 理解嵌套传播:默认特性开关下,内层
@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),仅供参考