Главная / Статьи / Параллельный захват данных в React: Suspense с Promise.all и allSettled

Параллельный захват данных в React: Suspense с Promise.all и allSettled

Узнайте, как удалять последовательные запросы с помощью Promise.all, защищать необязательные данные от сбоев на страницах с помощью Promise.allSettled и отображать состояния загрузки с использованием Suspense.

1327 слов

Страница, на которой постоянно отображаются индикаторы загрузки, обычно имеет одну из двух проблем. Либо её запросы выполняются по одному, хотя могли бы идти одновременно, либо какой-то необязательный запрос, например всплывающее окно с рекомендациями, терпит неудачу и приводит к сбою всего интерфейса. Три инструмента помогают решить обе проблемы: Promise.all и Promise.allSettled для одновременного запуска операций с разными стратегиями обработки ошибок, а также Suspense из React для отображения запасного варианта до получения данных. В этом руководстве мы рассмотрим каждый из них на примере небольшой демонстрации, загружающей два списка с публичного API.

Откуда появляются последовательные запросы

Ожидание вызовов по очереди приводит к их сериализации. Второй запрос даже не начинается, пока первый не завершится, поэтому общая задержка равна сумме времени выполнения обоих запросов:

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

Когда запросы не зависят друг от друга, запустите их оба сразу и дождитесь их вместе. В этом случае общее время выполнения сокращается примерно до времени выполнения самого медленного запроса, а не к сумме времен всех запросов. Для более подробного сравнения этих методов смотрите выбор между Promise.all, Promise.race и последовательными awaits.

Suspense: декларативные состояния загрузки

Suspense оборачивает часть структуры и отображает fallback, такой как шаблон-основа или сообщение «Загрузка данных...», пока все элементы внутри неего находятся в состоянии ожидания. Остальная часть страницы остается видимой, и вам больше не нужен вручную написанный флаг loading для этого раздела.

В демо-версии асинхронная функция ожидает три секунды для отображения состояния загрузки, затем одновременно отправляет запросы на использование заклинаний и покупку книг, объединяя их с помощью Promise.all. Полученный обещание передается в Section1, который разворачивает его с использованием хука use в React. Пока обещание не будет выполнено, хук 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() — только при непарсируемом содержимом. HTTP-ошибка 500, возвращающая JSON, всё равно считается fulfilled; поэтому, если вы хотите, чтобы ошибки сервера рассматривались как отклонения, проверяйте значение res.ok в каждом этапе обработки.

Выбор подходящей комбинации

  • Используйте Promise.all, когда отображение контента бессмысленно без всех данных, и сочетайте его с механизмом обработки ошибок или явным состоянием ошибки.
  • Используйте Promise.allSettled, когда некоторые элементы являются необязательными, и частичная версия страницы лучше, чем её отсутствие.
  • Оберните каждый раздельно загружаемый элемент в собственный контейнер Suspense, чтобы медленная загрузка данных влияла только на соответствующую область.
  • Сохраняйте стабильность обещаний: создавайте их на сервере или храните в кэше, а не при каждой отрисовке на клиенте.

Заключение

Модели в виде «водопадов» и единые точки отказа — это архитектурные решения, а не неизбежности. Совместная обработка независимых задач, осознанный выбор стратегии реагирования на сбои и использование Suspense для управления зонами загрузки позволяют создавать страницы, которые быстро открываются и плавно переходят в режим сниженной производительности при сбое какого-либо компонента.