探索 React 中使用 Suspense 进行并行数据获取的高级技术,从而提高应用程序性能和用户体验。 学习协调多个异步操作并有效处理加载状态的策略。
React Suspense 协调:掌握并行数据获取
React Suspense 彻底改变了我们处理异步操作的方式,尤其是数据获取。它允许组件在等待数据加载时“暂停”渲染,从而提供了一种声明式的方式来管理加载状态。但是,简单地用 Suspense 包装单个数据获取可能会导致瀑布效应,即一个获取完成后下一个才开始,从而对性能产生负面影响。这篇博文深入探讨了使用 Suspense 并行协调多个数据获取的高级策略,从而优化应用程序的响应能力并增强全球用户的用户体验。
了解数据获取中的瀑布问题
想象一下,您需要显示包含用户名、头像和最近活动的用户个人资料。如果您按顺序获取每条数据,用户会看到用户名加载微调器,然后是头像加载微调器,最后是活动提要加载微调器。这种顺序加载模式会产生瀑布效应,延迟完整个人资料的呈现并让用户感到沮丧。对于网络速度不同的国际用户,这种延迟可能会更加明显。
考虑以下简化的代码段:
function UserProfile() {
const name = useName(); // Fetches user name
const avatar = useAvatar(name); // Fetches avatar based on name
const activity = useActivity(name); // Fetches activity based on name
return (
<div>
<h2>{name}</h2>
<img src={avatar} alt="User Avatar" />
<ul>
{activity.map(item => <li key={item.id}>{item.text}</li>)}
</ul>
</div>
);
}
在此示例中,useAvatar 和 useActivity 依赖于 useName 的结果。这会产生明显的瀑布 – useAvatar 和 useActivity 只有在 useName 完成后才能开始获取数据。这是低效的,也是常见的性能瓶颈。
使用 Suspense 进行并行数据获取的策略
使用 Suspense 优化数据获取的关键是同时启动所有数据请求。您可以采用以下几种策略:
1. 使用 `React.preload` 和资源预加载数据
最强大的技术之一是在组件呈现之前预加载数据。 这涉及创建“资源”(封装数据获取 promise 的对象)并预先获取数据。`React.preload` 在这方面有所帮助。当组件需要数据时,数据已经可用,几乎完全消除了加载状态。
考虑用于获取产品的资源:
const createProductResource = (productId) => {
let promise;
let product;
let error;
const suspender = new Promise((resolve, reject) => {
promise = fetch(`/api/products/${productId}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
product = data;
resolve();
})
.catch(e => {
error = e;
reject(e);
});
});
return {
read() {
if (error) {
throw error;
}
if (product) {
return product;
}
throw suspender;
},
};
};
// Usage:
const productResource = createProductResource(123);
function ProductDetails() {
const product = productResource.read();
return (<div>{product.name}</div>);
}
现在,您可以在渲染 ProductDetails 组件之前预加载此资源。 例如,在路由转换期间或悬停时。
React.preload(productResource);
这样可以确保在 ProductDetails 组件需要数据时,数据很可能可用,从而最大程度地减少或消除加载状态。
2. 使用 `Promise.all` 进行并发数据获取
另一种简单有效的方法是使用 Promise.all 在单个 Suspense 边界内同时启动所有数据获取。 当事先知道数据依赖项时,此方法效果很好。
让我们重新审视用户个人资料示例。我们可以同时获取姓名、头像和活动提要,而不是按顺序获取数据:
import { useState, useEffect, Suspense } from 'react';
async function fetchName() {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
return 'John Doe';
}
async function fetchAvatar(name) {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 300));
return `https://example.com/avatars/${name.toLowerCase().replace(' ', '-')}.jpg`;
}
async function fetchActivity(name) {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 800));
return [
{ id: 1, text: 'Posted a photo' },
{ id: 2, text: 'Updated profile' },
];
}
function useSuspense(promise) {
const [result, setResult] = useState(null);
useEffect(() => {
let didCancel = false;
promise.then(
(data) => {
if (!didCancel) {
setResult({ status: 'success', value: data });
}
},
(error) => {
if (!didCancel) {
setResult({ status: 'error', value: error });
}
}
);
return () => {
didCancel = true;
};
}, [promise]);
if (result?.status === 'success') {
return result.value;
} else if (result?.status === 'error') {
throw result.value;
} else {
throw promise;
}
}
function Name() {
const name = useSuspense(fetchName());
return <h2>{name}</h2>;
}
function Avatar({ name }) {
const avatar = useSuspense(fetchAvatar(name));
return <img src={avatar} alt="User Avatar" />;
}
function Activity({ name }) {
const activity = useSuspense(fetchActivity(name));
return (
<ul>
{activity.map(item => <li key={item.id}>{item.text}</li>)}
</ul>
);
}
function UserProfile() {
const name = useSuspense(fetchName());
return (
<div>
<Suspense fallback=<div>Loading Avatar...</div>>
<Avatar name={name} />
</Suspense>
<Suspense fallback=<div>Loading Activity...</div>>
<Activity name={name} />
</Suspense>
</div>
);
}
export default UserProfile;
但是,如果每个 `Avatar` 和 `Activity` 也依赖于 `fetchName`,但渲染在单独的 suspense 边界内,您可以将 `fetchName` promise 提升到父级并通过 React Context 提供它。
import React, { createContext, useContext, useState, useEffect, Suspense } from 'react';
async function fetchName() {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
return 'John Doe';
}
async function fetchAvatar(name) {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 300));
return `https://example.com/avatars/${name.toLowerCase().replace(' ', '-')}.jpg`;
}
async function fetchActivity(name) {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 800));
return [
{ id: 1, text: 'Posted a photo' },
{ id: 2, text: 'Updated profile' },
];
}
function useSuspense(promise) {
const [result, setResult] = useState(null);
useEffect(() => {
let didCancel = false;
promise.then(
(data) => {
if (!didCancel) {
setResult({ status: 'success', value: data });
}
},
(error) => {
if (!didCancel) {
setResult({ status: 'error', value: error });
}
}
);
return () => {
didCancel = true;
};
}, [promise]);
if (result?.status === 'success') {
return result.value;
} else if (result?.status === 'error') {
throw result.value;
} else {
throw promise;
}
}
const NamePromiseContext = createContext(null);
function Avatar() {
const namePromise = useContext(NamePromiseContext);
const name = useSuspense(namePromise);
const avatar = useSuspense(fetchAvatar(name));
return <img src={avatar} alt="User Avatar" />;
}
function Activity() {
const namePromise = useContext(NamePromiseContext);
const name = useSuspense(namePromise);
const activity = useSuspense(fetchActivity(name));
return (
<ul>
{activity.map(item => <li key={item.id}>{item.text}</li>)}
</ul>
);
}
function UserProfile() {
const namePromise = fetchName();
return (
<NamePromiseContext.Provider value={namePromise}>
<Suspense fallback=<div>Loading Avatar...</div>>
<Avatar />
</Suspense>
<Suspense fallback=<div>Loading Activity...</div>>
<Activity />
</Suspense>
</NamePromiseContext.Provider>
);
}
export default UserProfile;
3. 使用自定义 Hook 管理并行获取
对于具有潜在条件数据依赖性的更复杂场景,您可以创建一个自定义 Hook 来管理并行数据获取并返回 Suspense 可以使用的资源。
import { useState, useEffect, useRef } from 'react';
function useParallelData(fetchFunctions) {
const [resource, setResource] = useState(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
const promises = fetchFunctions.map(fn => fn());
const suspender = Promise.all(promises).then(
(results) => {
if (mounted.current) {
setResource({ status: 'success', value: results });
}
},
(error) => {
if (mounted.current) {
setResource({ status: 'error', value: error });
}
}
);
setResource({
status: 'pending',
value: suspender,
});
return () => {
mounted.current = false;
};
}, [fetchFunctions]);
const read = () => {
if (!resource) {
throw new Error('Resource not yet initialized');
}
if (resource.status === 'pending') {
throw resource.value;
}
if (resource.status === 'error') {
throw resource.value;
}
return resource.value;
};
return { read };
}
// Example usage:
async function fetchUserData(userId) {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 300));
return { id: userId, name: 'User ' + userId };
}
async function fetchUserPosts(userId) {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
return [{ id: 1, title: 'Post 1' }, { id: 2, title: 'Post 2' }];
}
function UserProfile({ userId }) {
const { read } = useParallelData([
() => fetchUserData(userId),
() => fetchUserPosts(userId),
]);
const [userData, userPosts] = read();
return (
<div>
<h2>{userData.name}</h2>
<ul>
{userPosts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
</div>
);
}
function App() {
return (
<Suspense fallback=<div>Loading user data...</div>>
<UserProfile userId={123} />
</Suspense>
);
}
export default App;
此方法将管理 promise 和加载状态的复杂性封装在 Hook 中,使组件代码更简洁,更专注于呈现数据。
4. 使用流式服务器渲染进行选择性补水
对于服务器渲染的应用程序,React 18 引入了流式服务器渲染的选择性补水。 这允许您在服务器上可用时将 HTML 分块发送到客户端。 您可以使用 <Suspense> 边界包装加载缓慢的组件,从而允许页面的其余部分变为交互式,而缓慢的组件仍在服务器上加载。 这大大提高了感知性能,尤其对于网络连接或设备速度较慢的用户。
考虑这样一种情况:新闻网站需要显示来自世界各个地区(例如,亚洲、欧洲、美洲)的文章。 某些数据源可能比其他数据源慢。 选择性补水允许首先显示来自更快区域的文章,而来自较慢区域的文章仍在加载,从而防止整个页面被阻止。
处理错误和加载状态
虽然 Suspense 简化了加载状态管理,但错误处理仍然至关重要。 错误边界(使用 componentDidCatch 生命周期方法或来自 `react-error-boundary` 等库的 useErrorBoundary Hook)允许您优雅地处理数据获取或呈现期间发生的错误。 应策略性地放置这些错误边界,以捕获特定 Suspense 边界内的错误,从而防止整个应用程序崩溃。
import React, { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
function MyComponent() {
// ... fetches data that might error
}
function App() {
return (
<ErrorBoundary fallback={<div>Something went wrong!</div>}>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
</ErrorBoundary>
);
}
请记住为加载和错误状态提供信息丰富且用户友好的回退 UI。 这对于可能遇到较慢网络速度或区域服务中断的国际用户尤其重要。
使用 Suspense 优化数据获取的最佳实践
- 识别并确定关键数据的优先级:确定哪些数据对于应用程序的初始呈现至关重要,并优先获取这些数据。
- 尽可能预加载数据:使用 `React.preload` 和资源在组件需要数据之前预加载数据,从而最大程度地减少加载状态。
- 并发获取数据:利用 `Promise.all` 或自定义 Hook 并行启动多个数据获取。
- 优化 API 端点:确保您的 API 端点针对性能进行了优化,从而最大程度地减少延迟和有效负载大小。 考虑使用 GraphQL 等技术仅获取所需的数据。
- 实施缓存:缓存经常访问的数据以减少 API 请求的数量。 考虑使用 `swr` 或 `react-query` 等库来获得强大的缓存功能。
- 使用代码拆分:将您的应用程序拆分为更小的块,以减少初始加载时间。 将代码拆分与 Suspense 结合使用,以逐步加载和呈现应用程序的不同部分。
- 监控性能:使用 Lighthouse 或 WebPageTest 等工具定期监控应用程序的性能,以识别和解决性能瓶颈。
- 优雅地处理错误:实施错误边界以捕获数据获取和呈现期间发生的错误,并向用户提供信息性错误消息。
- 考虑服务器端渲染 (SSR):出于 SEO 和性能原因,请考虑使用 SSR 进行流式传输和选择性补水,以提供更快的初始体验。
结论
React Suspense 与并行数据获取策略相结合,为构建响应迅速且性能良好的 Web 应用程序提供了一个强大的工具包。 通过了解瀑布问题并实施预加载、使用 Promise.all 并发获取以及自定义 Hook 等技术,您可以显着改善用户体验。 请记住优雅地处理错误并监控性能,以确保您的应用程序针对全球用户保持优化。 随着 React 的不断发展,探索流式服务器渲染的选择性补水等新功能将进一步增强您提供卓越用户体验的能力,无论位置或网络状况如何。 通过采用这些技术,您可以创建不仅功能强大,而且使用起来令人愉悦的应用程序,从而为您的全球受众带来福音。
这篇博文旨在全面概述 React Suspense 的并行数据获取策略。 我们希望您觉得它内容丰富且有用。 我们鼓励您在自己的项目中尝试这些技术,并与社区分享您的发现。