Паралельныя запыткі ў React: Suspense з Promise.all і allSettled
Выучыце, як адзьёмаць «водоспады» запытоў за дапамой Promise.all, як захаваць необов’язковыя даны, каб сторанкі не зламаліся, за дапамой Promise.allSettled, і як паказваць статусы завантажэння за дапамой Suspense.
Старонка, яка паказвае адзін спінер за іншым, зазвычай мае адзін з двух проблем. Альбо яе запыткі выкананы адзін пасля іншага, хоць яны моглі бы выканацца адночасова, альбо якась неабяжна запытка, такая як віджэт з рэкамендацыямі, не выйшла і прыводзіць да таго, што вся старонка перестае працаваць. Тры інструмента рашыраюць як першую, так і другую проблему: 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), і сторанка будзе паказваць "Loading data..." назаўсёды. Пакрыйце кантэнт 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 для керавання регіонамі завантажэння дапамагаюць стварыць сторанку, якая быстра адкрываецца і грайна падваівае свою функцыональнасць, калі якая-небудзь залежнасць працюе некоректна.