如何构建有弹性的JavaScript UIs的教程

114 阅读11分钟

网络上的东西可能会出错--机会对我们来说是堆积的。很多事情都会出错:网络请求失败,第三方库损坏,JavaScript功能不被支持(假设JavaScript是可用的),CDN瘫痪,用户的行为出乎意料(他们双击提交按钮),这样的例子还有很多。

幸运的是,作为工程师,我们可以避免,或者至少可以减轻我们所构建的网络应用中的故障的影响。然而,这需要有意识的努力和思维方式的转变,要像考虑快乐的情况一样考虑不快乐的情况。

用户体验(UX)不需要是全部或没有--只要是可用的。这个前提,也就是所谓的优雅退化,允许系统在部分功能失调时继续工作--就像电动自行车在电池耗尽时变成普通自行车一样。如果某样东西发生故障,只有依赖于它的功能会受到影响。

UIs应该适应它们所能提供的功能,同时为终端用户提供尽可能多的价值。

为什么要有弹性

弹性是网络的固有特性。

浏览器会忽略无效的HTML标签和不支持的CSS属性。这种宽松的态度被称为Postel定律,Jeremy Keith在《弹性网页设计》一书中对此有很好的表述。

"即使HTML或CSS中存在错误,浏览器仍然会尝试处理信息,跳过任何它无法解析的部分。"

JavaScript是不太宽容的。弹性是外在的。我们指示JavaScript在发生意外情况时该怎么做。如果一个API请求失败了,我们就有责任抓住这个错误,并随后决定如何处理。而这个决定会直接影响到用户。

弹性建立了对用户的信任。一个有问题的体验会对品牌产生不好的影响。根据Kim和Mauborgne的说法,便利性(可用性、易消费性)是与成功品牌相关的六个特征之一,这使得优雅的退化成为品牌认知的同义词。

稳健可靠的用户体验是质量和可信度的信号,这两者都反馈到品牌中。一个用户因为某些东西坏了而无法执行任务,自然会面临他们可能与你的品牌联系起来的失望。

通常,系统故障被归结为 "角落里的案例"--很少发生的事情,然而,网络有很多角落。不同的浏览器在不同的平台和硬件上运行,尊重我们的用户偏好和浏览模式(Safari阅读器/辅助技术),以不同的延迟和间歇性提供给地理区域,增加了一些不按预期工作的可能性。

错误平等性

就像网页上的内容有等级之分一样,故障--出错的事情--也遵循着一个等级顺序。不是所有的错误都是一样的,有些比其他的更重要。

我们可以根据错误的影响对其进行分类。XYZ不工作是如何阻止用户实现其目标的?答案一般反映了内容的层次性。

例如,你的银行账户的仪表板概述包含不同重要性的数据。你的余额总值比提示你检查应用内消息的通知更重要。MoSCoWs的优先排序方法将前者归类为必须的,而后者则是不错的。

如果主要信息不可用(即:网络请求失败),我们应该是透明的,让用户知道,通常是通过一个错误信息。如果次要信息不可用,我们仍然可以提供核心(必须有)的体验,同时优雅地隐藏退化的组件。

知道何时显示错误信息或不显示错误信息可以用一个简单的决策树来表示。

分类消除了用户界面中故障和错误信息之间的1-1关系。否则,我们有可能用太多的错误信息来轰炸用户并使用户界面变得杂乱无章。在内容层次的指导下,我们可以挑选出哪些故障会浮现在用户界面上,哪些是发生在终端用户不知情的情况下。

预防胜于治疗

医学上有一句格言:预防胜于治疗。

在构建有弹性的用户界面的背景下,首先防止错误的发生要比需要从错误中恢复更可取。最好的错误类型是不发生的错误。

安全的做法是永远不要做假设,特别是在消费远程数据、与第三方库互动或使用较新的语言功能时。停电或计划外的API变化以及用户选择或必须使用的浏览器都是我们无法控制的。虽然我们不能阻止不在我们控制范围内的故障发生,但我们可以保护自己免受其(副作用)影响。

在编写代码时采取更多的防御性方法,有助于减少因假设而产生的程序员错误。悲观主义比乐观主义更有利于复原力。下面的代码例子太乐观了。

const debitCards = useDebitCards();

return (
  <ul>
    {debitCards.map(card => {
      <li>{card.lastFourDigits}</li>
    })}
  </ul>
);

它假设借记卡存在,终端返回一个数组,数组包含对象,每个对象有一个名为lastFourDigits 的属性。目前的实现迫使终端用户测试我们的假设。如果这些假设被嵌入到代码中,会更安全,也更方便用户使用。

const debitCards = useDebitCards();

if (Array.isArray(debitCards) && debitCards.length) {
  return (
    <ul>
      {debitCards.map(card => {
        if (card.lastFourDigits) {
          return <li>{card.lastFourDigits}</li>
        }
      })}
    </ul>
  );
}

return "Something else";

使用第三方方法而不首先检查该方法是否可用,同样是很乐观的。

stripe.handleCardPayment(/* ... */);

上面的代码片段假设stripe 对象存在,它有一个名为handleCardPayment 的属性,并且该属性是一个函数。如果我们事先对这些假设进行验证,会更安全,因此也更有防御性。

if (
  typeof stripe === 'object' && 
  typeof stripe.handleCardPayment === 'function'
) {
  stripe.handleCardPayment(/* ... */);
}

这两个例子都是在使用某个东西之前检查它是否可用。熟悉特征检测的人可能会认识到这种模式。

if (navigator.clipboard) {
  /* ... */
}

在尝试剪切、复制或粘贴之前,简单地询问浏览器是否支持剪贴板API是一个简单而有效的弹性例子。UI可以提前适应,对不支持的浏览器或尚未授予权限的用户隐藏剪贴板功能。

用户的浏览习惯是另一个我们无法控制的领域。虽然我们不能决定我们的应用程序是如何被使用的,但我们可以灌输护栏以防止我们认为的 "滥用"。有些人双击按钮--这种行为在网络上大多是多余的,但并不是一种应受惩罚的罪行。

双击一个提交表单的按钮,不应该提交两次表单,尤其是对于非空闲的HTTP方法。在表单提交过程中,要防止后续的提交,以减轻多次请求的后果。

在JavaScript中防止表单重新提交,同时使用aria-disabled="true" ,比disabled HTML属性更可用、更方便。Sandrina Pereira详细解释了 "让残疾按钮更有包容性"。

对错误的回应

不是所有的错误都可以通过防御性编程来预防。这意味着对操作错误(那些发生在正确编写的程序中的错误)的回应就落在我们身上。

对错误的反应可以用决策树来模拟。我们可以恢复、回退或承认错误。

当面对一个错误时,第一个问题应该是,"我们能恢复吗?"例如,重新尝试第一次失败的网络请求,在随后的尝试中是否会成功?断断续续的微服务、不稳定的互联网连接或最终的一致性都是再试的理由。像SWR这样的数据获取库免费提供这种功能。

风险偏好和周围的环境会影响你对哪些HTTP方法进行重试。在Nutmeg,我们重试失败的读取(GET请求),但不重试写入(POST/ PUT/ PATCH/ DELETE)。多次尝试检索数据(组合性能)比突变数据(重新提交表单)更安全。

第二个问题应该是。如果我们不能恢复,我们能否提供一个后备方案?例如,如果在线卡支付失败,我们是否可以提供另一种支付方式,如通过贝宝或开放银行。

回退并不总是需要如此精细,它们可以是微妙的。当请求失败时,包含依赖于远程数据的文本的副本可以回落到不太具体的文本。

第三个也是最后一个问题应该是。如果我们不能恢复,或回退,这个失败有多重要(这与 "错误平等 "有关)。UI应该通过告知用户出了问题来承认主要的错误,同时提供可操作的提示,如联系客户支持或链接到相关的支持文章。

可观察性

用户界面适应出错的情况并不是终点。同样的硬币也有另一面。

工程师需要了解体验下降背后的根本原因。即使是没有浮现在终端用户面前的错误(次要错误)也必须传播给工程师。实时错误监控服务,如SentryRollbar,是现代网络开发的宝贵工具。

大多数错误监控供应商会自动捕获所有未处理的异常。设置只需要最小的工程努力,但很快就能为改善健康的生产环境和MTTA(平均确认时间)带来回报。

真正的力量来自于我们自己明确地记录错误。虽然这涉及到更多的前期工作,但它允许我们用更多的意义和背景来充实记录的错误--这两者都有助于故障排除。在可能的情况下,我们的目标是让团队中的非技术成员也能理解错误信息。

在前面的Stripe例子中,扩展一个其他分支是明确的错误记录的完美竞争者。

if (
  typeof stripe === "object" &&
  typeof stripe.handleCardPayment === "function"
) {
  stripe.handleCardPayment(/* ... */);
} else {
  logger.capture(
    "[Payment] Card charge — Unable to fulfill card payment because stripe.handleCardPayment was unavailable"
  );
}

注意这种防御性的风格不需要绑定到表单提交(在错误发生时),它可以发生在一个组件第一次安装时(在错误发生前),给我们和UI更多的时间来适应。

可观察性有助于找出代码中的弱点和可以加固的地方。一旦一个弱点浮出水面,看看是否/如何能够加强它,以防止同样的事情再次发生。观察趋势和风险领域,如第三方集成,以确定什么可以被包裹在一个操作功能标志中(也称为杀戮开关)。

预先知道某些东西不工作的用户会比没有警告的用户更少沮丧。提前了解道路工程有助于管理预期,使司机能够计划替代路线。在处理故障时(希望是通过监控发现,而不是由用户报告),要透明。

回顧

掩盖错误是非常诱人的。

然而,它们为我们和我们现在或未来的同事提供了宝贵的学习机会。从不可避免的出错中消除耻辱感是至关重要的。在黑箱思维中,这被描述为。

"在高度复杂的组织中,只有当我们正视自己的错误,从自己的黑匣子中学习,并创造一个可以安全地失败的氛围时,才能取得成功"。

善于分析有助于防止或减轻同样的错误再次发生。就像航空业的黑匣子记录事件一样,我们应该记录错误。如果同样的错误再次发生,至少先前事件的记录有助于减少MTTR(平均修复时间)。

通常以RCA(根本原因分析)报告的形式出现的文件应该是诚实的、可发现的,并包括:问题是什么、它的影响、技术细节、如何修复、以及事件发生后应采取的行动。

结束语

接受网络的脆弱性是建立弹性系统的一个必要步骤。更可靠的用户体验是快乐客户的同义词。从企业、客户和开发者的角度来看,为最坏的情况做好准备(主动)比救火(被动)更好(更少的bug!)。

需要记住的事情:

  • UIs应该适应他们所能提供的功能,同时仍然为用户提供价值。
  • 始终思考可能出现的问题(永远不要做假设)。
  • 根据错误的影响对其进行分类(不是所有的错误都一样)。
  • 预防错误比应对错误要好(编写防御性的代码)。
  • 当面对一个错误时,询问是否有恢复或回退的方法。
  • 用户面临的错误信息应该提供可操作的提示。
  • 工程师必须对错误有可见性(使用错误监控服务)。
  • 工程师/同事的错误信息应该是有意义的,并提供背景。
  • 从错误中学习,以帮助我们未来的自己和他人。