React Suspense:加载数据的经验教训

Avatar of Adam Rackis
Adam Rackis

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

Suspense 是 React 的一项即将推出的功能,它有助于协调异步操作(例如数据加载),使您可以轻松防止 UI 中出现不一致的状态。我将对这到底意味着什么进行更好的解释,并快速介绍 Suspense,然后介绍一个比较现实的用例,并涵盖一些学到的经验教训。

我介绍的功能仍处于 Alpha 阶段,绝不应在生产环境中使用。这篇文章适用于希望抢先了解即将推出的功能并了解未来发展方向的人员。

Suspense 简介

应用程序开发中最具挑战性的部分之一是协调应用程序状态和数据加载方式。状态更改通常会在多个位置触发新的数据加载。通常,每部分数据都会有自己的加载 UI(例如“加载中”),大致位于应用程序中该数据所在的位置。数据加载的异步特性意味着这些请求中的每一个都可以以任何顺序返回。结果,您的应用程序不仅会出现许多不同的加载动画不断弹出和消失,而且更糟糕的是,您的应用程序可能会显示不一致的数据。如果您的三个数据加载中有两个已完成,则第三个位置上将显示一个加载动画,并且仍然显示旧的、现在已过时的数据。

我知道这有点多。如果您发现其中任何内容令人费解,您可能对 我之前关于 Suspense 的文章 感兴趣。该文章更详细地介绍了 Suspense 是什么以及它实现了什么。请注意,其中一些小部分现在已过时,即,useTransition 钩子不再接受 timeoutMs 值,而是无限期等待。

现在让我们快速浏览一下详细信息,然后进入一个具体的用例,其中有一些隐藏的陷阱。

Suspense 如何工作?

幸运的是,React 团队足够聪明,没有将这些努力仅限于加载数据。Suspense 通过低级基元工作,您可以将这些基元应用于几乎任何事物。让我们快速浏览一下这些基元。

首先是 <Suspense> 边界,它接受一个 fallback 属性

<Suspense fallback={<Fallback />}>

每当此组件下的任何子组件挂起时,它都会渲染回退内容。无论挂起的子组件有多少,无论出于何种原因,显示的都是回退内容。这是 React 确保 UI 一致的一种方式——在所有内容准备就绪之前,它不会渲染任何内容。

但是,在初始渲染后,用户更改状态并加载新数据会怎样?我们当然不希望现有的 UI 消失并显示我们的回退内容;这将是糟糕的用户体验。相反,我们可能希望显示一个加载动画,直到所有数据准备就绪,然后才显示新的 UI。

useTransition 钩子实现了这一点。此钩子返回一个函数和一个布尔值。我们调用该函数并包装我们的状态更改。现在事情变得有趣起来。React 尝试应用我们的状态更改。如果任何内容挂起,React 会将该布尔值设置为 true,然后等待挂起结束。完成后,它将尝试再次应用状态更改。也许这次它会成功,或者也许其他内容会挂起。无论哪种情况,布尔标志都会保持 true,直到所有内容都准备就绪,然后且仅当所有内容都准备就绪时,状态更改才会完成并反映在 UI 中。

最后,我们如何挂起?我们通过抛出一个 Promise 来挂起。如果请求数据并且我们需要获取,那么我们获取——并抛出一个与该获取相关的 Promise。由于挂起机制处于如此低级别,因此我们可以将其与任何内容一起使用。用于延迟加载组件的 React.lazy 实用程序已与 Suspense 协同工作,并且 我之前写过关于使用 Suspense 等待图像加载完成后再显示 UI 以防止内容发生偏移的文章。

别担心,我们会详细介绍所有这些。

我们要构建什么

我们将构建一些与许多其他类似文章中的示例略有不同的内容。请记住,Suspense 仍处于 Alpha 阶段,因此您最喜欢的的数据加载实用程序可能尚未支持 Suspense。但这并不意味着我们不能模拟一些事情并了解 Suspense 的工作原理。

让我们构建一个无限加载列表,该列表显示一些数据,并结合一些基于 Suspense 的预加载图像。我们将显示我们的数据,以及一个加载更多数据的按钮。随着数据的呈现,我们将预加载关联的图像,并在准备好之前挂起。

此用例基于我在我的副项目中所做的实际工作(再次强调,不要在生产环境中使用 Suspense——但副项目是公平的游戏)。我当时使用的是 我自己的 GraphQL 客户端,这篇文章的动机是我遇到的一些困难。我们将模拟数据加载以保持简单,并将重点放在 Suspense 本身,而不是任何特定的数据加载实用程序。

让我们构建!

这是我们初始尝试的沙箱。我们将使用它来逐步完成所有操作,因此现在不必担心理解所有代码。

我们的根 App 组件如下渲染 Suspense 边界

<Suspense fallback={<Fallback />}>

每当任何内容挂起时(除非状态更改发生在 useTransition 调用中),回退内容都会渲染。为了使事情更容易理解,我将此 Fallback 组件将整个 UI 变为粉红色,这样就不会错过它;我们的目标是理解 Suspense,而不是构建高质量的 UI。

我们正在 DataList 组件内部加载当前数据块

const newData = useQuery(param);

我们的 useQuery 钩子被硬编码为返回伪数据,包括模拟网络请求的超时。它处理缓存结果,如果数据尚未缓存,则抛出一个 Promise。

我们正在(至少目前)在我们要显示的数据的主列表中保留状态

const [data, setData] = useState([]);

随着新数据从我们的钩子中传入,我们将它附加到我们的主列表中

useEffect(() => {
  setData((d) => d.concat(newData));
}, [newData]);

最后,当用户需要更多数据时,他们单击按钮,该按钮会调用以下内容

function loadMore() {
  startTransition(() => {
    setParam((x) => x + 1);
  });
}

最后,请注意,我正在使用 SuspenseImg 组件来处理我要与每部分数据一起显示的图像的预加载。仅显示五张随机图像,但我添加了一个查询字符串以确保对我们遇到的每部分新数据进行新的加载。

总结

为了总结我们目前所处的位置,我们有一个加载当前数据的钩子。该钩子遵循 Suspense 机制,并在加载过程中抛出一个 Promise。每当数据更改时,正在运行的项目总数列表都会更新并附加新项目。这发生在 useEffect 中。每个项目都会渲染一个图像,我们使用 SuspenseImg 组件来预加载图像,并在准备好之前挂起。如果您好奇其中一些代码是如何工作的,请查看 我之前关于使用 Suspense 预加载图像的文章。

让我们测试

如果一切正常,这篇文章将会非常无聊,别担心,事实并非如此。请注意,在初始加载时,粉红色的回退屏幕会显示,然后快速隐藏,但随后会重新显示。

当我们单击加载更多数据的按钮时,我们会看到内联加载指示器(由 useTransition 钩子控制)翻转到 true。然后我们看到它翻转到 false,然后我们的原始粉红色回退屏幕显示。我们期望在初始加载后不再看到那个粉红色屏幕;内联加载指示器应该在所有内容都准备就绪之前显示。到底发生了什么?

问题

它一直隐藏在显眼的位置

useEffect(() => {
  setData((d) => d.concat(newData));
}, [newData]);

useEffect 在状态更改完成后运行,即状态更改已完成挂起,并已应用到 DOM。其中“已完成挂起”这部分是关键。我们可以在此处设置状态,但如果该状态更改挂起,那么这将是一个全新的挂起。这就是我们在初始加载时看到粉色闪光,以及数据加载完成后后续加载时看到粉色闪光的原因。在这两种情况下,数据加载都已完成,然后我们在一个 effect 中设置了状态,导致这些新数据实际渲染,并因图像预加载而再次挂起。

那么,我们如何解决这个问题呢?从一个层面上来说,解决方案很简单:停止在 effect 中设置状态。但这说起来容易做起来难。如何在不使用 effect 的情况下更新我们正在运行的条目列表以追加新结果?你可能会认为我们可以使用 ref 来跟踪这些东西。

不幸的是,Suspense 带来了一些关于 ref 的新规则,即我们不能在渲染内部设置 ref。如果你想知道为什么,请记住 Suspense 完全是关于 React 尝试运行渲染,看到 promise 被抛出,然后在渲染中途丢弃该渲染。如果我们在渲染被取消和丢弃之前修改了一个 ref,那么 ref 仍然会保留那个已更改但无效的值。渲染函数需要是纯的,没有副作用。这在 React 中一直是一条规则,但现在它变得更加重要了。

重新思考我们的数据加载

解决方案在此,我们将逐段讲解。

首先,与其将我们的主数据列表存储在状态中,不如做些不同的事情:让我们存储一个我们正在查看的页面列表。我们可以将最近的页面存储在 ref 中(尽管我们不会在渲染中写入它),并将所有当前加载的页面存储在一个数组中。

const currentPage = useRef(0);
const [pages, setPages] = useState([currentPage.current]);

为了加载更多数据,我们将相应地更新

function loadMore() {
  startTransition(() => {
    currentPage.current = currentPage.current + 1;
    setPages((pages) => pages.concat(currentPage.current));
  });
}

然而,棘手的是将这些页码转换为实际数据。我们绝对**不能**做的是循环遍历这些页面并调用我们的 useQuery hook;hook 不能在循环中调用。我们需要一个新的、基于非 hook 的数据 API。根据我在过去的 Suspense 演示中看到的非常非官方的约定,我将此方法命名为 read()。它不会是一个 hook。如果数据被缓存,它将返回请求的数据,否则将抛出一个 promise。对于我们的假数据加载 hook,没有必要进行任何更改;我只是简单地复制粘贴了 hook,然后重命名它。但对于实际的数据加载实用程序库,作者可能需要做一些工作才能将这两个选项都作为其公共 API 的一部分公开。在我的之前提到的GraphQL 客户端中,确实同时存在 useSuspenseQuery hook 和客户端对象上的 read() 方法。

有了这个新的 read() 方法,我们代码的最后部分就变得微不足道了

const data = pages.flatMap((page) => read(page));

我们获取每个页面,并使用我们的 read() 方法请求相应的数据。如果任何页面未被缓存(实际上应该只有列表中的最后一个页面),则会抛出一个 promise,React 会为我们挂起。当 promise 解析时,React 会再次尝试先前的状态更改,并且这段代码会再次运行。

不要让 flatMap 调用让你感到困惑。它与 map 做完全相同的事情,只是它获取新数组中的每个结果,如果它本身是一个数组,则将其“展平”。

结果

有了这些更改,一切都能按照我们开始时的预期工作。我们的粉色加载屏幕在初始加载时显示一次,然后在后续加载时,内联加载状态显示直到一切准备就绪。

结语

Suspense 是一个即将推出的令人兴奋的 React 更新。它仍处于 alpha 阶段,因此不要尝试在任何重要的地方使用它。但如果你是一种喜欢抢先体验即将发布的功能的开发者,那么我希望这篇文章能为你提供一些有用的背景信息和信息,以便在它发布时使用。