React中的并行获取:使用Promise.all和allSettled实现悬停状态
了解如何使用 Promise.all 移除请求级联效应,利用 Promise.allSettled 防止可选数据导致页面出错,以及如何通过 Suspense 显示加载状态。
一个不断显示加载动画的页面通常存在两个问题之一:要么本可同时执行的请求却依次执行,要么某个可选请求(如推荐插件)失败,导致整个页面无法正常显示。有三种工具可以解决这些问题: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 管理加载区域,就能为用户提供快速显示的页面,并在某个依赖项出问题时实现平滑降级。