关于 relay-best-practices
relay-best-practices 是指导 React 开发者使用 Relay 的技能,涵盖 GraphQL 数据获取、组件片段、编译器和错误处理等模式。它以决策规则和反模式帮助团队编写符合 Relay 架构的代码。
主要功能
- 01按组件组织 GraphQL 片段
- 02采用预加载查询模式
- 03提供 Relay 编译器工作流指导
- 04说明字段错误处理方式
- 05列出常见反模式
适用场景
- 01为 React 组件规划数据依赖
- 02决定查询应放置的位置
- 03处理 Relay 分页与变更
- 04排查生成类型缺失
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ facebook/ relay --skill relay-best-practices安装后在 Claude Code 中输入/relay-best-practices确认已加载。
详细介绍
Relay 最佳实践
Relay 是一个适用于 React 的 GraphQL 客户端,强调数据获取应当与组件共置、可组合且类型安全。它的核心理念是:每个组件都应通过 GraphQL 片段准确声明所需的数据,其余工作——获取、缓存、一致性和更新——由 Relay 处理。
本指南提供了关于优先采用哪些模式的建议。如需查看详细的 API 文档,请阅读下方文档索引中对应的页面。
文档
Relay 在 node_modules/relay-runtime/llm-docs/ 中提供适合大模型使用的文档(v20.1.1 及更高版本可用)。对于更早的版本,请从
https://raw.githubusercontent.com/facebook/relay/main/website/docs/ 获取相同文件。
以下路径均相对于此目录(<llm-docs>/)。编写 Relay 代码前,请先阅读相关页面。主要文档:
| 主题 | 路径 |
|---|---|
| 核心概念与理念 | principles-and-architecture/thinking-in-relay.mdx |
| 片段 | guided-tour/rendering/fragments.mdx |
| 查询 | guided-tour/rendering/queries.mdx |
| 变更 | guided-tour/updating-data/graphql-mutations.mdx |
| 分页 | guided-tour/list-data/pagination.mdx |
| 重新获取 | guided-tour/refetching/refetching-queries-with-different-data.mdx |
useFragment | api-reference/hooks/use-fragment.mdx |
usePreloadedQuery | api-reference/hooks/use-preloaded-query.mdx |
useQueryLoader / loadQuery | api-reference/hooks/load-query.mdx |
useMutation | api-reference/hooks/use-mutation.mdx |
usePaginationFragment | api-reference/hooks/use-pagination-fragment.mdx |
@throwOnFieldError | guides/throw-on-field-error-directive.mdx |
@catch 指令 | guides/catch-directive.mdx |
| 语义可空性 | guides/semantic-nullability.mdx |
| Relay 解析器 | guides/relay-resolvers/introduction.mdx |
| 测试 | guides/testing-relay-components.mdx |
| 编译器设置 | getting-started/compiler.mdx |
| 编译器配置 | getting-started/compiler-config.mdx |
| Lint 规则(ESLint 插件) | getting-started/lint-rules.mdx |
如需性能方面的专门指导(查询位置、@defer、分页、获取策略、缓存、片段粒度),请参阅配套的 relay-performance skill。
核心理念
以下原则是做出所有决策的基础。如有疑问,请回顾这些原则。
- 共置:每个组件都通过 GraphQL 片段声明自己的数据需求,并将其放在渲染代码旁边。数据需求随组件一起传递,而不是单独传递。
- 数据屏蔽:组件只能访问其自身片段中明确选取的字段。父组件无法查看子组件的片段数据,反之亦然。这可以避免组件之间产生隐式耦合。
- 组合:片段组合成父片段,并最终组成查询,形成与组件树相对应的树。编译器会将其展平为每个查询一个网络请求。
- 边获取边渲染:在需要数据的组件渲染前开始获取数据。这可以避免请求按顺序依次发起而形成瀑布流。
- 规范化存储:Relay 维护一个扁平的、以 ID 为键的缓存。当变更返回更新后的数据时,所有读取该数据的组件都会自动重新渲染。无需手动传递更改。
编译器工作流
Relay 使用提前编译器读取代码中的 graphql 标记模板字面量,并生成运行时产物以及 TypeScript/Flow 类型。
查找配置
编译器会按以下顺序在这些位置查找配置:
- 项目根目录中的
relay.config.{json,js,mjs,ts} package.json中的"relay"键
完整的配置模式请参阅 <llm-docs>/getting-started/compiler-config.mdx。
也可以运行 npx relay-compiler config-json-schema,为配置生成 JSON Schema。
运行编译器
修改 graphql 标记模板字面量的内容或 Relay Resolver 的文档块后,运行 npx relay-compiler。有些项目会在 package.json 中将其添加为脚本(例如,yarn relay)。编译器也支持监视模式(--watch),但请避免在非交互式上下文中使用,因为该进程永远不会退出。
生成的文件会放在源文件旁边的 __generated__/ 目录中。切勿编辑这些文件——每次运行编译器时都会覆盖它们。如果看到有关缺少生成类型的类型错误,请先运行编译器——类型可能只是过期了。
Lint 规则
Relay 的 ESLint 插件(eslint-plugin-relay)是开发体验的重要组成部分。其中两条规则尤为重要:
relay/unused-fields— 检测在片段中选取、但组件从未读取的 GraphQL 字段。这可以避免“只增不减的查询”问题:片段会随时间累积未使用的字段,获取组件实际并不需要的数据。relay/no-future-added-value— 禁止显式处理 Relay 插入的枚举占位符"%future added value",该占位符用于确保你能处理服务端添加新枚举变体的情况。
安装和配置说明请参阅 <llm-docs>/getting-started/lint-rules.mdx。
决策规则
查询应放在根节点——绝不要放在钩子中
查询应放在路由入口(URL 对应的顶层组件)中。钩子是叶节点,会在多个组件间复用。钩子中的查询会延迟执行(在宿主组件渲染后),还会在每个调用方重复执行。解决方法始终是:将片段键作为参数传入,并使用 useFragment。
使用 usePreloadedQuery + useQueryLoader(或 loadQuery)。在组件渲染前,于事件处理程序、路由切换或应用初始化期间开始获取数据。useLazyLoadQuery 要等到渲染时才开始获取数据,会造成瀑布流。完整模式请参阅 <llm-docs>/guided-tour/rendering/queries.mdx。
修复查询位置前,先确认它是否应该存在
| 问题 | 如果是 |
|---|---|
| 父组件已经获取了这个 GraphQL 类型? | 删除查询,使用 useFragment |
| 组件只负责获取数据并向下传递? | 彻底删除包装组件(加载器反模式) |
| 查询位于自定义钩子中? | 删除查询,改为接收片段键参数 |
| 两个组件获取了相同的数据? | 删除其中一个查询,在共同的祖先组件中获取数据 |
| 数据仅用于日志记录或分析? | 移至 @defer 或服务端日志记录 |
| 数据是静态配置(对每个用户都相同)? | 在服务端注入,无需往返请求 |
在 useEffect 中使用 loadQuery 比 useLazyLoadQuery 更糟
useEffect 在绘制后运行,因此获取数据的开始时间比 useLazyLoadQuery 更晚(后者至少会在渲染期间开始)。在事件处理程序、路由切换或应用初始化期间调用 loadQuery——绝不要在副作用中调用。
遍历祖先树以使用 useFragment
判断能否用 useFragment 替代查询时,从组件开始沿组件树向上遍历。在以下位置停止:路由边界、功能门控、条件渲染或用户触发的交互。以下情况则继续遍历:无条件渲染、布局或包装组件、上下文提供程序。
如果任何祖先组件已经查询了你需要的 GraphQL 类型,答案就是使用 useFragment。通过多个属性层传递片段键完全没问题——这正是正确模式。
不要将查询变量默认设为空值
当真实值不可用时,绝不要将查询变量默认设为 ''、0 或 null。这会让查询带着错误数据执行,导致返回错误结果或报错。应改用条件渲染(if (!id) return null)或 @include/@skip 指令,完全省略该字段。
数据流:始终使用片段
每个显示服务端数据的组件都应声明一个片段,并将片段引用($key 类型)作为 prop 接收。父组件应在自己的查询或片段中展开子组件的片段,并将结果向下传递。请参阅下方的反模式“保持片段与组件共置”示例。
变更:在响应中展开片段
在变更响应中展开使用数据的组件的片段,而不是逐个选择字段。这样可以自动保持同步。请参阅下方的反模式示例。
错误处理:使用 @throwOnFieldError 和 @catch
处理字段错误和可空性的推荐做法是在片段或查询中添加 @throwOnFieldError。遇到字段错误时,Relay 会因此抛出 JavaScript 异常,并可由 React 错误边界捕获。对于 @semanticNonNull 字段,这也会启用非空类型,从而免去不必要的空值检查。请注意,此模式依赖于在应用中配置 React 错误边界;如果错误边界设置不够健全,请谨慎使用。
对于希望在本地处理错误而非抛出错误的字段,请使用 @catch,以内联形式接收错误:{ ok: true, value: T } | { ok: false, errors: [...] }。
也可以使用 @required 声明特定字段必须非空,但对于新代码,首选模式是 @throwOnFieldError + @catch。
请参阅 <llm-docs>/guides/throw-on-field-error-directive.mdx、<llm-docs>/guides/catch-directive.mdx 和 <llm-docs>/guides/semantic-nullability.mdx。
分页:使用三指令模式
始终同时使用 @argumentDefinitions(用于游标/计数变量)、@refetchable(用于自动生成分页查询)和 @connection(用于标识供存储管理的连接)。切勿手动编写分页查询。请参阅 <llm-docs>/guided-tour/list-data/pagination.mdx。
客户端状态:优先使用 Relay Resolvers
当多个组件需要读取客户端数据时,请使用 Relay Resolvers 在 GraphQL 架构上定义仅限客户端的字段,而不是逐层传递 props 或使用 React context。这样一来,客户端状态也能像服务端数据一样具备可组合性和缓存保证。对于仅读取 Resolver 定义字段的查询,请使用 useClientQuery。
有关如何定义 Resolvers,请参阅 <llm-docs>/guides/relay-resolvers/introduction.mdx。
关键反模式
切勿将 Relay 数据复制到 React 状态中
这是最重要的一条规则。不要从 useFragment 读取数据并将其复制到 useState 中,也不要在变更的 onCompleted 回调中手动更新该状态。
// WRONG: Copying Relay data into React state
function UserProfile({userKey}) {
const data = useFragment(UserProfileFragment, userKey);
const [name, setName] = useState(data.name); // broken
const [commit] = useMutation(UpdateNameMutation);
const handleSave = (newName) => {
commit({
variables: {name: newName},
onCompleted: (response) => {
setName(response.updateName.user.name); // broken
},
});
};
return <span>{name}</span>;
}
这样做是错误的原因:Relay 的规范化存储是唯一真实数据源。变更返回带有匹配 id 的更新数据时,Relay 会自动更新所有通过 useFragment 读取该数据的组件。将数据复制到 useState 中会创建第二个真实数据源,而 Relay 无法更新它。每当应用中的其他变更、订阅或重新获取操作更新记录时,组件都会显示过时数据。
// CORRECT: Read directly from the fragment
function UserProfile({userKey}) {
const data = useFragment(UserProfileFragment, userKey);
const [commit, isInFlight] = useMutation(UpdateNameMutation);
const handleSave = (newName) => {
commit({variables: {name: newName}});
// No onCompleted needed — Relay updates the store automatically,
// and useFragment re-renders this component with the new data.
};
return <span>{data.name}</span>;
}
同样,不要在 React 状态中存储片段键($key prop)。Relay 会对已不再由已挂载查询组件保留的数据进行垃圾回收——如果最初获取数据的组件卸载,暂存的键可能会指向存储中已不存在的数据。
保持片段与组件共置
不要在父组件的查询中获取所有数据,再将原始数据对象作为 props 传给子组件。这会破坏数据屏蔽并造成紧密耦合——要给子组件添加字段,就必须修改父组件的查询。请注意,relay/unused-fields lint 规则会标记父组件中所选、但仅由子组件使用的字段;这说明你应该提取一个片段。
// WRONG: Parent fetches everything, passes raw data
function Parent({queryRef}) {
const data = usePreloadedQuery(graphql`
query ParentQuery {
user {
name
email
avatarUrl
}
}
`, queryRef);
return <UserCard name={data.user.name} avatarUrl={data.user.avatarUrl} />;
}
// CORRECT: Child declares its own fragment
function Parent({queryRef}) {
const data = usePreloadedQuery(graphql`
query ParentQuery {
user {
...UserCard_user
}
}
`, queryRef);
return <UserCard user={data.user} />;
}
将片段展开到变更响应中
不要在片段和变更响应中分别选择字段——两者会逐渐不同步。改为展开片段:
# WRONG
mutation UpdateUserMutation($input: UpdateUserInput!) {
updateUser(input: $input) {
user { id, name, email, avatarUrl }
}
}
# CORRECT
mutation UpdateUserMutation($input: UpdateUserInput!) {
updateUser(input: $input) {
user { ...UserCard_user }
}
}
正确性
对依赖存储区的值使用 optimisticUpdater
如果乐观值依赖当前存储区状态(例如,增加点赞数),请使用 optimisticUpdater,而不是 optimisticResponse。多个重叠的乐观响应可能会错误地累加——两个同时进行的“点赞”变更都会读取 count=5 并将其设为 count=6,而不是按 5→6→7 递增。当其中一个回滚时,存储区会处于不一致状态。
在影响范围广的变更后使数据失效
当变更的副作用范围过广,无法在单个响应负载中体现时,请使用 invalidateRecord() 进行定向失效,或使用 invalidateStore() 进行全局失效。在已挂载的组件中配合使用 useSubscribeToInvalidationState,自动触发过期数据的重新获取。
明确处理过期数据
默认情况下,Relay 会将缓存数据视为永久有效。有两种处理方式:
- 基于时间:在 Relay Store 上设置
queryCacheExpirationTime,在经过指定时长后自动将数据标记为过期。 - 基于事件:对于副作用超出变更响应负载范围的变更,在变更后调用
invalidateRecord()。
如果不明确处理数据过期,用户返回之前访问过的屏幕后,组件可能会显示任意久远的数据。
使用单一制品目录确保类型安全
如果未在编译器配置中设置 artifactDirectory,片段引用可能会默认为 any,从而失去类型安全。请设置此项,并相应调整打包工具的模块解析配置。请参阅 <llm-docs>/getting-started/compiler-config.mdx。
使用 @updatable 操作存储区
优先使用类型安全的可更新查询/片段,而不是通过基于字符串的字段访问直接操作存储区(例如,store.get(id).setValue(newName, 'name'))。可更新片段提供 getter 和 setter,可降低拼写错误和类型不匹配的风险。
避免在变更后进行不必要的重新获取
如果在变更响应中展开组件片段即可自动更新存储区,就不要在变更后调用 refetch() 或 fetchQuery()。每次不必要的重新获取都会浪费网络请求,并延迟 UI 更新。
仅当变更的副作用范围过广,无法在响应负载中体现时,才手动重新获取——在这些情况下,优先使用 invalidateRecord()(见上文)。
使用订阅获取实时数据
对于必须保持最新的数据,优先使用 GraphQL 订阅,而不是轮询(setInterval + fetchQuery)或手动刷新按钮。订阅只会在数据发生变化时推送更新,并自动与 Relay 的规范化存储区集成。
命名约定
Relay 强制要求操作名称与其定义所在的模块(文件)匹配。名称不匹配会导致编译器错误,而不只是样式警告。
| 元素 | 约定 | 示例 |
|---|---|---|
| 片段 | ComponentName_propName | UserCard_user |
| 查询 | ComponentNameQuery | HomePageQuery |
| 变更 | ComponentNameMutation | LikeButtonMutation |
| 生成的文件 | __generated__/*.graphql | 切勿编辑这些文件 |
模块名称是移除所有扩展名(.react.js、.tsx 等)后的文件名。UserCard.react.js → 模块名称为 UserCard。
提取到新文件时重命名操作
将组件移到新文件时,只重命名在该文件中定义的操作,使其与新文件名匹配。不要重命名引用其他模块所拥有片段的展开名称——这些名称属于定义它们的组件。
重命名操作也会更改其生成的类型名称(例如,UserCard_user$key → ProfileCard_user$key),因此请更新所有下游对这些生成类型的导入。
切勿手动编辑 __generated__/ 文件
下次运行编译器时,所有手动修改都会被覆盖。如果遇到缺少生成类型的类型错误,请先运行编译器——这些类型只是过期了,并非缺失。
自动格式化后验证 mutation 变量键
自动格式化工具和代码检查工具可能会重命名变量,从而在不易察觉的情况下破坏 mutation 调用。运行 lint 自动修复后,请确认 commit({ variables: { ... } }) 中的变量键仍与生成的 Mutation$variables 类型一致(尤其要检查 data 和 input 是否不匹配)。