这是一篇由 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
LOG
和 NONE
操作具有相同的运行时行为,但 LOG
会向您选择的日志记录机制发送一条消息,记录返回为 null 的字段的完整路径。对于大多数需要使用 @required
指令的用例,应使用 LOG
而不是 NONE
。只有在某个字段预期对某些用户为 null 时,才应优先使用 NONE
。
虽然使用 action: LOG
创建的日志条目本身可能没有操作性,但它可以作为未来错误的线索,提供一个有用的信号。能够查看错误的历史记录并看到某个特定字段意外地为 null,可以帮助追踪用户在工作流程中可能遇到的未来错误。
何时使用 @required(action:LOG/NONE)
LOG/NONE
操作应仅用于必须在组件中显示可选 UI 的字段。在设计应用程序时,这会显示出两种不同的用例。
- 您的组件是可选 UI,如果一个或一组字段为 null,则根本不应该渲染。
- 您的组件的一部分是可选 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.total
或 supply.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 (asset === null) {
return null;
}
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.name
或 asset.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 {
timestamp
price @required(action: THROW)
}
}
`,
assetPriceRef,
);
}
此组件同时选择了 quotes
数组以及该数组中每个项目上的 timestamp
和 price
字段。如果我们想在没有返回任何报价时向用户显示错误,那么在 quotes
字段上放置 THROW
是可以接受的。但是,在 price
字段上放置 THROW
将会导致如果该数组中只有一个 price 字段为 null,就会向用户显示错误。这可能不是我们想要的行为。如果我们正确地获得了过去一天 24 个报价中的 23 个,那么我们可能仍然应该显示我们拥有的结果,只是省略空值。
相反,我们应该使用 action: LOG/NONE
,这样我们只会使数组中的单个项目无效,而不是所有项目。然后,我们可以根据需要选择性地过滤掉数组中的空值。
function Component({ assetPriceRef }) {
const { quotes } = useFragment(
graphql`
fragment ComponentFragment on AssetPriceData {
quotes {
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)
buyPercent
}`,
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)
hasOfferToStake
}
`,
assetRef,
);
const showStakeOffer = asset.hasOfferToStake ?? false;
return (
<div>
{asset.canTrade && <Button>Trade</Button>}
{showStakeOffer && <Button>Stake your currency</Button>}
</div>
);
}
如果你从本文中获得了任何启发,希望是,你需要认真考虑如何处理停机和服务中断。处理故障状态是构建世界一流应用程序的重要组成部分。在确定新功能范围时,确保你的设计和 PM 团队与你的团队保持一致。如果他们没有给你关于数据丢失时向用户显示什么的建议,请进行回推,以便团队就这些决定达成一致。
Relay 可以成为处理应用程序故障的有力工具。它可以帮助你细致地决定如何处理故障,这可能比你习惯的要多一些工作。然而,这些额外的努力最终会得到回报,并极大地改善用户对你应用程序的体验。