mcpField
R
relay-best-practices

relay-best-practices

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

关于 relay-best-practices

为使用 Relay 的 React 开发者提供模式指导,强调片段共置、组合和类型安全的数据获取,并收录编译器、错误处理、分页等实践。

主要功能

  1. 01GraphQL 片段共置与组合
  2. 02预加载查询以避免请求瀑布
  3. 03编译器与生成类型工作流
  4. 04字段错误与空值处理建议
  5. 05分页及客户端状态模式

适用场景

  1. 01规划组件的数据需求
  2. 02选择查询和片段的放置方式
  3. 03处理 Relay 更新与错误
  4. 04配置分页和客户端状态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/meta/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