-
Notifications
You must be signed in to change notification settings - Fork 0
Description
什么是 Suspense
Suspense 允许组件在渲染时“暂停”,告诉 React:“我还没准备好(还在加载数据或代码),先帮我显示个占位符(Fallback),等我好了你再回来渲染我。”
初识 Suspense —— 代码分割 (React 18 之前)
Suspense 在 React 16.6 就可以部分使用, 一开始通常用 Suspense 配合 React.lazy 来做代码分割(Code Splitting)
为什么要这么做?
现代前端应用体积越来越大。如果用户只访问首页,却被迫下载了“个人中心”、“后台管理”等所有页面的代码,首屏加载会非常慢。
React.lazy 允许我们将组件拆分成独立的文件,只有当组件真正需要渲染时,才去网络请求下载代码。而 Suspense 就负责在下载期间显示“加载中...”。
代码例子
import { Suspense, useState, lazy } from 'react';
// --- 模拟部分 ---
// 在真实项目中,这里通常是: const LazyComponent = lazy(() => import('./MyComponent'));
// 这里=手动构造一个 Promise 来模拟 2秒 的网络加载延迟
const LazyHeavyComponent = lazy(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
default: () => (
<div>
<b>🎉 加载成功!</b><br />
我是体积很大的组件,但我只有在被需要时才会被下载。
</div>
)
});
}, 2000);
});
});
// --- 主应用部分 ---
export default function StageOneDemo() {
const [show, setShow] = useState(false);
return (
<div>
<p>点击下方按钮,模拟从服务器动态加载一个组件。</p>
<button
onClick={() => setShow(true)}
>
🚀 点击加载组件
</button>
<div style={{ border: '2px dashed #ccc', padding: '20px', minHeight: '100px' }}>
{/*
核心知识点:
1. 当 show 为 true,React 尝试渲染 LazyHeavyComponent。
2. 发现它还没加载好(Promise pending),React 会向上寻找最近的 Suspense。
3. 渲染 Suspense 的 fallback 内容。
4. Promise resolve 后,自动切换为真实组件。
*/}
{show && (
<Suspense fallback={<div style={{ color: 'blue' }}>⏳ 正在通过网络下载组件代码...</div>}>
<LazyHeavyComponent />
</Suspense>
)}
</div>
</div>
);
}核心解析
- lazy(...): 它定义了一个动态组件。React 在初次渲染时不会去执行里面的 import,直到该组件被放置在 JSX 中。
- 抛出 Promise: 当 React 试图渲染 LazyHeavyComponent 时,发现它还在加载中,React 内部会“捕获”到这个未完成的状态(本质上是捕获了一个 Promise)。
- fallback 上场: 因为捕获到了等待状态,React 暂停渲染子组件,转而渲染 Suspense 提供的 fallback UI。
Tip
Suspense 的**“聚合”**特性。
Suspense 会等待其边界内所有的异步操作(Promise)都 Resolve 之后,才会统一展示内容。只要其中有一个还在加载,用户看到的就是 fallback
进阶 Suspense —— 异步数据流 (React 18)
React 18 中, 要让普通的 fetch 支持 Suspense, 我们需要一个特殊的 Resource(资源)读取器。
原理是:当组件读取数据时,如果数据没好,就 throw 一个 Promise 出去(就像抛出错误一样),React 捕获到这个 Promise 后就会显示 Fallback。
代码例子
import { Suspense, useState } from 'react';
function fetchUser() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ name: 'GG Bond', role: 'Assistance' })
}, 2000)
})
}
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 // 关键:抛出 Promise,让 Suspense 捕获!
} else if (status === 'error') {
throw result // 抛出错误
} else {
return result; // 返回数据
}
}
}
}
function UserProfile({ resource }) {
const user = resource.read()
return (
<div>
<h3>用户档案</h3>
<p>姓名: {user.name}</p>
<p>角色: {user.role}</p>
</div>
)
}
export default function Demo() {
const [resource] = useState(() => wrapPromise(fetchUser()))
return (
<div>
<Suspense fallback={<div style={{ color: 'orange' }}>📡 正在从服务器获取用户数据...</div>}>
<UserProfile resource={resource} />
</Suspense>
</div>
)
}核心解析
- 声明式编程:
以前我们写 if (loading) return <Spinner />。
现在 UserProfile 组件里完全没有加载状态的判断逻辑,它假定数据已经存在。
- throw Promise:
这是 Suspense 的黑魔法。resource.read() 在数据没回来时,像抛出异常一样抛出了一个 Promise。React 捕获到它,就知道:“哦,这个组件需要暂停”,于是去渲染最近的 Suspense fallback。
Tip
Suspense 数据获取的一个核心原则:Render-as-you-fetch(边渲染边获取)
在 React 18 中,我们需要保证 resource 是稳定的。
function UserProfile() {
// ❌ 错误写法:每次渲染都创建一个新的请求
const resource = wrapPromise(fetchUser());
const user = resource.read();
return <div>{user.name}</div>;
}这种写法会造成死循环
第一次渲染:
- React 执行 UserProfile。
- 执行 fetchUser(),发起一个新的网络请求(请求 A)。
- 执行 resource.read()。因为请求 A 刚发出去,状态是 pending。
- read() 抛出 Promise。
- React 捕获 Promise,**挂起(Suspend)**该组件,显示 Fallback。
Promise 解决(Resolve):
- 2秒后,请求 A 完成。
- React 收到通知:“嘿,刚才那个让组件挂起的 Promise 解决了!”
- React 决定:重新渲染 UserProfile 组件,试图显示真实内容。
第二次渲染(死循环开始):
- React 再次执行 UserProfile 函数。
- 🚨 关键点:因为变量定义在函数内部,代码再次执行 const resource = wrapPromise(fetchUser())。
- 发起了一个全新的网络请求(请求 B)!
- 执行 resource.read()。因为请求 B 是新的,状态又是 pending。
- read() 再次抛出新的 Promise。
- React 再次挂起组件...
结果:请求完成 -> 重渲染 -> 发起新请求 -> 挂起 -> 请求完成 -> 重渲染... 无限循环。
正确写法汇总
正确的写法 A:在组件外部定义(仅适用于全局静态数据)
如果数据是固定的,可以在组件外部定义。但在实际应用中很少见,因为数据通常是动态的。
// 可行,但仅限于非动态数据
const resource = wrapPromise(fetchUser());
function Parent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserComponent resource={resource} />
</Suspense>
);
}正确的写法 B:在 useEffect 中设置的 State
function Parent() {
// 1. 初始化为空
const [resource, setResource] = useState(null);
useEffect(() => {
// 2. 这里只执行一次
const newResource = wrapPromise(fetchUser());
setResource(newResource);
}, []); // <--- 空数组依赖,保证了只在组件挂载(Mount)时执行
// ...
}这是由 useEffect 的机制决定的。
- 依赖数组 []:告诉 React,“这段代码只在组件第一次出现在屏幕上时运行”。
- 后续渲染:当父组件因为其他原因(比如 props 变化)重新渲染时,React 会检查 useEffect 的依赖数组。发现依赖没变(还是空的),React 就直接跳过 useEffect 里面的代码。
- State 的保持:useState 会从 React 的内部存储(Fiber Node)中取出之前存进去的 resource 对象返回给你,而不是重新生成。
正确写法 C: “惰性初始化”(Lazy Initialization)
function Parent() {
// ✅ 正确:传入一个函数 () => ...
const [resource] = useState(() => {
return wrapPromise(fetchUser());
});
// ...
}这是 useState 的特有机制:
- 首次渲染:React 发现你传给 useState 的是一个函数。React 会调用这个函数,拿到返回值(resource),并将其存入内存。
- 后续渲染:父组件再次执行 Parent() 函数。代码依然会走到这一行。但是,React 内部逻辑是:“我已经有这个 State 的值了,我不需要也不会再次调用那个初始化函数”。React 直接把内存里存好的那个 resource 返回给你。
正确写法 D: 最佳实践:Render-as-you-fetch (在事件或路由中触发)
React 团队最推荐的模式是:尽早开始获取数据。通常是在用户交互(点击按钮)或路由跳转时,就开始创建 resource,然后将其传递给组件。
// 模拟一个路由或页面容器
const initialResource = wrapPromise(fetchUser());
function App() {
const [resource, setResource] = useState(initialResource);
const [isPending, startTransition] = useTransition();
function handleNextUser() {
startTransition(() => {
// ✅ 在交互发生时立即开始获取新数据,并更新 state
setResource(wrapPromise(fetchNextUser()));
});
}
return (
<div>
<button onClick={handleNextUser} disabled={isPending}>
Next User
</button>
{/* resource 被传递给子组件 */}
<Suspense fallback={<div>Loading...</div>}>
<UserComponent resource={resource} />
</Suspense>
</div>
);
}
// 子组件只负责“读取”
function UserComponent({ resource }) {
const user = resource.read(); // 如果没准备好,这里会 throw promise
return <h1>{user.name}</h1>;
}补充说明:为什么写法 D 优于 B 和 C?
虽然 B (useEffect) 和 C (lazy init) 都能工作,但它们都属于组件加载后/加载时才发起请求。
- 写法 B (useEffect):
父组件 Render -> 2. 父组件 Mount -> 3. 开始请求 -> 4. 等待数据 -> 5. 子组件 Render
(这是最慢的,有明显的瀑布流)
- 写法 D (Best Practice):
点击按钮/路由跳转 -> 2. 开始请求 (同时) -> 3. 父组件 Render -> 4. 子组件 Render (Suspend)
(请求和渲染是并行的,用户体验最好)
Note
瀑布流 (Waterfall) = 排队。A 做完 B 才能做。
在图表上,这看起来就像楼梯台阶一样,一级一级往下掉,形状非常像瀑布。
请求 A: [==========]
请求 B: [==========]
请求 C: [==========]
总耗时: A + B + C (非常慢)
Tip
如果你写成了下面这样,虽然用了 useState,但依然会造成重复请求:
function Parent() {
// ❌ 错误:直接把函数调用结果传进去
// JavaScript 语法决定了:在调用 useState 之前,必须先执行 wrapPromise(fetchUser())
// 以便拿到结果作为参数传给 useState。
const [resource] = useState(wrapPromise(fetchUser()));
// ...
}为什么这是错的?
虽然 React State 本身没有变(React 会忽略后续渲染传入的初始值),但是:
JS 执行顺序:在进入 useState 内部逻辑之前,wrapPromise(fetchUser()) 已经在每一轮渲染中都被执行了。
后果:这意味着你每一次渲染都在发起新的网络请求(fetchUser 被调用了)。虽然 React 没有使用这个新请求的结果来更新 State,但请求已经发出去了,这极其浪费资源。
进阶 Suspense —— 错误边界 (ErrorBoundary)
既然 Suspense 处理了“加载中”的状态,那“加载失败”怎么办?比如:网断了,或者 API 返回 500 错误。
在 Suspense 模式下,数据获取的错误也是通过 throw 抛出来的(还记得 wrapPromise 里的 throw result 吗?)。
普通的 try/catch 无法捕获组件渲染过程中的错误,我们需要使用 Error Boundary(错误边界)。
代码例子
import { Suspense, Component } from 'react';
// --- 1. 模拟一个必定失败的请求 ---
function fetchBadData() {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('服务器爆炸了'))
}, 1500)
})
}
function wrapPromise(promise) {
let status = 'pending'
let result
let suspender = promise.then(
r => {
status = 'success'
result = r
},
e => {
status = 'fail'
result = e
}
)
return {
read() {
if (status === 'pending') {
throw suspender
}
if (status === 'fail') {
throw result
}
if (status === 'success') {
return result
}
}
}
}
const resource = wrapPromise(fetchBadData())
// --- 2. 错误边界组件 (目前必须用 Class 组件) ---
class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
// 静态方法:从错误中更新 state
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
// 可以在这里打印日志
componentDidCatch(error, errorInfo) {
console.log('捕获到错误', error, errorInfo)
}
render() {
// 自定义降级 UI
if (this.state.hasError) {
return (
<div>
<h3>⚠️ 哎呀,出错了!</h3>
<p>{this.state.error.message}</p>
</div>
)
}
return this.props.children;
}
}
// --- 3. 数据组件 ---
function BadComponent() {
resource.read(); // 这里会抛出 Error
return <div>这也永远不会显示</div>;
}
export default function Demo() {
return (
<div>
<p>模拟请求失败的场景:</p>
<div>
{/*
层级结构:
ErrorBoundary (处理失败)
-> Suspense (处理加载中)
-> Component (正常显示)
*/}
<ErrorBoundary>
<Suspense fallback={<div style={{ color: 'blue' }}>⏳ 正在尝试连接服务器...</div>}>
<BadComponent />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}核心原理解析
这构成了 React 异步 UI 的完整形态:
- Pending (加载中) -> 由 Suspense 处理。
- Error (失败) -> 由 ErrorBoundary 处理。
- Success (成功) -> 组件自身渲染。
这种结构将 UI 逻辑与状态处理逻辑彻底解耦,代码非常干净。
Tip
在普通的 React 组件交互中,我们习惯看到 this.setState(...) 来触发更新。但在 Error Boundary(错误边界)中,状态的更新机制略有不同。
这两个值的更新来源是代码中的这个静态生命周期方法:
static getDerivedStateFromError(error) {
// 👇 这里返回的对象,会被 React 自动合并到 this.state 中
return { hasError: true, error }
}
它是如何工作的?
React 内部有一套专门处理错误的机制,流程如下:
- 子组件抛出错误:
BadComponent 执行 resource.read(),抛出了一个异常(Error)。 - React 捕获异常:
React 框架捕获到这个异常,发现父组件 ErrorBoundary 定义了 getDerivedStateFromError 方法。 - 自动调用静态方法:
React 自动调用 这个静态方法,并将捕获到的 error 作为参数传进去。
注意:这是由 React 调用的,不是你手动调用的。 - 状态合并(关键点):
这个方法返回了一个对象 { hasError: true, error }。
React 会拿到这个返回的对象,自动执行类似 setState 的操作,将其合并到组件的当前 state 中。 - 触发重新渲染:
由于 state 变了(hasError 变成了 true),React 会触发 ErrorBoundary 的 render 方法重新执行。 - 显示降级 UI:
在 render 中,if (this.state.hasError) 判断为真,于是渲染了报错提示 UI。
Tip
在实际开发中,绝大多数开发者会使用 react-error-boundary 这个库。它在内部封装好了 Class 组件,但对外提供了非常现代化的、基于 Props 的 API,让你在函数组件中使用起来感觉像是在用原生 Hook 一样自然。
import { ErrorBoundary } from "react-error-boundary";
function FallbackComponent({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
FallbackComponent={FallbackComponent}
onReset={() => {
// 重置应用状态
}}
>
<MyComponent />
</ErrorBoundary>
);
}拥抱未来 —— React 19 的新特性 (use API)
在 React 18 中,我们必须自己写 wrapPromise 或者依赖第三方库。React 19 引入了一个新的 Hook:use。
use API 可以直接在组件内部“解包”一个 Promise。如果 Promise 还没完成,它会自动触发 Suspense;如果失败了,它会自动触发 ErrorBoundary。
最大的改变:你再也不用写那个复杂的 wrapPromise 函数了!代码量直接减半。
正确写法汇总
在 React 19 中,use API 的引入极大地简化了 Suspense 的使用,但它并没有改变一个核心原则:你仍然需要自己管理 Promise 的生命周期(创建与缓存)。
推荐模式 A:Promise 存放在 State 中 (手动管理)
import { Suspense, useState, use } from 'react';
// 1. 定义数据获取函数(返回 Promise)
const fetchUser = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ name: 'Tom' })
}, 2000)
})
}
function UserProfile({ userPromise }) {
// 3. 使用 `use` 解包 Promise
// 如果 Promise 还没 resolve,这里会自动 Suspend
// 如果 reject,这里会自动 throw Error (给 ErrorBoundary)
const user = use(userPromise)
return <h1>{user.name}</h1>
}
export default function Demo() {
const [userPromise, setUserPromise] = useState(null)
const handleLoad = () => {
// 2. 点击时创建 Promise,并存入 State
// 注意:这里存的是 Promise 对象本身,而不是数据结果!
setUserPromise(fetchUser(1));
}
return (
<div>
<button onClick={handleLoad}>Load User</button>
{/* 只有当 promise 存在时才渲染子组件 */}
{
userPromise && (
<Suspense fallback={<p>Loading</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
</div>
)
}推荐模式 B:Promise 来自 Props (RSC 混合模式)
如果你在使用 Next.js (App Router) 等支持 RSC 的框架,这是最常见的模式。
- 服务端组件负责创建 Promise。
import { Suspense } from 'react'
import UserProfile from './user'
export default function Page() {
// 在服务端创建一个 Promise, 但是不 await 它
const userPromise = new Promise((resolve) => {
setTimeout(() => {
resolve({ name: 'Tom' })
}, 2000)
})
return (
<Suspense fallback={<p>Loading</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}- 客户端组件负责用 use 消费它。
'use client'
import { use } from 'react'
export default function UserProfile({ userPromise }) {
const user = use(userPromise)
return <div>{user.name}</div>
}推荐模式 C:使用第三方库 (TanStack Query / SWR)
在生产环境的纯客户端应用(CSR)中,手动管理 Promise 的缓存(如模式 A)是非常痛苦的(你需要处理竞态条件、缓存失效、重复请求等)。
虽然 React 19 有了 use,但TanStack Query 或 SWR 依然是最佳选择。React 19 的 use 更多是给库作者用的底层原语。
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchTodos, type Todo } from './api'
function TodoList({ query }: { query: UseQueryResult<Todo[]> }) {
const data = React.use(query.promise)
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
export function App() {
const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
return (
<>
<h1>Todos</h1>
<React.Suspense fallback={<div>Loading...</div>}>
<TodoList query={query} />
</React.Suspense>
</>
)
}