首页 / 文章

为加载、空状态、错误状态及重试状态设计 React 页面

学会为 React 页面和表单的所有可能状态建模,涵盖骨架屏、重新获取数据、空结果、有用的错误提示以及防止重复提交等功能。

发布者:REACTAPP.TOP阅读约 6 分钟1319 词

来源: Your React App Works. Until the API Doesn’t.

在本地数据库测试环境中表现完美的界面,在实际生产环境中却可能出现问题,因为用户会面临网络延迟、空表以及接口故障等问题。本指南将详细介绍数据驱动的 React 界面及其表单实际会经历的各种状态,并在编写任何 JSX 代码之前提供相应的设计检查清单。

大多数脆弱界面背后的思维模式

在最初设计组件时,其流程往往看起来像一条直线:请求数据,然后渲染结果。

Fetch data → Show data

这种设想没有为故障留出空间。实际生产环境中的界面流程是分支式的:它开始加载数据,可能得到成功结果或空结果,也有可能出现错误,因此需要通过重试或重新获取数据的方式来恢复。

Loading → Success → Empty
             ↓
           Error
             ↓
        Retry / Refetch

将加载状态视为需要独立设计的真实状态

“显示旋转加载图标”听起来就像“用try/catch包裹代码”一样是个完整的解决方案:这并没有错,但很少能反映用户的实际体验。两个问题就能暴露出这一差距——当响应需要四秒时,屏幕会是什么样子?而当响应在40毫秒内到达时又会发生什么:用户是否会看到旋转图标闪烁一帧,从而觉得界面有卡顿现象?

初始加载与重新获取数据

将加载过程分为两种不同情况会更有帮助:

  • 初始加载。此时屏幕上还没有任何内容,因此使用与最终布局相似的骨架结构是个合理且不错的选择。
  • 重新获取数据。例如用户在点击刷新或更改筛选条件后,相关数据已经显示在屏幕上。此时如果清除现有内容并替换为全屏加载界面,就会丢失有用的上下文信息。

在重新获取数据时,应保持先前数据的显示,并在正在更新的内容附近展示一个小型内联指示器。旋转加载图标无法给用户带来安心感,充其量只能被勉强接受。对于响应速度极快的情况,可考虑稍作延迟再显示指示器,使其仅在等待时间较长时出现。

为何成功的获取请求仅是整个工作的一小部分

请求本身其实是最简单的部分:

const res = await fetch("/api/students");
const data = await res.json();
setStudents(data);

这段代码并未解答与之相关的所有问题。当 Promise 处于待处理状态时,用户会看到什么?如果 fetch 被拒绝,或者服务器返回非 2xx 状态码(而 fetch 自身并不将其视为错误),又会怎样?如果返回的数据是一个空数组呢?关键问题不在于如何获取数据,而在于该请求在任何时刻可能处于何种状态。

如果students是唯一的状态数据,那么UI的其他部分就必须通过该变量是否为null、为空还是已填充来推断当前状况。null可能表示“仍在加载”或“请求失败”,但无法区分这两种情况。这其实只是披着状态管理外衣的猜测罢了。

空结果属于成功,而非错误

这一点值得单独强调,因为很多代码库都会在理解上出现细微偏差。200 OK响应中返回[],说明服务器已正常工作:它只是在告知没有内容可显示。

“未找到学生”与“无法加载学生”描述的是完全不同的情况,但许多应用对这两种情况都使用相同的灰色占位符。统一使用 <p>出错了</p> 的方式会将本需不同处理的两种状况混为一谈:空状态或许应提示用户清除筛选条件,而错误状态则应说明故障原因并提供恢复方法。

有效的错误状态应具备的特点

通用的“出错了”提示对用户毫无帮助。更好的错误状态应:

  • 告知用户可采取的行动,比如检查网络连接或稍后重试;
  • 在重试有实际成功可能时提供重试按钮;
  • 绝不在用户界面中显示原始的堆栈跟踪信息或内部错误数据;
  • 仅显示出现故障的区域。

最后一项是最常被忽略的。在包含五个小部件的控制面板中,一个请求失败不应导致另外四个成功加载的内容消失。应通过本地状态或独立区域周围的错误边界,为每个部分实现范围内的错误处理。

表单拥有自身的生命周期

写入数据的状态甚至比读取数据更多:

Idle → Editing → Submitting → Success / Error

防止重复提交

假设有一名用户因为前两次点击按钮后没有看到任何反馈,于是又点击了三次“提交”按钮。除非在提交过程中该按钮被禁用,或者处理程序会检查isSubmitting标志并在已有请求正在处理时立即返回,否则服务器会收到三个请求。对于订单表单而言,这意味着同一件T恤会被向该客户收取三次费用。在处理支付时,后端也应通过诸如幂等键等方式来消除重复请求。

提交失败时保留用户输入

当请求失败时,应保持表单中的内容不变。如果在网络故障后要求用户重新输入长文本,只会浪费他们的时间。无论是来自客户端规则还是服务器的验证错误,都应显示在对应的字段旁边,而非出现在表单顶部的模糊提示栏中。

用命名状态替代分散的布尔值

如果不加以约束,组件往往会积累类似这样的标志位:

isLoading, isError, isSubmitting, hasData, isEmpty

每个标志位单独来看都是合理的。但五个布尔值组合起来共有32种可能,其中只有少数具有实际意义,而且没有任何机制能防止isLoading && isError同时为真。

学生列表页面无需使用正式的状态机库。它需要的是在首次调用 useState 之前就明确决定屏幕可以处于哪些不同的状态。用一个值来表示当前状态,比如 status 字段,其值为 "loading"、"success"、"empty" 或 "error",然后根据该值来决定渲染内容,而非通过多个独立的标志位来重新构建状态。在 TypeScript 中,区分联合类型可以避免表示不可能出现的组合;关于 useState、useReducer 以及自定义钩子的类型设计这篇文章更深入地探讨了相关类型方面的问题。

构建下一个页面前的检查清单

在编写组件的 return 语句之前,请先回答以下问题:

  1. 在数据到达之前会显示什么内容?
  2. 当请求失败时会发生什么?
  3. 当请求成功但未返回任何内容时会发生什么?
  4. 在刷新现有数据时用户会看到什么?
  5. 在表单提交过程中会发生什么?
  6. 如果表单被提交两次会怎样?
  7. 当验证失败时会发生什么?

总结

演示版本与实际生产界面之间的差距往往不在正常流程中,而在于真实用户和网络环境介入后出现的加载、空状态、错误提示、数据重新获取以及表单提交等状态。应明确命名这些状态,有针对性地设计每个状态,将故障限制在局部范围,并保护用户输入及服务器处理结果免受重复提交或失败提交的影响。