为什么需要 Suspense?
在传统 React 应用中,我们通常用 useEffect + useState 来管理异步数据加载,然后手动维护 loading 状态。这种方式虽然可行,但会导致组件逻辑臃肿,并且多个异步请求容易产生“瀑布式”加载,用户体验差。
React 18 正式引入了并发模式(Concurrent Mode),其中 Suspense 成为声明式处理异步加载的核心方案。它允许组件“暂停”渲染,直到所需数据就绪,同时由上层 统一展示加载状态。这不仅简化了代码,还能配合 startTransition 和 useDeferredValue 实现更流畅的交互。
本文将通过一个用户列表页面的实战,带你一步步用 Suspense 优化加载体验,并分享一些容易被忽略的最佳实践。
环境准备
确保你的项目使用 React 18 或更高版本,并启用了并发特性。如果你使用 Create React App,需要升级到 v5;或者直接使用 Vite 创建项目:
npm create vite@latest my-suspense-app -- --template react
cd my-suspense-app
npm install
npm run dev
然后安装一个用于模拟数据请求的库(也可以自己写一个简单的 fetch 封装):
npm install axios
第一步:创建一个可挂起的资源(Resource)
Suspense 的核心是“当组件读取一个尚未准备好的数据时,抛出一个 Promise”。React 会捕获这个 Promise,并显示 fallback,直到 Promise 完成。
我们封装一个简单的 wrapPromise 函数,它接收一个 Promise,并返回一个对象,该对象有一个 read() 方法:
// utils/wrapPromise.js
export function wrapPromise(promise) {
let status = 'pending';
let result;
let suspender = promise.then(
(r) => {
status = 'success';
result = r;
},
(e) => {
status = 'error';
result = e;
}
);
return {
read() {
if (status === 'pending') {
throw suspender;
} else if (status === 'error') {
throw result;
} else if (status === 'success') {
return result;
}
},
};
}
这个函数是 Suspense 的基石。注意:不要在生产环境直接使用这个简易版本,因为它没有缓存,每次调用都会重新请求。后面我们会引入缓存机制。
第二步:创建数据获取函数并缓存资源
为了避免重复请求,我们需要一个缓存。这里用 Map 简单实现:
// utils/fetchData.js
import axios from 'axios';
import { wrapPromise } from './wrapPromise';
const cache = new Map();
export function fetchUserList() {
const key = 'userList';
if (cache.has(key)) {
return cache.get(key);
}
const promise = axios.get('https://jsonplaceholder.typicode.com/users')
.then(res => res.data);
const resource = wrapPromise(promise);
cache.set(key, resource);
return resource;
}
这里我们请求了 JSONPlaceholder 的免费 API。注意缓存是全局的,适合演示;实际项目中你可能需要根据参数做缓存键。
第三步:编写挂起组件
创建一个 UserList 组件,它直接调用 resource.read() 来获取数据。当数据未就绪时,read() 会抛出 Promise,触发 Suspense。
// components/UserList.jsx
import React from 'react';
import { fetchUserList } from '../utils/fetchData';
const resource = fetchUserList();
export default function UserList() {
const users = resource.read();
return (
<ul className="user-list">
{users.map(user => (
<li key={user.id}>
<strong>{user.name}</strong> - {user.email}
</li>
))}
</ul>
);
}
注意:resource 在模块顶层创建,这样它只会在模块加载时请求一次。如果你在组件内部调用 fetchUserList(),每次渲染都会创建新的资源,导致无限循环。
第四步:用 Suspense 包裹并添加骨架屏
现在在父组件中使用 ,并提供一个加载中的 fallback。为了更好的体验,我们做一个骨架屏组件。
// components/UserListSkeleton.jsx
import React from 'react';
export default function UserListSkeleton() {
return (
<ul className="user-list skeleton">
{[...Array(5)].map((_, i) => (
<li key={i}>
<div className="skeleton-line" style={{ width: '60%' }} />
<div className="skeleton-line" style={{ width: '40%' }} />
</li>
))}
</ul>
);
}
然后在 App 中:
// App.jsx
import React, { Suspense } from 'react';
import UserList from './components/UserList';
import UserListSkeleton from './components/UserListSkeleton';
import './App.css';
function App() {
return (
<div className="App">
<h1>用户列表</h1>
<Suspense fallback={<UserListSkeleton />}>
<UserList />
</Suspense>
</div>
);
}
export default App;
添加一些简单的 CSS 让骨架屏有闪烁效果:
/* App.css */
.user-list {
list-style: none;
padding: 0;
}
.user-list li {
padding: 12px;
border-bottom: 1px solid #eee;
}
.skeleton-line {
height: 16px;
background: #e0e0e0;
margin: 8px 0;
border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out;
}
@keyframes pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
现在运行应用,你会看到骨架屏,然后数据加载完成后替换为真实列表。
第五步:处理错误与重试
Suspense 本身不处理错误,需要配合 Error Boundary。我们创建一个简单的错误边界组件,并提供重试按钮。
// components/ErrorBoundary.jsx
import React from 'react';
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error('ErrorBoundary caught:', error, info);
}
handleRetry = () => {
// 清除缓存并重新加载页面(简单粗暴,但有效)
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<div className="error-box">
<p>加载失败:{this.state.error.message}</p>
<button onClick={this.handleRetry}>重试</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
然后在 App 中包裹 Suspense:
<ErrorBoundary>
<Suspense fallback={<UserListSkeleton />}>
<UserList />
</Suspense>
</ErrorBoundary>
注意:重试时最好清除缓存,否则会再次抛出同样的错误。更优雅的做法是让资源本身支持重置。
第六步:使用 startTransition 避免不必要的加载态
当你在一个已经展示内容的页面上切换数据时,如果直接更新 state,Suspense 会立刻显示 fallback,导致内容闪烁。React 18 的 startTransition 可以标记非紧急更新,让 Suspense 在后台等待新数据,同时保持旧内容可见。
假设我们有一个按钮切换用户列表和文章列表:
import React, { Suspense, useState, startTransition } from 'react';
import UserList from './components/UserList';
import PostList from './components/PostList';
import UserListSkeleton from './components/UserListSkeleton';
function App() {
const [tab, setTab] = useState('users');
const handleTabChange = (newTab) => {
startTransition(() => {
setTab(newTab);
});
};
return (
<div className="App">
<div className="tabs">
<button onClick={() => handleTabChange('users')}>用户</button>
<button onClick={() => handleTabChange('posts')}>文章</button>
</div>
<Suspense fallback={<UserListSkeleton />}>
{tab === 'users' ? <UserList /> : <PostList />}
</Suspense>
</div>
);
}
这样切换时,如果新数据未加载完,旧列表会继续显示,直到新数据就绪。这大大提升了感知性能。
第七步:结合 useDeferredValue 优化搜索过滤
另一个常见场景是搜索过滤大量数据。如果每次输入都触发重渲染,可能会卡顿。useDeferredValue 可以延迟更新非紧急部分。
import React, { useState, useDeferredValue } from 'react';
function SearchList({ items }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const filtered = items.filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索..."
/>
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
注意 useDeferredValue 不是防抖,它只是让 React 在空闲时更新,适合计算量大的渲染。
常见坑与最佳实践
💡 资源缓存:务必缓存资源,避免每次渲染都重新请求。可以使用 Map 或更专业的库如 react-query、swr,它们内部已经集成了 Suspense 支持。
⚠️ 不要过度使用 Suspense:对于简单的、快速的请求,直接使用 useEffect 可能更简单。Suspense 最适合那些可能较慢、需要占位符的请求。
💡 嵌套 Suspense:可以在不同层级放置 Suspense,实现细粒度的加载状态。例如,页面整体一个 Suspense,每个卡片再包一个。
⚠️ 避免在 Suspense 内部使用 useEffect 获取数据:这会导致双重加载,应该直接在渲染时读取资源。
💡 服务端渲染(SSR):React 18 的 renderToPipeableStream 支持 Suspense,可以流式传输 HTML,进一步提升首屏速度。
总结
通过本文,你学会了如何用 React Suspense 声明式地处理异步加载,包括:
- 创建可挂起的资源
- 使用 Suspense 和骨架屏
- 错误边界与重试
- 利用
startTransition和useDeferredValue优化交互
Suspense 不仅简化了代码,还让加载体验更加流畅。建议在实际项目中结合 react-query 或 swr 等库,它们已经很好地集成了 Suspense 模式。下一步,你可以探索 React Server Components 与 Suspense 的结合,那将是未来的趋势。
延伸阅读
- React 官方文档:Concurrent Mode 与 Suspense
- react-query 的 Suspense 模式
- 使用
useTransition实现搜索优化