mcpField
R
relay-best-practices

relay-best-practices

github.com/facebook开发19.0k214 次安装今天更新
代码仓库

关于 relay-best-practices

relay-best-practices 是指导 React 开发者使用 Relay 的技能,涵盖 GraphQL 数据获取、组件片段、编译器和错误处理等模式。它以决策规则和反模式帮助团队编写符合 Relay 架构的代码。

主要功能

  1. 01按组件组织 GraphQL 片段
  2. 02采用预加载查询模式
  3. 03提供 Relay 编译器工作流指导
  4. 04说明字段错误处理方式
  5. 05列出常见反模式

适用场景

  1. 01为 React 组件规划数据依赖
  2. 02决定查询应放置的位置
  3. 03处理 Relay 分页与变更
  4. 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
useFragmentapi-reference/hooks/use-fragment.mdx
usePreloadedQueryapi-reference/hooks/use-preloaded-query.mdx
useQueryLoader / loadQueryapi-reference/hooks/load-query.mdx
useMutationapi-reference/hooks/use-mutation.mdx
usePaginationFragmentapi-reference/hooks/use-pagination-fragment.mdx
@throwOnFieldErrorguides/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_propNameUserCard_user
查询ComponentNameQueryHomePageQuery
变更ComponentNameMutationLikeButtonMutation
生成的文件__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 是否不匹配)。

评论

支持 Markdown