用途
本技能为编写地道的 Relay 代码提供有明确倾向的实践指导。Relay 是面向 React 的 GraphQL 客户端,其核心思想是:每个组件通过 GraphQL 片段精确声明自己所需的数据,Relay 负责其余部分——获取、缓存、一致性与更新。当编写或修改使用 Relay 进行数据获取的 React 组件时,应始终使用本技能。
核心哲学
- 共置(Co-location):每个组件通过 GraphQL 片段在渲染代码旁声明自身数据需求,数据需求随组件移动,而非分离。
- 数据屏蔽(Data masking):组件只能访问自己在片段中显式选择的字段,父组件看不到子片段数据,反之亦然,防止组件间隐式耦合。
- 组合(Composition):片段组合进父片段并最终组合进查询,形成与组件树镜像的树;编译器将其展平为每个查询一次网络请求。
- 渲染即获取(Render-as-you-fetch):在需要数据的组件渲染之前就开始获取,避免顺序请求瀑布。
- 规范化存储(Normalized store):Relay 维护扁平的、以 ID 为键的缓存;变更返回更新数据时,读取该数据的所有组件自动重新渲染,无需手动传播变更。
编译器工作流
Relay 使用提前编译器读取代码中的 graphql 标签模板字面量,生成运行时产物与 TypeScript/Flow 类型。
- 查找配置:编译器按顺序在项目根目录查找 relay.config.{json,js,mjs,ts},或 package.json 中的 "relay" 键。可通过 npx relay-compiler config-json-schema 输出配置的 JSON Schema。
- 运行编译器:在 graphql 标签模板字面量内容或 Relay Resolver 的 docblock 发生任何更改后,运行 npx relay-compiler。部分项目将其添加为 package.json 脚本(如 yarn relay)。编译器支持 watch 模式(--watch),但避免在非交互式上下文中使用,因为进程永不退出。
- 生成文件位于源文件旁的 generated/ 目录中。切勿编辑这些文件——每次编译器运行都会覆盖。若出现缺少生成类型的类型错误,先运行编译器——类型很可能只是过期了。
Lint 规则
Relay 的 ESLint 插件(eslint-plugin-relay)是开发体验的关键部分。两条规则尤为重要:
- relay/unused-fields:检测片段中已选择但组件中从未读取的 GraphQL 字段,防止“只增不减查询”问题。
- relay/no-future-added-value:防止显式处理 Relay 插入的 "%future added value" 枚举占位符。
决策规则
- 查询属于根组件——绝不在 hook 中:查询属于路由入口点(URL 的顶层组件)。Hook 是叶子,被许多组件复用。Hook 中的查询触发晚且在每个调用者中重复。修复方法始终是:接受片段键作为参数,使用 useFragment。
- 使用 usePreloadedQuery + useQueryLoader(或 loadQuery):在事件处理器、路由转换或应用初始化期间开始获取——在组件渲染之前。useLazyLoadQuery 直到渲染才开始获取,造成瀑布。
- 在修复查询位置之前,先问它是否应该存在:父组件已获取该 GraphQL 类型?删除查询,使用 useFragment。组件只获取并向下传递数据?完全删除包装组件(loader 反模式)。查询在自定义 hook 中?删除查询,接受片段键参数。两个组件获取相同数据?删除一个查询,在共同祖先中获取。数据仅用于日志/分析?移至 @defer 或服务端日志。数据是静态配置?服务端注入,无需往返。
- useEffect 中的 loadQuery 比 useLazyLoadQuery 更糟:useEffect 在绘制后运行,获取开始得更晚。在事件处理器、路由转换或应用初始化中调用 loadQuery——绝不在 effect 中。
- 为 useFragment 遍历祖先树:从组件向上遍历,在路由边界、功能门、条件渲染或用户触发交互处停止;继续穿过无条件渲染、布局/包装组件、上下文提供者。若任何祖先已查询所需 GraphQL 类型,useFragment 就是答案。通过多层 props 传递片段键是可以的——这正是正确模式。
- 不要将查询变量默认为空值:当真实值不可用时,绝不要将查询变量默认为 ''、0 或 null。改用条件渲染(if (!id) return null)或 @include/@skip 指令完全省略字段。
- 数据流:始终使用片段。每个显示服务端数据的组件都应声明片段并接收片段引用($key 类型)作为 prop。父组件在自身查询或片段中展开子片段并向下传递结果。
- 变更:将片段展开进响应。将消费组件的片段展开进变更响应,而非单独选择字段,以自动保持同步。
- 错误处理:使用 @throwOnFieldError 和 @catch。推荐在片段或查询中添加 @throwOnFieldError,遇到字段错误时抛出 JavaScript 异常,可由 React 错误边界捕获,同时为 @semanticNonNull 字段启用非空类型。此模式依赖应用中配置了 React 错误边界。对于希望本地处理错误的字段,使用 @catch 以内联形式接收错误。@required 也可用于声明特定字段必须非空,但 @throwOnFieldError + @catch 是新代码的首选模式。
- 分页:使用三指令模式。始终一起使用 @argumentDefinitions(用于 cursor/count 变量)、@refetchable(自动生成分页查询)和 @connection(标识连接以供存储管理)。绝不要手写分页查询。
- 客户端状态:优先使用 Relay Resolvers。当多个组件需要读取客户端数据时,使用 Relay Resolvers 在 GraphQL schema 上定义仅客户端字段,而非 prop-drilling 或 React context。对仅读取 resolver 定义字段的查询使用 useClientQuery。
关键反模式
- 绝不将 Relay 数据复制进 React state:这是最重要的规则。不要从 useFragment 读取数据并复制进 useState,也不要在变更 onCompleted 回调中手动更新该状态。Relay 的规范化存储是唯一事实来源;复制进 useState 会创建 Relay 无法更新的第二事实来源,组件将显示过期数据。同样,不要将片段键($key prop)存入 React state。
- 保持片段共置:不要在父查询中获取所有数据并将原始数据对象作为 props 传给子组件。这破坏了数据屏蔽并造成紧耦合。relay/unused-fields lint 规则会标记父组件中仅被子组件使用的字段——这是需要提取片段的良好信号。
- 将片段展开进变更响应:不要在片段和变更响应中分别单独选择字段——它们会失去同步。应展开片段。
正确性
- 对依赖存储的值使用 optimisticUpdater:若乐观值依赖当前存储状态(如递增点赞数),使用 optimisticUpdater 而非 optimisticResponse。多个重叠的乐观响应可能错误叠加。
- 广泛影响变更后使数据失效:当变更的副作用过于广泛无法在单个响应负载中捕获时,使用 invalidateRecord() 进行定向失效或 invalidateStore() 进行全局失效。配合已挂载组件上的 useSubscribeToInvalidationState 自动触发过期数据的重新获取。
- 显式处理过期:Relay 默认将缓存数据视为永久新鲜。两种方法:基于时间——在 Relay Store 上设置 queryCacheExpirationTime 自动标记数据过期;基于事件——在副作用超出变更响应负载的变更后调用 invalidateRecord()。
- 单一产物目录以确保类型安全:未设置 artifactDirectory 时,片段引用可能默认为 any,失去类型安全。设置它并对齐打包器的模块解析。
- 使用 @updatable 进行存储操作:优先使用类型安全的 updatable 查询/片段,而非基于字符串字段访问的原始存储操作。
- 避免变更后不必要的重新获取:当在变更响应中展开组件片段可自动更新存储时,不要在变更后调用 refetch() 或 fetchQuery()。
- 使用订阅获取实时数据:对于必须保持最新的数据,优先使用 GraphQL Subscriptions 而非轮询或手动刷新按钮。
命名约定
Relay 强制操作名称与定义它们的模块(文件)匹配,名称不匹配会导致编译器错误。
- 片段:ComponentName_propName,如 UserCard_user
- 查询:ComponentNameQuery,如 HomePageQuery
- 变更:ComponentNameMutation,如 LikeButtonMutation
- 生成文件:generated/*.graphql,切勿编辑
模块名是去除所有扩展名后的文件名(.react.js、.tsx 等)。
文档
Relay 在 node_modules/relay-runtime/llm-docs/ 中提供 LLM 友好文档(v20.1.1 后可用)。旧版本可从 https://raw.githubusercontent.com/facebook/relay/main/website/docs/ 获取相同文件。编写 Relay 代码前应阅读相关页面。关键文档涵盖核心概念、片段、查询、变更、分页、重新获取、各 hooks API、@throwOnFieldError、@catch 指令、语义可空性、Relay Resolvers、测试、编译器设置与配置、Lint 规则。性能相关指导(查询放置、@defer、分页、获取策略、缓存、片段粒度)见配套的 relay-performance 技能。