跳至主要内容

·阅读时长 5 分钟

Relay 对应用程序创作方法的采用,使最佳运行时性能和应用程序可维护性独特地结合在一起。在这篇文章中,我将描述大多数应用程序在其数据获取方面被迫做出的权衡,然后描述 Relay 的方法如何让你避开这些权衡,并在多个权衡维度上取得最佳结果。


在基于组件的 UI 系统(如 React)中,一个重要的决策是在你的 UI 树的哪个位置获取数据。虽然可以在 UI 树中的任何位置进行数据获取,但为了理解正在发挥作用的权衡,让我们考虑两个极端情况

  • 叶节点:直接在使用数据的每个组件内获取数据
  • 根节点:在 UI 的根节点处获取所有数据,并使用道具传递将数据传递给叶节点

在 UI 树的哪个位置获取数据会影响应用程序的性能和可维护性的多个维度。不幸的是,对于朴素的数据获取,这两个极端情况对于所有维度来说都不是最佳的。让我们看看这些维度,并考虑哪些维度在将数据获取更靠近叶子时会得到改善,以及哪些维度在将数据获取更靠近根节点时会得到改善。

加载体验

  • 🚫 叶节点:如果各个节点获取数据,最终会导致请求级联,你的 UI 需要按顺序进行多次请求往返(瀑布),因为 UI 的每一层都阻塞在它父层的渲染上。此外,如果多个组件恰好使用相同的数据,最终会导致多次获取相同的数据
  • ✅ 根节点:如果所有数据都在根节点处获取,你将进行一次请求,并渲染整个 UI,没有任何重复的数据或级联请求

悬念级联

  • 🚫 叶节点:如果每个单独的组件都需要单独获取数据,则每个组件将在初始渲染时挂起。在 React 的当前实现中,取消挂起会导致从最近的父级悬念边界重新渲染。这意味着你必须在初始加载期间 O(n) 次重新评估产品组件代码,其中 n 是树的深度。
  • ✅ 根节点:如果所有数据都在根节点处获取,你将在一次挂起,并且仅评估产品组件代码一次。

可组合性

  • ✅ 叶节点:在新的地方使用现有组件与渲染它一样简单。删除组件与不渲染它一样简单。类似地,添加/删除数据依赖项可以完全在本地完成。
  • 🚫 根节点:将现有组件添加为另一个组件的子组件需要更新每个包含该组件的查询,以获取新数据,然后将新数据通过所有中间层传递。类似地,删除组件需要将这些数据依赖项追踪回每个根组件,并确定你删除的组件是否为该数据的最后一个剩余消费者。相同的动态适用于向现有组件添加/删除新数据。

细粒度更新

  • ✅ 叶节点:当数据更改时,每个读取该数据的组件都可以单独重新渲染,避免重新渲染未受影响的组件。
  • 🚫 根节点:由于所有数据都源于根节点,因此当任何数据更新时,它总是强制根组件更新,从而强制对整个组件树进行昂贵的重新渲染。

Relay

Relay 利用 GraphQL 片段和编译器构建步骤来提供更优化的替代方案。在使用 Relay 的应用程序中,每个组件都定义一个 GraphQL 片段,它声明了该组件所需的数据。这包括组件将渲染的具体值以及它将渲染的每个直接子组件的片段(按名称引用)。

在构建时,Relay 编译器会收集这些片段,并为你的应用程序中的每个根节点构建一个单一查询。让我们看看这种方法是如何在上面描述的每个维度中发挥作用的

  • ✅ 加载体验 - 编译器生成的查询在一个往返中获取表面所需的所有数据
  • ✅ 悬念级联 - 由于所有数据都在一个请求中获取,因此我们只挂起一次,并且就在树的根节点处
  • ✅ 可组合性 - 向组件添加/删除数据,包括渲染子组件所需的片段数据,可以在单个组件内本地完成。编译器负责更新所有受影响的根查询
  • ✅ 细粒度更新 - 由于每个组件都定义了一个片段,因此 Relay 确切地知道每个组件消耗了哪些数据。这使得 Relay 能够执行最佳的更新,其中在数据更改时只重新渲染最小的组件集

总结

如你所见,Relay 使用声明性的可组合数据获取语言(GraphQL),再加上编译器步骤,使我们能够在上面概述的所有权衡维度中取得最佳结果

叶节点根节点GraphQL/Relay
加载体验🚫
悬念级联🚫
可组合性🚫
细粒度更新🚫

·阅读时长 4 分钟

Relay 团队很高兴地宣布发布 Relay v15。虽然此版本是主要版本升级,并包含一些重大更改,但我们预计大多数用户不会受到影响,并将体验到无缝升级。你可以在 v15 发行说明 中找到完整的更改列表。

Relay 15 中有哪些新功能?

支持接口上的 @refetchable

以前无法在服务器接口类型上的片段定义中添加 @refetchable 指令。

// schema.graphql

interface RefetchableInterfaceFoo @fetchable(field_name: "id") {
id: ID!
}

extend type Query {
fetch__RefetchableInterfaceFoo(id: ID!): RefetchableInterfaceFoo
}

// fragment

fragment RefetchableFragmentFoo on RefetchableInterfaceFoo
@refetchable(queryName: "RefetchableFragmentFooQuery") {
id
}

持久查询改进

如果你使用基于 URL 的持久查询,现在可以指定自定义标头,以便在请求中发送持久化查询。例如,这可以用来向你的查询持久化 URL 端点发送身份验证标头。

persistConfig: {
url: 'example.com/persist',
headers: {
Authorization: 'bearer TOKEN'
}
}

对于基于文件的持久查询,我们添加了一个新的功能标志 compact_query_text,它将从持久查询文本中删除所有空格。这可以使文件缩小 60% 以上。可以在你的 Relay 配置文件中启用此新功能标志。

persistConfig: {
file: 'path/to/file.json',
algorithm: 'SHA256'
},
featureFlags: {
compact_query_text: true
}

类型安全更新现在支持缺少字段处理程序

类型安全更新程序现在支持缺少字段处理程序。以前,如果你在类型安全更新程序中选择了 node(id: 4) { ... on User { name, __typename } },但该用户以其他方式获取(例如,使用 best_friend { name }),你将无法使用类型安全更新程序访问和修改该用户。

在此版本中,我们在类型安全更新程序中添加了对缺少字段处理程序的支持,这意味着如果为节点设置了缺少字段处理程序(如 此示例 中所示),你将能够使用此缺少字段处理程序更新用户的姓名。

为了支持这一点,缺少字段处理程序 的签名已更改。处理程序接收的 record 参数以前接收的是 Record 类型(它是一个未类型的杂乱数据包)。现在它接收的是 ReadOnlyRecordProxy。此外,类型为 NormalizationLinkedField 的字段参数现在为 CommonLinkedField,它是一个包含 ReaderLinkedFieldNormalizationLinkedField 中找到的属性的类型。

Flow 类型改进

Flow 用户现在将从更多 Relay API 中的 graphql 文本获取推断的类型。Flow 用户不再需要显式地为 usePreloadedQueryuseQueryLoaderuseRefetchableFragmentusePaginationFragmentuseBlockingPaginationFragment API 方法的返回值指定类型。

Relay Resolver 改进

自从我们上次发布以来,我们的大部分开发工作都集中在改进 Relay Resolvers(一种在图中公开派生数据的机制)上。值得注意的是,Relay Resolvers 仍处于实验阶段,API 可能会在将来发生变化。

更简洁的 docblock 标签

Relay Resolver 函数的注释已简化。在许多情况下,你现在可以使用 ParentType.field_name: ReturnType 语法来定义你的 Relay Resolver 公开的新的字段。

之前

/**
* @RelayResolver
* @onType User
* @fieldName favorite_page
* @rootFragment myRootFragment
*/

之后

/**
* @RelayResolver User.favorite_page: Page
* @rootFragment myRootFragment
*/

在上面的示例中,Page 类型是架构类型。如果你的 Relay Resolver 没有返回架构类型,则可以使用固定的 RelayResolverValue 值作为你的返回类型

/**
* @RelayResolver User.best_friend: RelayResolverValue
* @rootFragment myRootFragment
*/

每个文件定义多个解析器

在此版本之前,我们仅允许每个文件一个 Relay Resolver,并要求 Relay Resolver 函数为默认导出。在 Relay 15 中,您现在可以为每个文件定义多个 Relay Resolver 并使用命名导出。

/**
* @RelayResolver User.favorite_page: Page
* @rootFragment favoritePageFragment
*/
function usersFavoritePage(){
...
}

/**
* @RelayResolver User.best_friend: RelayResolverValue
* @rootFragment bestFriendFragment
*/
function usersBestFriend(){
...
}

module.exports = {
usersFavoritePage,
usersBestFriend
}

查询愉快!

·阅读时间:22 分钟
客座文章

这是一篇由 Coinbase 的员工工程师 Ernie Turner 撰写的客座文章。Coinbase 已在其应用程序中全面采用 Relay,并且是 Relay 团队的强力盟友。去年,他们帮助共同开发了 Relay VSCode 扩展。Ernie 同意与我们分享这篇内部工程博客文章。

如何在服务中断期间为客户提供最佳体验

在理想情况下,Coinbase 的所有服务都不会出现故障,并且我们 GraphQL 架构中的所有字段都会一直正确解析。由于这并不现实,因此 Coinbase 应用程序应该能够抵御停机并最大程度地减少对客户的影响:单个服务停机不应阻止用户使用或与整个应用程序交互。但是,当我们的应用程序无法按预期工作时,向用户传达问题也很重要。显示传达停机时间并带有重试按钮的错误消息比让用户对缺失的内容或无法交互的 UI 感到困惑要好。

本文将介绍处理 Relay 应用程序中缺失数据的常见模式和最佳实践。

屏幕架构和错误边界

在讨论处理 GraphQL 查询中的服务停机和故障之前,让我们首先讨论更广泛的屏幕架构以及 React 错误边界在正确使用时如何帮助创造更好的用户体验。

就像生活中大多数事情一样,错误边界应该适度使用。让我们看一下 Coinbase Retail 应用程序中的一个常见屏幕。

上述屏幕中的任何部分都可能无法获取渲染所需的数据,但我们处理这些故障的方式决定了用户使用我们应用程序的体验。例如,仅对任何故障使用单个屏幕级 ErrorBoundary 会导致应用程序在任何错误发生时无法使用,无论该错误的严重程度如何。相反,用自己的 ErrorBoundary 包装每个组件可能会产生同样糟糕的体验。最后,完全省略有错误的组件与其他两个选项一样糟糕。没有一种方法适合所有情况,因此让我们逐一分析这些方法并解释为什么它们会造成糟糕的用户体验。

全屏错误

上面的 UI 是 Coinbase 的全屏错误回退,如果服务遇到中断,而我们无法获取渲染此屏幕上组件所需的必要数据,则会显示该错误回退。在某些情况下,这实际上创造了良好的用户体验。我们可能没有向用户提供有关发生情况的详细信息,但在大多数情况下,提供技术原因是不可能的,也不会改善用户的体验。但是,我们正在告诉他们某些东西没有正常工作,并为他们提供了清晰的重试按钮以尝试使应用程序再次正常工作。

如果我们向用户显示此消息的原因是,我们无法加载某些非关键内容(例如资产价格历史图表或其观察列表状态),我们不应该关闭整个屏幕。隐藏比特币的当前价格并阻止用户交易,仅仅因为我们无法告诉他们比特币是否在他们的观察列表中,是一种消极的用户体验。

此 UI 的另一个负面影响是它会隐藏用户的所有应用程序导航。即使我们有充分的理由向用户显示全屏错误,也不意味着我们应该在此过程中隐藏应用程序的其余部分。用户仍然应该能够导航到另一个屏幕。在实践中,我们应该只向用户显示“全屏错误”,而不是“全应用程序错误”。

到处都是错误消息

上面图片中的 UI 在很多方面都更糟糕。这是之前体验的另一端,向用户显示全屏错误会更好。价格历史图表上的错误消息是有意义的,因为用户会期望该 UI 出现在此屏幕上,但如果用户甚至无法看到比特币的价格或找到交易按钮,那么我们真的应该向他们显示第一个屏幕截图中的 UI(但带有导航)- 因为此屏幕的核心目标和目的已经丢失了。

此图像还演示了 ErrorBoundaries 如何过于普遍。整个价格历史图表以及时间范围选择器应该只有一个错误消息,而不是每个时间范围一个错误消息。

空回退

上面的 UI 与之前的示例一样糟糕。在这种情况下,我们的 ErrorBoundaries 回退到空内容。对于某些 UI 元素,这是有意义的。观察列表旁边缺少的“分享”按钮对于此 UI 并不关键,因此省略它是有意义的。但是,隐藏比特币的当前价格、价格历史图表和交易按钮会使 UI 无法使用,甚至有些误导。即使是每天都不使用该应用程序的用户也会知道有些不对劲。我们也没有为用户提供任何重试任何故障的选项 - 用户只是看到空内容,没有办法恢复。

用户应该看到什么?

以下两个屏幕截图显示了用户更好体验的示例。第一个屏幕截图是如果我们无法获取比特币的当前价格,或者如果我们无法确定用户是否被允许交易,用户应该看到的内容。第二个屏幕截图是如果我们无法获取比特币价格的当前变化或价格历史,那么用户会得到更好的体验。

所有这些都表明需要对屏幕上的 UI 部分进行分类:哪些对用户的体验至关重要,用户期望看到哪些 UI,以及哪些辅助内容是体验可选的。

关键 UI 元素与预期 UI 元素与可选 UI 元素

应用程序屏幕中的并非所有 UI 元素都相同。屏幕的某些部分对于用户与 UI 的核心交互至关重要,而其他部分可能只是更具信息性和对用户更有帮助。对于 Coinbase 的应用程序设计,我们将 UI 元素分为三类:**关键**、**预期**和**可选**。

关键 UI 元素

定义用户与 UI 的核心信息或交互的屏幕部分。如果屏幕上没有这些元素,屏幕就没有意义,如果这些元素缺失,用户会感到困惑和/或生气,因为不清楚为什么应用程序没有按预期工作。

假设我们无法加载显示这些关键 UI 元素所需的数据。在这种情况下,我们应该向用户显示一个全屏错误消息,解释问题(如果可能),并提供一个重试按钮,让用户可以轻松尝试重新请求缺失的数据。

让用户与缺少关键 UI 元素的应用程序交互会导致困惑、生气,甚至可能在用户能够完成交易而不了解正在发生的事情的全部细节的情况下导致资金损失。

关键 UI 元素示例

  • Coinbase 应用程序主屏幕上的用户当前投资组合余额
  • 订单预览屏幕上的资产价格、付款方式和总购买价格
  • 用户在“赚取”屏幕上的终身收益和每项资产的收益

预期 UI 元素

预期 UI 元素是屏幕的那些部分,它们可能不服务于屏幕的核心目的,但大多数用户会期望它们存在。如果屏幕上缺少预期 UI 元素,用户可能会认为出了问题,但这不会阻止他们执行屏幕上的核心操作。

如果我们无法加载显示这些预期 UI 元素所需的数据,我们应该向用户显示一个组件本地错误消息,告诉他们缺少预期 UI。这些错误消息还应该附带一个重试按钮,让用户重新请求缺失的数据。本地化错误更有可能不被用户看到或与之交互,这在某种程度上是可以接受的,因为它们不是屏幕核心目的所必需的。

让用户与缺少预期 UI 元素的应用程序交互应该是可以接受的,但这可能会导致用户对正在发生的事情感到困惑。完全省略这些 UI 元素而不附带错误消息会产生更糟糕的体验。

预期 UI 元素示例

  • 购买资产屏幕上的资产当前价格(用户在此输入购买数量)
  • 资产详细信息屏幕上的价格历史图表
  • Coinbase 卡屏幕上的最近交易列表

可选 UI 元素

可选 UI 元素是屏幕的那些部分,它们完全支持屏幕的主要目的。有些用户可能会注意到这些元素缺失,而另一些用户可能完全没有意识到这些元素应该存在。在这两种情况下,用户都不会被阻止完成他们在屏幕上的主要目标。

如果我们无法加载显示这些可选 UI 元素所需的数据,我们应该直接将它们从 UI 中完全省略。但是,这会带来以下风险

A. 用户可能不知道有任何东西丢失 B. 用户将无法重新请求此 UI 的数据,除非他们进行完整的屏幕刷新。

开发人员应该考虑这些缺点,并确保它们不会造成负面的用户体验。相反,这些故障应该被记录下来,以便在用户体验不理想时通知产品工程师。

可选 UI 元素示例

  • 资产详细信息屏幕上的优惠卡
  • 交易屏幕上的资产类别部分(Coinbase 新上市、热门交易等)
  • 主屏幕上的新闻提要

让我们回到上面的图像,并将 UI 部分分类到这些类别中。

元素分类限制

在上面的例子中,我们有一个屏幕包含两个关键组件、两个预期组件和一个可选组件。大多数应用程序中的屏幕应该只包含少数关键 UI 组件。对于某些屏幕,整个 UI 可能会由一个单一的关键组件组成。

预期元素也是如此。如果我们的屏幕包含五个独立的预期 UI 元素,那么我们会得到上面截图中所示的情况,即“重试”按钮遍布整个应用程序。如果可能,请将单个屏幕上的预期元素和重试按钮的数量限制为一个或两个。

下拉刷新

对于以上所有情况,移动应用程序的用户应该能够下拉刷新以重试屏幕上的任何失败请求。对于 Relay 应用程序,这通常意味着重试整个屏幕级别的查询。如果屏幕因缺少数据而出现任何错误消息或隐藏组件,使用下拉刷新应始终尝试修复所有这些错误条件。

与产品经理和设计师协作

所有这些分类都是主观的 - 以上所有例子都只是一种观点,而设计师或 PM 可能对屏幕如何降级有不同的意见。在设计应用程序 UI 时,跨职能的协同一致非常重要。团队应咨询工程师、设计师和产品经理,以确保整个应用程序的屏幕无缝且符合品牌。

Relay 如何提供帮助

将屏幕分类到各个部分后,下一步是将适当的 ErrorBoundaries 添加到您的应用程序,并根据其分类配置组件的 GraphQL 片段。这就是 Relay 可以提供帮助的地方。根据我们在 Relay 应用程序方面的经验,我们创建了一些关于如何处理来自 GraphQL 查询的缺失数据的最佳实践。

背景

我们在 Coinbase 的目标是使用可空类型 schema,正如 Relay 团队建议的那样。主要驱动力是它将如何处理服务中断和缺失查询数据的决定权交给了客户端工程师。如果没有可空类型 schema,处理缺失数据的决定将在服务器上做出(通过将空值冒泡到最近的可空类型父级),并且客户端代码无法更改此决定。

这种决定是通过 Relay @required 指令 的存在来支持的,该指令允许客户端工程师用指令注释他们的查询和片段,这些指令告诉 Relay 如何在运行时处理缺失数据。这减少了工程师原本需要编写的样板代码。表面上,该指令看起来非常简单:它只有三个选项,而且都非常简单。但是,当尝试将此指令用于各种用例时,很明显,选择哪个选项并不总是显而易见的,是否使用该指令也并非显而易见。

@required 的局部性

@required 指令的一大特点是它只影响使用它的片段。它永远不会改变查询相同字段的其他片段的行为。这允许您添加或删除指令,而无需考虑组件范围之外的任何内容。这一点很重要,因为不同的组件可能被归类为不同的类别,即使它们从同一个查询获取数据。能够使用不同的 @required 参数标记相同查询片段中的字段对于构建理想的用户体验非常重要。

使用 action: LOG 与 action: NONE

LOGNONE 操作具有相同的运行时行为,但 LOG 会向您选择的日志记录机制发送一条消息,记录返回为 null 的字段的完整路径。对于大多数需要使用 @required 指令的用例,应使用 LOG 而不是 NONE。只有在某个字段预期对某些用户为 null 时,才应优先使用 NONE

虽然使用 action: LOG 创建的日志条目本身可能没有操作性,但它可以作为未来错误的线索,提供一个有用的信号。能够查看错误的历史记录并看到某个特定字段意外地为 null,可以帮助追踪用户在工作流程中可能遇到的未来错误。

何时使用 @required(action:LOG/NONE)

LOG/NONE 操作应仅用于必须在组件中显示可选 UI 的字段。在设计应用程序时,这会显示出两种不同的用例。

  1. 您的组件是可选 UI,如果一个或一组字段为 null,则根本不应该渲染。
  2. 您的组件的一部分是可选 UI,并且依赖于一个对象类型字段,如果缺少一个或多个子字段,则该对象毫无意义。

让我们看一下包含这两个用例的片段。

fragment MyFragment on Asset {
id
name @required(action: LOG)
slug @required(action: LOG)
color
supply {
total @required(action: LOG)
circulating @required(action: LOG)
}
}

对于此片段,我们表示如果我们没有获取 name 或 slug 字段,则整个片段无效。如果这些字段从服务器返回为 null,则我们根本无法渲染此组件。此片段还显示了如何使用 @required(action: LOG/NONE) 指令使整个对象类型字段无效。此片段表示,如果我们没有 supply.totalsupply.circulating 字段,则整个 supply 对象本身无效,应该为 null。然后将使用此可空性来隐藏此组件 UI 的可选部分。

现在让我们看看我们的组件将如何处理来自此查询的结果。

const asset = useFragment(
graphql`
fragment MyFragment on Asset {
id
name @required(action: LOG)
slug @required(action: LOG)
color
supply {
total @required(action: LOG)
circulating @required(action: LOG)
}
}
`,
assetRef,
);

// If we couldn't get the required asset name or slug fields, hide this entire UI
if (asset === null) {
return null;
}
// Otherwise hide certain portions of the UI if data is missing
return (
<>
<Title color={asset.color}>{asset.name}</Title>
<Subtitle>{asset.slug}</Subtitle>
{asset.supply && (
<SupplyStats total={asset.supply.total} circulating={asset.supply.circulating} />
)}
</>
);

@required 指令在这里真正发挥了作用,因为它消除了我们原本需要编写的复杂空值检查。我们不再需要检查 asset.nameasset.slug 字段是否都为 null,而只需检查我们的整个片段是否为空,并防止渲染。检查是否应该渲染 SupplyStats 组件时也是如此。我们只需要检查父字段是否为 null,就可以知道两个子字段是否为非 null。

何时使用 @required(action:THROW)

使用 @required(action: THROW) 更加简单明了。此操作应用于渲染预期或关键 UI 组件所必需的字段。如果这些字段从服务器返回为 null,则您的组件应向最近的 ErrorBoundary 抛出错误,用户应看到错误消息。

您的 ErrorBoundary 向上到达树的程度取决于如果出现错误,您希望删除多少 UI。例如,如果我们向用户显示错误而不是资产价格历史图表,那么保留时间序列按钮在视野中就没有意义,整个 UI 也应该消失。但我们也不希望在这种情况下删除整个屏幕。

确保您的 ErrorBoundary 为用户提供了一种机制,使他们能够重试失败的查询,以查看他们是否可以在后续尝试中获取数据。我们应该始终将错误消息与可操作的元素配对,以让用户恢复。我们不应该依赖于用户能够(或知道如何)使用下拉刷新来重新加载屏幕。

关于在数组中的字段上使用 @required(action: THROW) 的说明

您几乎不应该在选择数组字段和该数组字段的字段的组件中使用 THROW 操作。以下是一个错误操作的示例。

function Component({ assetPriceRef }) {
const { quotes } = useFragment(
graphql`
fragment ComponentFragment on AssetPriceData {
quotes {
# Returns an array of items
timestamp
price @required(action: THROW)
}
}
`,
assetPriceRef,
);
}

此组件同时选择了 quotes 数组以及该数组中每个项目上的 timestampprice 字段。如果我们想在没有返回任何报价时向用户显示错误,那么在 quotes 字段上放置 THROW 是可以接受的。但是,在 price 字段上放置 THROW 将会导致如果该数组中只有一个 price 字段为 null,就会向用户显示错误。这可能不是我们想要的行为。如果我们正确地获得了过去一天 24 个报价中的 23 个,那么我们可能仍然应该显示我们拥有的结果,只是省略空值。

相反,我们应该使用 action: LOG/NONE,这样我们只会使数组中的单个项目无效,而不是所有项目。然后,我们可以根据需要选择性地过滤掉数组中的空值。

function Component({ assetPriceRef }) {
const { quotes } = useFragment(
graphql`
fragment ComponentFragment on AssetPriceData {
quotes {
# Returns an array of items
timestamp
price @required(action: LOG)
}
}
`,
assetPriceRef,
);
const validQuotes = quotes.filter(removeNull);
}

何时不要在字段上使用 @required

对这个问题没有帮助的答案是“当字段不是必需时,不要使用 @required”。这个答案将什么是必需的,什么不是必需的决定变得微不足道,尤其是在您的片段中包含十几个或更多字段时。但是,我们可以遵循一些最佳实践来决定是否将字段标记为必需。同样重要的是,您要与 PM 和设计师协作,以帮助您做出这些决定。

省略 @required 指令与使用 LOG/NONE 操作之间也存在细微的差别。主要区别在于,如果由该字段渲染的 UI 是可选 UI,则您应该省略 @required 指令。

您应用程序中的某些组件可以渲染不同 UI 分类组合。例如,单个组件可能负责显示资产的当前价格以及一定时间内有多少百分比的用户买入或卖出资产。这意味着该组件混合了关键 UI(资产价格)和可选 UI(买入/卖出统计数据)。

如果字段用于渲染可选内容,而该内容可以从 UI 中完全省略而不会造成用户混淆(请记住,这就是可选 UI 的定义),那么您不应该在该字段上使用 @required 指令。相反,您应该在代码中添加检查,以在字段为 null 时省略 UI。

function SomeComponent({ queryRef }) {
const { asset } = useFragment(
graphql`
asset {
latestQuote @required(action: THROW) # Required data
buyPercent # Optional data
}`,
queryRef,
);

return (
<div>
<div>Price: {asset.latestQuote}</div>
{asset.buyPercent !== null && (
<>
<div>Buy Percent: {asset.buyPercent}</div>
<div>Sell Percent: {1 - asset.buyPercent}</div>
</>
)}
</div>
);
}

在此示例中,在 buyPercent 字段上使用 @required(action: LOG/NONE) 将是不正确的,因为这会使整个片段无效,而这并不是我们想要的行为。

省略 @required 指令的另一个不太常见的用例是,当您可以提供一个安全的回退值时。如果错误地提供回退/默认值,可能会非常危险。虽然有一些情况下可以安全地回退到默认值,但通常非常罕见,应避免。但是,如果您能够提供安全的回退值,则应避免在该字段上添加 @required,而应使用回退值。

以下是一些关于何时提供备用值的指南。

  • 不应使用数字字段(表示数字的数字或字符串)的备用值。
    • 使用 0 来代替缺失值总是会给用户带来更多困惑。Coinbase 是一家金融公司,如果我们无法向用户展示准确的值,我们就应该完全不展示它们。告诉用户他们的账户余额是 $0.00 明显比向他们显示错误消息更糟糕。这是一个明显的用例,但即使在资产的价格变化百分比、Coinbase 卡的 APY% 或用户可以通过 Coinbase Earn 获得的金额等地方,如果我们没有实际值,也绝不应该显示 0。
  • 谨慎使用布尔字段的备用值。
    • 布尔字段备用值的第一个选择通常是将字段设置为 false。根据布尔字段的含义,回退到 false 可能会比向用户显示错误产生更糟糕的客户体验。对于诸如 isEligibleForOffer 之类的字段,回退到 false 可能可以接受,因为这可能是在显示可选内容。而对于诸如 hasCoinbaseOneSubscription 之类的字段,回退到 false 则不可接受,因为对于 CoinbaseOne 订阅用户来说,该内容是必不可少的,用户会对应用程序中缺少该 UI 感到困惑。
  • 谨慎使用数组字段回退到空数组。
    • 如果你要向用户展示他们的 Coinbase 卡交易列表,回退到空数组是个坏主意,但如果你要向用户展示最近添加的资产列表,回退到空数组可能没问题,这样可以省略 UI 的显示,因为该组件已经需要处理数组为空的情况。
  • 字符串字段通常只需处理 null。
    • 在某些情况下,你可能希望将返回为 null 的字符串字段回退到空字符串,但这通常会创建与将字段保留为 null 相同的代码路径。大多数模式中的字符串字段都不希望为空,因此回退到空字符串会带来负面的用户体验,用户会看到空字符串而不是实际内容。
function SomeComponent({ queryRef }) {
const asset = useFragment(
graphql`
fragment MyFragment on Asset {
canTrade @required(action: THROW) # Required data
hasOfferToStake # Optional data
}
`,
assetRef,
);

const showStakeOffer = asset.hasOfferToStake ?? false;

return (
<div>
{asset.canTrade && <Button>Trade</Button>}
{showStakeOffer && <Button>Stake your currency</Button>}
</div>
);
}

总结

如果你从本文中获得了任何启发,希望是,你需要认真考虑如何处理停机和服务中断。处理故障状态是构建世界一流应用程序的重要组成部分。在确定新功能范围时,确保你的设计和 PM 团队与你的团队保持一致。如果他们没有给你关于数据丢失时向用户显示什么的建议,请进行回推,以便团队就这些决定达成一致。

Relay 可以成为处理应用程序故障的有力工具。它可以帮助你细致地决定如何处理故障,这可能比你习惯的要多一些工作。然而,这些额外的努力最终会得到回报,并极大地改善用户对你应用程序的体验。

·阅读时间:12 分钟

我们非常高兴今天开源发布新的基于 Rust 的 Relay 编译器预览版(作为 v13.0.0-rc.1)!这个新的编译器更快,支持新的运行时功能,并为未来的发展提供了坚实的基础。

在发布之前,Meta 的代码库一直在增长,没有停止的迹象。以我们的规模来看,编译代码库中所有查询所需的时间一直在增加,直接损害了开发人员的生产力。虽然我们尝试了许多策略来优化基于 JavaScript 的编译器(下面会讨论),但我们逐渐提高性能的能力无法跟上代码库中查询数量的增长。

因此,我们决定用 Rust 重写编译器。我们选择 Rust 是因为它速度快、内存安全,并且可以轻松地在线程之间安全地共享大型数据结构。开发始于 2020 年初,编译器在当年年底内部发布。部署顺利,没有中断应用程序开发。最初的内部基准测试表明,编译器的平均性能提高了近 5 倍,P95 性能提高了近 7 倍。从那时起,我们进一步提高了编译器的性能。

本文将探讨为什么 Relay 有一个编译器,我们希望用新的编译器解锁什么,它的新功能以及我们选择使用 Rust 语言的原因。如果你急于开始使用新的编译器,请查看 编译器包自述文件发行说明

为什么 Relay 有一个编译器?

Relay 有一个编译器是为了提供稳定性保证并实现出色的运行时性能。

要了解原因,请考虑使用该框架的工作流程。使用 Relay,开发人员使用一种名为 GraphQL 的声明性语言来指定每个组件需要什么数据,但不是如何获取它。然后,编译器将这些组件的数据依赖项缝合到查询中,这些查询获取给定页面的所有数据,并预先计算出使 Relay 应用程序获得如此高性能和稳定性的工件。

在这个工作流程中,编译器

  • 允许以隔离的方式推理组件,从而使许多错误类型变得不可能,并且
  • 尽可能地将工作转移到构建时间,从而显著提高使用 Relay 的应用程序的运行时性能。

让我们依次研究这些内容。

支持本地推理

使用 Relay,组件仅通过使用 GraphQL 片段来指定自己的数据需求。然后,编译器将这些组件的数据依赖项缝合到查询中,这些查询获取给定页面的所有数据。开发人员可以专注于编写组件,而不必担心其数据依赖项如何融入更大的查询中。

但是,Relay 将这种本地推理更进一步。编译器还会生成由 Relay 运行时用来读出给定组件片段选择的特定数据的文件(我们称之为 数据遮蔽)。因此,组件永远不会访问(实际上,不只是在类型级别!)它没有明确请求的任何数据。

因此,修改一个组件的数据依赖项不会影响另一个组件看到的数据,这意味着 **开发人员可以以隔离的方式推理组件**。这使 Relay 应用程序具有无与伦比的稳定性,并使许多错误类型变得不可能,这也是 Relay 可以扩展到许多开发人员接触相同代码库的关键部分。

改进的运行时性能

Relay 还利用编译器将尽可能多的工作转移到构建时间,从而提高 Relay 应用程序的性能。

由于 Relay 编译器具有所有组件数据依赖项的全局知识,因此它能够编写与手动编写的查询一样好——通常甚至更好——的查询。它能够通过以在运行时难以实现的速度优化查询来做到这一点。例如,它会修剪从生成的查询中永远无法访问的分支,并将查询的相同部分扁平化。

由于这些查询是在构建时生成的,因此 Relay 应用程序从不从 GraphQL 片段生成抽象语法树 (AST),操作这些 AST 或在运行时生成查询文本。相反,Relay 编译器使用预先计算的、优化的指令(作为普通 JavaScript 数据结构)替换应用程序的 GraphQL 片段,这些指令描述了如何将网络数据写入存储以及如何读回网络数据。

这种安排的一个额外好处是,Relay 应用程序包既不包含模式,也不包含——在使用持久化查询时——GraphQL 片段的字符串表示形式。这有助于减小应用程序的大小,从而节省用户的带宽并提高应用程序的性能。

事实上,新的编译器更进一步,并以另一种方式节省用户的带宽——Relay 可以将应用程序的服务器告知构建时的每个查询文本,并生成一个唯一的查询 ID,这意味着应用程序永远不需要在用户缓慢的网络上发送可能很长的查询字符串。当使用这种持久化查询时,唯一需要在网络上传送以进行网络请求的是查询 ID 和查询变量!

新的编译器可以实现什么?

与动态语言相比,编译语言有时被认为会引入摩擦并减慢开发人员的速度。但是,Relay 利用编译器来减少摩擦,并使常见的开发人员任务更容易。例如,Relay 公开了用于常见交互的高级原语,这些交互很容易出错,例如分页和使用新变量重新获取查询。

这些交互的共同点是,它们需要从旧查询生成一个新查询,因此涉及样板代码和重复——这是自动化的理想目标。Relay 利用编译器的全局知识,使开发人员能够通过添加一个指令和更改一个函数调用来启用分页和重新获取。仅此而已。

**但是,让开发人员能够轻松添加分页仅仅是冰山一角。**我们对编译器的愿景是,它提供了更多高级工具来发布功能并避免样板代码,为开发人员提供实时帮助和见解,并由可供其他与 GraphQL 配合使用的工具使用的部分组成。

该项目的主要目标是,重写的编译器架构应该让我们能够在未来几年内实现这一愿景。

虽然我们还没有达到目标,但我们在每个标准上都取得了重大进展。

例如,新的编译器附带对新的 @required 指令的支持,该指令将在读取时,如果给定的子字段为 null,则使父级链接字段无效或抛出错误。这听起来可能是一个微不足道的质量改进,但如果你的组件代码有一半是空检查,那么 @required 就会开始看起来很不错!

没有 @required 的组件
以及使用 @required 的组件

接下来,编译器为一个内部专用的 VSCode 扩展提供支持,该扩展在您键入时自动完成字段名称,并在悬停时显示类型信息,以及许多其他功能。我们还没有将其公开发布,但我们希望在某个时候发布!我们的经验是,这个 VSCode 扩展使使用 GraphQL 数据变得更加容易和直观。

最后,新的编译器被编写为一系列独立的模块,可以被其他 GraphQL 工具重用。我们称之为 Relay 编译器平台。在内部,这些模块被用于其他代码生成工具以及不同平台上的其他 GraphQL 客户端。

编译器性能

到目前为止,我们已经讨论了为什么 Relay 有一个编译器,以及我们希望重写能够实现什么。但我们还没有讨论为什么我们决定在 2020 年重写编译器:性能。

在决定重写编译器之前,随着代码库的增长,编译代码库中所有查询所需的时间逐渐但无情地变慢。我们提高性能的能力无法跟上代码库中查询数量的增长,我们也看不到摆脱这种困境的渐进式方法。

到达 JavaScript 的终点

以前的编译器是用 JavaScript 编写的。这是一种自然的选择,原因有几个:它是我们团队最熟悉的一种语言,也是 Relay 运行时使用的语言(允许我们在编译器和运行时之间共享代码),以及 GraphQL 参考实现和我们移动 GraphQL 工具所使用的语言。

编译器的性能在相当长的一段时间内都保持在合理水平:Node/V8 带有一个高度优化的 JIT 编译器和垃圾收集器,如果你小心使用(我们确实很小心)的话,它会非常快。但编译时间却在增长。

我们尝试了许多策略来跟上

  • 我们已经让编译器变得增量式。对于代码变更,它只重新编译受变更影响的依赖项。
  • 我们已经确定了哪些转换速度很慢(主要是 flatten),并做了一些力所能及的算法改进(例如添加记忆化)。
  • 官方 graphql npm 包的 GraphQL 架构表示需要使用多吉字节的内存来表示我们的架构,因此我们用一个自定义的分支替换了它。
  • 我们对最热代码路径进行了分析器引导的微优化。例如,我们停止使用 ... 运算符来克隆和修改对象,而是选择在复制对象时显式列出对象的属性。这保留了对象的隐藏类,并使代码能够更好地进行 JIT 优化。
  • 我们重构了编译器,使其可以分派到多个工作线程,每个工作线程处理一个架构。在 Meta 之外,拥有多个架构的项目并不常见,所以即使这样,大多数用户也只会在使用单线程编译器。

这些优化不足以跟上 Relay 在内部快速采用的步伐。

最大的挑战是 NodeJS 不支持使用共享内存的多线程程序。最好的办法是启动多个工作线程,通过传递消息进行通信。

这在某些情况下运行良好。例如,Jest 采用了这种模式,并在运行测试或转换文件时利用了所有核心。这很适合 Jest,因为它不需要在进程之间共享太多数据或内存。

另一方面,我们的架构太大,无法在内存中拥有多个实例,因此在 JavaScript 中,根本没有有效地使用多个线程(每个架构一个线程)来并行化 Relay 编译器的好方法。

选择 Rust

在决定重写编译器后,我们评估了许多语言,以确定哪种语言能满足我们项目的需要。我们想要一种速度快、内存安全的语言,并支持并发——最好是在构建时而不是运行时捕获并发错误。同时,我们也希望使用一种在内部得到良好支持的语言。这将我们的选择缩小到了几种。

  • C++ 满足了大多数标准,但学习起来很困难。而且,编译器在安全性方面提供的帮助并不像我们希望的那么大。
  • Java 可能也是一个不错的选择。它速度快,支持多核心,但提供的底层控制较少。
  • OCaml 是编译器领域的一种久经考验的选择,但多线程是具有挑战性的。
  • Rust 速度快、内存安全,并支持并发。它可以轻松地在线程之间安全地共享大型数据结构。由于围绕 Rust 的普遍热情、我们团队的一些先前经验以及 Facebook 其他团队的使用情况,这成为了我们明确的首选。

内部推出

Rust 证明是一个非常合适的选择!这个主要由 JavaScript 开发人员组成的团队发现 Rust 很容易上手。而且,Rust 的高级类型系统在构建时捕获了许多错误,帮助我们保持高速度。

我们在 2020 年初开始开发,并在当年年底在内部推出了编译器。最初的内部基准测试表明,编译器的平均性能提高了近 5 倍,P95 性能提高了近 7 倍。从那时起,我们进一步提高了编译器的性能。

在 OSS 中发布

今天,我们很高兴发布新版本的编译器,作为 Relay v13 的一部分。新的编译器功能包括

您可以在 README发行说明 中找到有关编译器的更多信息!

我们正在继续开发编译器中的功能,例如为开发者提供在图上访问派生值的能力,添加对更符合人体工程学语法以更新本地数据的支持,以及完整地完善我们的 VSCode 扩展,我们希望将这些功能发布到开源社区。我们为这次发布感到自豪,但还有更多内容即将推出!

感谢

感谢 Joe Savona、Lauren Tan、Jason Bonta 和 Jordan Eldredge 对这篇博文的精彩反馈。感谢 ch1ffa、robrichard、orta 和 sync 报告了与编译器错误相关的错误。感谢 MaartenStaa 添加了 TypeScript 支持。感谢 @andrewingram 指出启用 @required 指令的难度,现在默认情况下已启用该指令。还有许多其他人做出了贡献——这真是一个社区共同努力的成果!

·阅读时间:6 分钟

我们非常高兴地发布 Relay Hooks,这是迄今为止最适合开发人员的 Relay 版本,以及 今天将其提供给 OSS 社区!Relay Hooks 是一组全新的、经过重新设计的 API,用于使用 React Hooks 获取和管理 GraphQL 数据。

新 API 与现有的基于容器的 API 完全兼容。虽然我们建议使用 Relay Hooks 编写所有新代码,但 *将现有容器迁移到新 API 是可选的,基于容器的代码将继续工作*。

虽然这些 API 是新发布的,但它们并非没有经过测试:重写的 Facebook.com 完全由 Relay Hooks 提供支持,这些 API 自 2019 年年中以来一直是 Facebook 推荐使用 Relay 的方式。

此外,我们还发布了一个重写的 引导游览更新的文档,其中总结了我们在开发 Relay 以来所学到的构建可维护的数据驱动应用程序的最佳实践。

虽然我们离让 Relay 的入门变得像我们希望的那样容易还有很长的路要走,但我们相信这些步骤将使 Relay 的开发体验大大改善。

发布了什么?

我们发布了 Relay Hooks,这是一组基于 React Hooks 的 API,用于处理 GraphQL 数据。我们还利用这个机会发布了其他改进,例如更稳定的 fetchQuery 版本,以及使用 getDataID 自定义 Relay 中的对象标识符的能力(如果您的服务器没有全局唯一的 ID,这很有用)。

有关发布内容的完整列表,请参见 发行说明

Hooks API 的优势是什么?

新发布的 API 至少在以下方面改善了开发体验

  • 用于获取查询、使用片段加载数据、分页、重新获取、变异和订阅的基于 Hooks 的 API 通常比等效的基于容器的解决方案需要更少的代码行,并且间接性更少。
  • 这些 API 具有更完整的 Flow 和 Typescript 覆盖范围。
  • 这些 API 利用编译器功能来自动执行容易出错的任务,例如生成重新获取和分页查询。
  • 这些 API 带有配置获取策略的能力,这使您可以确定应从存储中获取查询以及应发出网络请求的条件。
  • 这些 API 使您能够在组件渲染之前开始获取数据,这是基于容器的解决方案无法实现的。这允许更早地向用户显示数据。

以下示例演示了新 API 的一些优势。

使用不同的变量重新获取片段

首先,让我们看看如何使用 Hooks API 使用不同的变量重新获取片段

type Props = {
comment: CommentBody_comment$key,
};

function CommentBody(props: Props) {
const [data, refetch] = useRefetchableFragment<CommentBodyRefetchQuery, _>(
graphql`
fragment CommentBody_comment on Comment
@refetchable(queryName: "CommentBodyRefetchQuery") {
body(lang: $lang) {
text
}
}
`,
props.comment,
);

return <>
<CommentText text={data?.text} />
<Button
onClick={() =>
refetch({ lang: 'SPANISH' }, { fetchPolicy: 'store-or-network' })
}>
>
Translate
</Button>
</>
}

将此与等效的 基于容器的示例 进行比较。基于 Hooks 的示例需要更少的代码行,不需要开发人员手动编写重新获取查询,并且对重新获取变量进行了类型检查,并明确指出如果可以从存储中的数据获取查询,则不应发出网络请求。

在渲染组件之前开始获取数据

新的 API 允许开发者通过在组件渲染之前开始获取数据来更快地向用户显示内容。使用这种方式预取数据在基于容器的 API 中是不可能的。考虑以下示例

const UserQuery = graphql`
query UserLinkQuery($userId: ID!) {
user(id: $userId) {
user_details_blurb
}
}
`;

function UserLink({ userId, userName }) {
const [queryReference, loadQuery] = useQueryLoader(UserQuery);

const [isPopoverVisible, setIsPopoverVisible] = useState(false);

const maybePrefetchUserData = useCallback(() => {
if (!queryReference) {
// calling loadQuery will cause this component to re-render.
// During that re-render, queryReference will be defined.
loadQuery({ userId });
}
}, [queryReference, loadQuery]);

const showPopover = useCallback(() => {
maybePrefetchUserData();
setIsPopoverVisible(true);
}, [maybePrefetchUserData, setIsPopoverVisible]);

return <>
<Button
onMouseOver={maybePrefetchUserData}
onPress={showPopover}
>
{userName}
</Button>
{isPopoverVisible && queryReference && (
<Popover>
<React.Suspense fallback={<Glimmer />}>
<UserPopoverContent queryRef={queryReference} />
</React.Suspense>
</Popover>
)}
</>
}

function UserPopoverContent({queryRef}) {
// The following call will Suspend if the request for the data is still
// in flight:
const data = usePreloadedQuery(UserQuery, queryRef);
// ...
}

在这个示例中,如果无法从本地缓存中的数据获取查询,则在用户将鼠标悬停在按钮上时会发起网络请求。因此,当最终按下按钮时,用户将看到内容的时间会更早。

相比之下,基于容器的 API 在组件渲染时会发起网络请求。

用于数据获取的 Hooks 和 Suspense

您可能已经注意到,这两个示例都使用了 Suspense。

虽然 Relay Hooks 在部分 API 中使用了 Suspense,但对 Suspense 在 React 中用于数据获取的支持、一般性指导和使用要求尚未准备好,React 团队仍在定义未来版本中将会是什么样的指导。在 React 17 中使用 Suspense 时存在一些限制。

尽管如此,我们现在发布了 Relay Hooks,因为我们知道这些 API 正朝着支持 React 未来版本的方向发展。即使 Relay 的 Suspense 实现的某些部分可能还会发生变化,Relay Hooks API 本身是稳定的;它们已经在内部被广泛采用,并且已经在生产环境中使用了一年以上。

请参阅Suspense 兼容性带有 Suspense 的加载状态,以了解有关此主题的更深入的介绍。

下一步去哪里

请查看入门指南迁移指南引导教程

感谢

发布 Relay Hooks 并非 React 数据团队的单方面努力。我们要感谢所有帮助实现这一目标的贡献者。

@0xflotus, @AbdouMoumen, @ahmadrasyidsalim, @alexdunne, @alloy, @andrehsu, @andrewkfiedler, @anikethsaha, @babangsund, @bart88, @bbenoist, @bigfootjon, @bondz, @BorisTB, @captbaritone, @cgriego, @chaytanyasinha, @ckknight, @clucasalcantara, @damassi, @Daniel15, @daniloab, @earvinLi, @EgorShum, @eliperkins, @enisdenjo, @etcinit, @fabriziocucci, @HeroicHitesh, @jaburx, @jamesgeorge007, @janicduplessis, @jaroslav-kubicek, @jaycenhorton, @jaylattice, @JonathanUsername, @jopara94, @jquense, @juffalow, @kafinsalim, @kyarik, @larsonjj, @leoasis, @leonardodino, @levibuzolic, @liamross, @lilianammmatos, @luansantosti, @MaartenStaa, @MahdiAbdi, @MajorBreakfast, @maraisr, @mariusschulz, @martinbooth, @merrywhether, @milosa, @mjm, @morrys, @morwalz, @mrtnzlml, @n1ru4l, @Nilomiranda, @omerzach, @orta, @pauloedurezende, @RDIL, @RicCu, @robrichard, @rsmelo92, @SeshanPillay25, @sibelius, @SiddharthSham, @stefanprobst, @sugarshin, @taion, @thedanielforum, @theill, @thicodes, @tmus, @TrySound, @VinceOPS, @visshaljagtap, @Vrq, @w01fgang, @wincent, @wongmjane, @wyattanderson, @xamgore, @yangshun, @ymittal, @zeyap, @zpao 和 @zth。

开源项目relay-hooks 允许社区尝试 Relay 和 React Hooks,并为我们提供了宝贵的反馈。useSubscription 钩子的想法源于该仓库中的一个问题。感谢 @morrys 推动了这个项目,并在我们开源社区中扮演如此重要的角色。

感谢您帮助实现这一切!