首页 / 文章 / React中的并行获取:使用Promise.all和allSettled实现悬停状态

React中的并行获取:使用Promise.all和allSettled实现悬停状态

了解如何使用 Promise.all 移除请求级联效应,利用 Promise.allSettled 防止可选数据导致页面出错,以及如何通过 Suspense 显示加载状态。

1327 词

一个不断显示加载动画的页面通常存在两个问题之一:要么本可同时执行的请求却依次执行,要么某个可选请求(如推荐插件)失败,导致整个页面无法正常显示。有三种工具可以解决这些问题:Promise.all和Promise.allSettled可用于以不同的失败处理策略同时启动多个请求,而 React 的 Suspense则能在数据加载期间显示备用内容。本指南将通过一个从公共 API 加载两个列表的小示例来介绍每种方法。

串行请求的由来

一次只等待一个请求会使其顺序执行。第二个请求要等到第一个请求完成才会开始,因此总延迟就是两个请求延迟之和:

const categories = await fetchCategories();
// fetchPosts will only start running after fetchCategories finishes executing.
const posts = await fetchPosts();

当这些请求彼此之间没有依赖关系时,可以同时启动它们并一起等待。这样总耗时就会接近最慢的那个请求的时间,而非所有请求时间之和。如需更全面地比较这些处理方式,请参阅Promise.all、Promise.race与顺序等待方式的选择。

Suspense:声明式加载状态

Suspense会包裹代码树中的部分内容,在其内部的内容正在等待处理时显示fallback内容,比如骨架结构或“正在加载数据...”之类的提示。页面的其他部分仍然可见,因此无需再为该区域手动设置loading标志。

在演示中,一个异步函数会人为等待三秒以显示加载状态,随后同时发起法术和书籍的请求,并使用 Promise.all 将它们合并。生成的承诺对象会被传递给 Section1,该组件通过 React 的 use 钩子来解析这个承诺。在承诺解决之前,use 会暂停组件的执行,从而显示备用内容。

'use client';

import React, { Suspense, use } from 'react';

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default function Home() {
  const fetchDataParalel = async () => {
    await delay(3000); // Simulate a 3-second loading delay

    // Trigger both APIs in PARALLEL (simultaneously)
    const getSpellsPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
    ).then((res) => res.json());
    const getBooksPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
    ).then((res) => res.json());

    // Wait for both to finish together
    const [spellsData, booksData] = await Promise.all([
      getSpellsPromise,
      getBooksPromise,
    ]);

    return {
      spells: spellsData?.map((item: any) => item.spell) || [],
      books: booksData?.map((item: any) => item.title) || [],
    };
  };

  return (
    <main className="p-4">
      <p>
        <strong>Demo Suspense + Promise.all</strong>
      </p>
      <br />

      <Suspense fallback={<p>Loading data...</p>}>
        <Section1 dataPromise={fetchDataParalel()} />
      </Suspense>
    </main>
  );
}

function Section1({
  dataPromise,
}: {
  dataPromise: Promise<{ spells: string[]; books: string[] }>;
}) {
  const data = use(dataPromise);

  return (
    <div>
      <p>
        <strong>Spells: </strong>{' '}
        {data.spells.length ? data.spells.join(', ') : '-'}
      </p>
      <p>
        <strong>Books: </strong>{' '}
        {data.books.length ? data.books.join(', ') : '-'}
      </p>
    </div>
  );
}

需要注意一点:该组件在渲染时会调用 fetchDataParalel(),因此每次 Home 组件渲染时都会创建一个新的承诺。React 要求传递给 use 的承诺必须是稳定的,而在客户端组件中,未缓存的承诺可能会引发警告或导致重复请求。在 Next.js 中,更符合习惯的做法是在服务端组件中创建承诺(或对其进行缓存)后再传递下去,或者直接将该部分设置为包裹在 Suspense 中的异步服务端组件。

Promise.all:全有或全无

Promise.all适用于所有字段均为必填的数据。一旦所有输入都满足条件,它就会按原始顺序返回结果数组;而只要有任何一个输入失败,它就会立即拒绝。第二个示例使用了相同的并行请求方式,这次是从useEffect中发起的,并明确使用了loading状态而非Suspense:

'use client';

import React, { Suspense, use } from 'react';

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default function HomePromiseAll() {
  const [loading, setLoading] = React.useState(false);
  const [spells, setSpells] = React.useState([]);
  const [books, setBooks] = React.useState([]);

  const fetchDataParalel = async () => {
    setLoading(true);
    await delay(3000);

    const getSpellsPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
    ).then((res) => res.json());
    const getBooksPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
    ).then((res) => res.json());

    const [spellsData, booksData] = await Promise.all([
      getSpellsPromise,
      getBooksPromise,
    ]);
    setSpells(spellsData?.map((item: any) => item.spell) || []);
    setBooks(booksData?.map((item: any) => item.title) || []);
    setLoading(false);
  };

  React.useEffect(() => {
    fetchDataParalel();
  }, []);

  return (
    <main>
      <h1>Demo Promise.all</h1>
      <br />
      {loading ? (
        <p>Loading data...</p>
      ) : (
        <Section1 spells={spells} books={books} />
      )}
    </main>
  );
}

function Section1({ spells, books }: { spells: string[]; books: string[] }) {
  return (
    <div>
      <p>
        <strong>Spells: </strong> {spells.length ? spells.join(', ') : '-'}
      </p>
      <p>
        <strong>Books: </strong> {books.length ? books.join(', ') : '-'}
      </p>
    </div>
  );
}

请注意,如果任意一个请求失败,执行流程将永远不会到达setLoading(false),页面会一直显示“正在加载数据...”。应当将相关代码包裹在try/finally结构中,并在catch块中渲染错误状态,这样失败情况就能被显示出来而非被隐藏。此外,当其中一个请求失败时,Promise.all也不会取消其他请求,它只是停止等待那个失败的请求而已。

Promise.allSettled:允许部分失败

Promise.allSettled 永不会拒绝。它会等待所有输入,并为每个输入返回一个对象,该对象的 status 为 fulfilled(包含 value)或 rejected(包含 reason)。之后你可以根据每个结果决定如何显示内容:

'use client';

import React from 'react';

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default function HomePromiseAllSettled() {
  const [loading, setLoading] = React.useState(false);
  const [spells, setSpells] = React.useState<string[]>([]);
  const [books, setBooks] = React.useState<string[]>([]);

  const fetchDataParalel = async () => {
    setLoading(true);
    await delay(3000);

    const getSpellsPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
    ).then((res) => res.json());

    const getBooksPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
    ).then((res) => res.json());

    const [spellsResult, booksResult] = await Promise.allSettled([
      getSpellsPromise,
      getBooksPromise,
    ]);

    // check status 'fulfilled' and get from `.value`
    const finalSpells =
      spellsResult.status === 'fulfilled'
        ? spellsResult.value.map((item: any) => item.spell)
        : [];

    const finalBooks =
      booksResult.status === 'fulfilled'
        ? booksResult.value.map((item: any) => item.title)
        : [];

    setSpells(finalSpells);
    setBooks(finalBooks);
    setLoading(false);
  };

  React.useEffect(() => {
    fetchDataParalel();
  }, []);

  return (
    <main>
      <h1>Demo Promise.allSettled</h1>
      <br />
      {loading ? (
        <p>Loading data...</p>
      ) : (
        <Section1 spells={spells} books={books} />
      )}
    </main>
  );
}

function Section1({ spells, books }: { spells: string[]; books: string[] }) {
  return (
    <div>
      <p>
        <strong>Spells: </strong> {spells.length ? spells.join(', ') : '-'}
      </p>
      <p>
        <strong>Books: </strong> {books.length ? books.join(', ') : '-'}
      </p>
    </div>
  );
}

在此示例中,请求失败时会返回空列表,而书籍内容仍会正常显示。这对次要内容来说是正确的处理方式,但需注意何为失败情况。fetch 仅在网络错误时才会拒绝,而 .json() 方法仅在无法解析响应体时才会拒绝。即使返回 JSON 的 HTTP 500 错误也仍属于 fulfilled 状态,因此如果你希望将服务器错误视为拒绝情况,则需要在每个处理链中检查 res.ok。

选择合适的组合

  • 当缺少任何数据时页面都无法正常显示时,应使用 Promise.all,并配合错误处理机制或明确的错误状态。
  • 当某些内容为可选项且部分页面总比没有好时,应使用 Promise.allSettled。
  • 将每个独立加载的内容部分包裹在各自的 Suspense 中,这样缓慢加载的数据仅会影响其所在区域。
  • 保持 Promise 的稳定性:在服务器端创建或缓存它们,而非在每次客户端渲染时生成。

总结

瀑布式加载与单点故障都是架构设计上的选择而非必然结果。通过让各个部分同步开始工作、有意识地选择故障处理策略,并利用 Suspense 管理加载区域,就能为用户提供快速显示的页面,并在某个依赖项出问题时实现平滑降级。