使用 React Suspense 预缓存图像

Avatar of Adam Rackis
Adam Rackis 发布

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

Suspense 是 React 中一项令人兴奋的即将推出的功能,它将使开发人员能够轻松地允许其组件延迟渲染,直到它们“准备好”,从而带来更加流畅的用户体验。“准备好”在此上下文中可以指许多事情。例如,您的数据加载实用程序可以与 Suspense 关联,允许在任何数据传输过程中显示一致的加载状态,而无需手动跟踪每个查询的加载状态。然后,当您的数据可用并且您的组件“准备好”时,它将进行渲染。这是与 Suspense 最常讨论的主题,并且我之前写过这方面的内容;但是,数据加载只是 Suspense 可以改善用户体验的众多用例之一。我今天想谈论的另一个用例是图像预加载。

您是否曾经制作过或使用过一个 Web 应用,在该应用中,在访问某个屏幕后,您的位置会随着图像下载和渲染而发生抖动和跳跃?我们称之为内容重排,它既令人不适又令人反感。Suspense 可以帮助解决这个问题。您知道我说过 Suspense 就是关于阻止组件渲染直到它准备好?幸运的是,“准备好”在此上下文中非常开放——出于我们的目的,可以包含“我们需要的已预加载的图像”。让我们看看如何实现吧!

Suspense 快速入门

在深入探讨细节之前,让我们快速了解一下 Suspense 的工作原理。它主要有两个部分。第一个是组件挂起的概念。这意味着 React 尝试渲染我们的组件,但它尚未“准备好”。发生这种情况时,组件树中最近的“回退”将进行渲染。我们很快就会了解如何创建回退(这非常简单),但是组件通知 React 它尚未准备好的方式是抛出一个 Promise。React 将捕获该 Promise,意识到组件尚未准备好,并渲染回退。当 Promise 解析时,React 将再次尝试渲染。反复进行。是的,我稍微简化了一些内容,但这就是 Suspense 工作原理的要点,我们将在以后扩展其中的一些概念。

Suspense 的第二个部分是引入了“过渡”状态更新。这意味着我们设置状态,但告诉 React 状态更改可能会导致组件挂起,如果发生这种情况,则不要渲染回退。相反,我们希望继续查看当前屏幕,直到状态更新准备好,此时它将进行渲染。当然,React 为我们提供了一个“pending”布尔指示器,让开发人员知道此过程正在进行中,以便我们可以提供内联加载反馈。

让我们预加载一些图像!

首先,我想指出本文末尾有一个我们正在制作的完整演示。如果您只想跳入代码,可以随时打开演示。它将展示如何结合过渡状态更新使用 Suspense 预加载图像。本文的其余部分将逐步构建该代码,并解释其背后的原理。

好的,让我们开始吧!

我们希望我们的组件挂起,直到所有图像都已预加载。为了使事情尽可能简单,让我们创建一个<SuspenseImage>组件,该组件接收src属性,预加载图像,处理异常抛出,然后在一切准备就绪后渲染<img>。这样的组件将允许我们无缝地放置<SuspenseImage>组件,以在我们需要显示图像的位置显示图像,而 Suspense 将处理将其保留到一切准备就绪的繁重工作。

我们可以从创建代码的初步草图开始

const SuspenseImg = ({ src, ...rest }) => {
  // todo: preload and throw somehow
  return <img alt="" src={src} {...rest} />;
}; 

因此,我们需要解决两个问题:(1)如何预加载图像,以及(2)如何与异常抛出关联。第一部分非常简单。我们都习惯于通过<img src="some-image.png">在 HTML 中使用图像,但我们也可以使用 JavaScript 中的Image()对象以命令式方式创建图像;此外,我们这样创建的图像具有一个onload回调,该回调在图像加载后触发。它看起来像这样

const img = new Image();
img.onload = () => {
  // image is loaded
}; 

但是我们如何将其与异常抛出关联?如果您像我一样,您的第一直觉可能是这样的

const SuspenseImg = ({ src, ...rest }) => {
  throw new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      resolve();
    };
  });
  return <img alt="" src={src} {...rest} />;
}; 

当然,问题在于这将始终抛出一个 Promise。每次 React 尝试渲染<SuspenseImg>实例时,都会创建一个新的 Promise,并立即抛出。相反,我们只希望在图像加载之前抛出一个 Promise。有一句老话,计算机科学中的每个问题都可以通过添加一层间接性来解决(除了间接性层数过多的问题)因此让我们这样做,并构建一个图像缓存。当我们读取src时,缓存将检查它是否已加载该图像,如果没有,它将开始预加载并抛出异常。并且,如果图像已预加载,它将只返回true并让 React 继续渲染我们的图像。

以下是我们的<SuspenseImage>组件的样子

export const SuspenseImg = ({ src, ...rest }) => {
  imgCache.read(src);
  return <img src={src} {...rest} />;
};

以下是缓存的最小版本

const imgCache = {
  __cache: {},
  read(src) {
    if (!this.__cache[src]) {
      this.__cache[src] = new Promise((resolve) => {
        const img = new Image();
        img.onload = () => {
          this.__cache[src] = true;
          resolve(this.__cache[src]);
        };
        img.src = src;
      }).then((img) => {
        this.__cache[src] = true;
      });
    }
    if (this.__cache[src] instanceof Promise) {
      throw this.__cache[src];
    }
    return this.__cache[src];
  }
};

它并不完美,但目前足够了。让我们继续使用它。

实现

请记住,下面有一个指向完整工作演示的链接,因此,如果我在任何特定步骤中进展太快,请不要绝望。我们将在前进的过程中解释这些内容。

让我们从定义回退开始。我们通过在组件树中放置一个 Suspense 标签并通过fallback属性传递我们的回退来定义回退。任何挂起的组件都将向上搜索最近的 Suspense 标签并渲染其回退(但如果没有找到 Suspense 标签,则会抛出错误)。一个真实的应用可能在整个应用中都有许多 Suspense 标签,为其各种模块定义特定的回退,但对于此演示,我们只需要一个包装我们的根应用即可。

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <ShowImages />
    </Suspense>
  );
}

<Loading>组件是一个基本的加载动画,但在真实的应用中,您可能希望渲染要渲染的实际组件的某种空壳,以提供更无缝的体验。

有了它,我们的<ShowImages>组件最终将使用以下方式渲染我们的图像

<FlowItems>
  {images.map(img => (
    <div key={img}>
      <SuspenseImg alt="" src={img} />
    </div>
  ))}
</FlowItems>

在初始加载时,我们的加载动画将显示,直到我们的初始图像准备好,此时它们将同时显示,没有任何交错重排的卡顿。

过渡状态更新

图像就位后,当我们加载下一批图像时,我们希望它们在加载后显示,当然,在加载时保留屏幕上的现有图像。我们使用useTransition钩子来实现这一点。这将返回一个startTransition函数和一个isPending布尔值,该值指示我们的状态更新正在进行中,但已挂起(或者即使它没有挂起,如果状态更新花费的时间过长,也可能仍然为真)。最后,在调用useTransition时,您需要传递一个timeoutMs值,该值是isPending标志可以为true的最长时间,在此之后,React 将放弃并渲染回退(请注意,timeoutMs参数可能会在不久的将来被移除,当更新现有内容时,过渡状态更新将简单地等待尽可能长的时间)。

以下是我的代码示例

const [startTransition, isPending] = useTransition({ timeoutMs: 10000 });

在我们的回退显示之前,我们将允许 10 秒钟过去,这在现实生活中可能太长了,但对于此演示的目的而言是合适的,尤其是在您可能在 DevTools 中故意降低网络速度以进行实验时。

以下是我们如何使用它。当您单击按钮加载更多图像时,代码如下所示

startTransition(() => {
  setPage(p => p + 1);
});

该状态更新将使用我的 GraphQL 客户端micro-graphql-react触发新的数据加载,该客户端与 Suspense 兼容,在查询进行时将为我们抛出一个 Promise。数据返回后,我们的组件将尝试渲染,并在我们的图像预加载时再次挂起。在所有这些事情发生期间,我们的isPending值将为true,这将允许我们在现有内容的顶部显示加载动画。

避免网络瀑布

您可能想知道 React 如何在图像预加载期间阻止渲染。使用上面的代码,当我们执行以下操作时

{images.map(img => (

…以及其中渲染的<SuspenseImage>,React 是否会尝试渲染第一张图像、挂起,然后重新尝试渲染列表,越过现在在我们的缓存中的第一张图像,只在第二张图像上挂起,然后是第三张、第四张等。如果您之前阅读过有关 Suspense 的内容,您可能想知道我们是否需要在所有这些渲染发生之前手动预加载列表中的所有图像。

事实证明,无需担心,也不需要笨拙的预加载,因为 React 在 Suspense 世界中对渲染事物的方式相当智能。当 React 遍历我们的组件树时,它不会在遇到挂起时就停止。相反,它会继续渲染我们组件树中的所有其他路径。所以,是的,当它尝试渲染图像 0 时,会发生挂起,但 React 会继续尝试渲染图像 1 到 N,然后才会挂起。

您可以在完整演示中查看网络选项卡,当您点击“下一批图像”按钮时,就能看到此操作。您应该会看到整个图像集立即显示在网络列表中,一个接一个地解析,完成后,结果应该会显示在屏幕上。为了真正放大这种效果,您可能需要将网络速度降低到“快速 3G”。

为了好玩,我们可以通过在 React 尝试渲染我们的组件 *之前* 手动从我们的缓存中读取每个图像,遍历组件树中的每条路径,从而强制 Suspense 瀑布式处理我们的图像。

images.forEach((img) => imgCache.read(img));

我创建了一个演示来说明这一点。 如果您类似地查看新图像集出现时的网络选项卡,您会看到它们按顺序添加到网络列表中(但 *不要* 在网络速度降低的情况下运行此操作)。

延迟挂起

在使用 Suspense 时,请记住一个推论:尽可能在渲染的后期和组件树的较低层级挂起。如果您有一些渲染一堆挂起图像的 <ImageList>,请确保每个图像都在其自己的组件中挂起,以便 React 可以分别访问它,并且不会有任何一个阻塞其他图像,从而导致瀑布效应。

此规则的数据加载版本是,数据应尽可能延迟到实际需要它的组件加载。这意味着我们应该避免在一个组件中执行以下操作

const { data1 } = useSuspenseQuery(QUERY1, vars1);
const { data2 } = useSuspenseQuery(QUERY2, vars2);

我们想要避免这种情况的原因是,查询一将挂起,然后是查询二,从而导致瀑布效应。如果这根本无法避免,我们将需要在挂起之前手动预加载这两个查询。

演示

这是我承诺的演示。它与我上面链接的演示相同。

如果您在打开开发者工具的情况下运行它,请确保取消选中 DevTools 网络选项卡中显示的“禁用缓存”框,否则您将破坏整个演示。

代码与我之前展示的几乎相同。演示的一个改进是我们的缓存读取方法包含以下行

setTimeout(() => resolve({}), 7000);

很好地预加载所有图像,但在现实生活中,我们可能不希望仅仅因为一两张滞后的图像加载缓慢而无限期地挂起渲染。因此,在一段时间后,我们会直接放行,即使图像尚未准备好。用户会看到一两张图像闪烁,但这总比忍受软件冻结的挫败感要好。我还需要说明的是,7 秒可能过长,但对于此演示,我假设用户可能会在 DevTools 中降低网络速度以更清晰地查看 Suspense 功能,并希望支持这一点。

演示还具有一个预缓存图像复选框。它默认处于选中状态,但您可以取消选中它以将 <SuspenseImage> 组件替换为普通的 <img> 标签,如果您想将 Suspense 版本与“普通 React”进行比较(只需在结果出现时不要选中它,否则整个 UI 可能会挂起并渲染回退)。

最后,与 CodeSandbox 一样,某些状态可能会偶尔不同步,因此如果出现奇怪或损坏的情况,请点击刷新按钮。

其他细节

在将此演示组合在一起时,我偶然犯了一个巨大的错误。我不希望演示的多次运行因浏览器缓存它已下载的图像而失去效果。因此,我手动使用缓存破坏器修改所有 URL

const [cacheBuster, setCacheBuster] = useState(INITIAL_TIME);


const { data } = useSuspenseQuery(GET_IMAGES_QUERY, { page });
const images = data.allBooks.Books.map(
  (b) => b.smallImage + `?cachebust=${cacheBuster}`
);

INITIAL_TIME 在模块级别(即全局)定义,使用以下行

const INITIAL_TIME = +new Date();

如果您想知道为什么我不这样做

const [cacheBuster, setCacheBuster] = useState(+new Date());

…那是因为这会导致非常糟糕的事情。在**第一次**渲染时,图像尝试渲染。缓存导致挂起,React 取消渲染并显示我们的回退。当所有 Promise 都解析后,React 将尝试重新进行此初始渲染,并且我们的初始 useState 调用将**重新运行**,这意味着这

const [cacheBuster, setCacheBuster] = useState(+new Date());

…将重新运行,并使用**新的**初始值,导致一组完全**新的**图像 URL,这将再次挂起,*无限循环*。组件将永远不会运行,并且 CodeSandbox 演示会陷入停滞(这使得调试变得令人沮丧)。

这似乎是一个由此特定演示的独特需求引起的奇怪的单次问题,但它也包含一个更大的教训:渲染应该保持纯净,不应有副作用。React 应该能够重新尝试渲染您的组件任意多次,并且(在给定相同的初始 props 时),应该从另一端获得完全相同的状态。