大多数人对 Suspense 的理解是:
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
组件没数据 → 显示 Loading → 数据到了 → 显示真实内容。
这个理解没有错,但它是 Suspense 最浅的一层。
Suspense 的真正设计意图是:
协调"数据什么时候到"和"UI 什么时候揭示"这两个独立问题。
它不是 loading 状态管理。它是 React 并发模型的核心原语之一——和 Fiber、Scheduler、Lane 同等重要。
一、Thenable 协议:Suspense 的捕获机制
Suspense 的捕获机制非常简单:组件 throw 一个 thenable,Suspense 边界捕获它。
function UserProfile({ userId }) {
const data = fetchData(userId) // 返回 thenable 或真实数据
if (!data) {
throw fetch(userId) // throw thenable → suspend
}
return <div>{data.name}</div>
}
React 不关心你的 thenable 从哪来。它只检查:
// React 内部的判断逻辑
if (typeof value === 'object' && value !== null && typeof value.then === 'function') {
// 这是一个 thenable,组件在 suspend
}
任何实现了 .then() 方法的对象都可以:
// Promise
throw fetch('/api/user')
// 自定义 thenable
throw {
then(resolve, reject) {
setTimeout(() => resolve(data), 1000)
},
}
// React Query 的 promise
throw queryClient.fetchQuery({ queryKey: ['user', id], queryFn: fetchUser })
这就是为什么 Suspense 能和任何数据源配合——React 只定义协议,不绑定实现。
二、Fiber 层:suspend 发生了什么
当组件 throw thenable 时,React 在 Fiber 层做了什么?
2.1 捕获与标记
// React 内部(简化)
function throwException(root, returnFiber, sourceFiber, value) {
// value 是 thenable
sourceFiber.flags |= Incomplete
// 找到最近的 Suspense 边界
let suspenseBoundary = getNearestSuspenseBoundary(returnFiber)
if (suspenseBoundary) {
// 挂起:把 thenable 挂到 Suspense Fiber 上
suspenseBoundary.memoizedState = {
dehydrated: null,
retries: { lanes: NoLanes, pingCache: null },
// 核心:pending 请求列表
pendingRequests: createThenableState(value),
}
}
}
关键点:thenable 被记录在 Suspense Fiber 的 memoizedState 上,不是组件自己的 Fiber。
2.2 暂停分支
Root
├── App
│ ├── Header ← 继续渲染
│ └── Suspense (fallback={<Loading />})
│ └── UserProfile ← throw thenable
│ └── (暂停,不继续向下)
React 暂停 Suspense 边界内的整个子树。不是只暂停 throw 的那个组件,而是整个子树。
为什么?因为子树里的其他组件可能依赖这个数据。如果继续渲染,会产生不一致的 UI。
2.3 渲染 fallback
暂停边界内的子树后,React 渲染 Suspense 的 fallback:
Root
├── App
│ ├── Header ← 正常渲染
│ └── Suspense
│ └── Loading ← 渲染 fallback
用户看到的是:Header 正常显示,内容区域显示 Loading。
三、恢复机制:从 Promise resolve 到 UI 切换
thenable resolve 后,React 怎么知道要重新渲染?
3.1 注册回调
// React 内部
function trackUsedThenable(thenableState, thenable) {
// 1. 记录 thenable
// 2. 注册 .then 回调
thenable.then(
(value) => {
// resolve 时触发
pingSuspendedRoot(root, thenableState)
},
(error) => {
// reject 时触发(进入 Error Boundary)
},
)
}
3.2 pingSuspendedRoot
function pingSuspendedRoot(root, thenableState) {
// 获取之前暂停时记录的 lane
const pingCache = root.pingCache
const threadIDs = pingCache.get(thenable)
// 用之前记录的 lane 重新触发更新
markRootPinged(root, pingedLanes)
// 确保 root 有 work
ensureRootIsScheduled(root)
}
关键:用暂停时记录的 lane 重新触发更新。这意味着恢复时的优先级和暂停时一样——如果是 Transition 触发的 suspend,恢复也是 Transition 优先级。
3.3 microtask 还是 macrotask?
Promise.resolve().then() 是微任务。React 的恢复回调也是通过微任务触发的。
Promise resolve
↓
.then() 回调(微任务)
↓
pingSuspendedRoot
↓
确保 root 有 work
↓
Scheduler 调度(宏任务)
↓
重新渲染
为什么用微任务?因为 Promise resolve 后,微任务在当前任务结束后立即执行,不会被其他宏任务打断。这保证了恢复是即时的——数据一到,UI 就更新。
四、边界语义:嵌套与独立
4.1 Suspense 是"数据边界",不是"错误边界"
<ErrorBoundary fallback={<Error />}>
{' '}
{/* 捕获 throw Error */}
<Suspense fallback={<Loading />}>
{' '}
{/* 捕获 throw thenable */}
<UserProfile />
</Suspense>
</ErrorBoundary>
两个边界,各管各的:
- throw Error → ErrorBoundary 捕获
- throw thenable → Suspense 捕获
- 如果 Suspense 内部 throw Error → 穿透 Suspense,被 ErrorBoundary 捕获
4.2 嵌套 Suspense = 渐进式揭示
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</Suspense>
每个边界独立工作:
时间线:
T0: 开始渲染
→ Header 立即显示
→ MainContent suspend → 显示 ContentSkeleton
→ Comments suspend → 显示 CommentsSkeleton
T1: MainContent 数据到了
→ MainContent 切换到真实内容
→ Comments 仍然显示 skeleton(不受影响)
T2: Comments 数据到了
→ Comments 切换到真实内容
一个慢了不阻塞另一个。这就是 Progressive Disclosure。
4.3 最近边界原则
<Suspense fallback={<Outer />}>
<Suspense fallback={<Inner />}>
<Component /> {/* suspend → 显示 Inner,不是 Outer */}
</Suspense>
</Suspense>
React 向上找最近的 Suspense 边界。嵌套时内层优先。
五、并发交互:Transition 里的 Suspense
这是 Suspense 最精妙的设计之一。
5.1 普通 setState 触发的 Suspense
function SearchResults() {
const [query, setQuery] = useState('react')
return (
<div>
<input onChange={(e) => setQuery(e.target.value)} />
<Suspense fallback={<Loading />}>
<Results query={query} />
</Suspense>
</div>
)
}
用户输入 → setQuery → Results suspend → 显示 Loading → 旧结果消失。
用户体验:每次输入都闪一下 Loading。
5.2 Transition 触发的 Suspense
function SearchResults() {
const [query, setQuery] = useState('react')
const [isPending, startTransition] = useTransition()
function handleSearch(e) {
// 紧急:输入框立即响应
setQuery(e.target.value)
// 非紧急:搜索结果可以等
startTransition(() => {
setResults(search(e.target.value))
})
}
return (
<div>
<input onChange={handleSearch} />
<Suspense fallback={<Loading />}>
<Results />
</Suspense>
</div>
)
}
关键:startTransition 里的 setState 触发的 Suspense 不会显示 fallback。
T0: 用户输入 "abc"
→ setQuery("abc") → 输入框立即更新
→ startTransition → setResults(suspend)
→ Results 仍然显示旧结果(不显示 Loading)
T1: 搜索结果数据到了
→ Results 切换到新结果
旧结果一直在,直到新结果准备好。没有闪烁。
5.3 为什么 Transition 能做到这个?
因为 Suspense 边界有一个内部状态:fallback 和 primary。
普通更新触发 suspend:
边界状态 → fallback
用户看到 → Loading
Transition 触发 suspend:
边界状态 → 保持 primary
用户看到 → 旧内容
React 在 Transition 模式下告诉 Suspense 边界:"先别切换,等等看数据能不能很快到。"
如果数据很快到了(一帧内),直接切换,用户看不到任何中间状态。
如果数据很慢,React 会根据 isPending 让你决定是否显示 loading indicator。
5.4 useTransition vs useDeferredValue
两者都和 Suspense 配合,但切入点不同:
// useTransition:控制 setState 的优先级
startTransition(() => {
setResults(search(query)) // 这个 setState 是 Transition 优先级
})
// useDeferredValue:创建一个"延迟"版本的值
const deferredQuery = useDeferredValue(query)
// deferredQuery 是 query 的延迟版本
// React 在空闲时用 deferredQuery 渲染
useDeferredValue 适合你控制不了触发更新的场景(比如 props 传进来的值)。
两者本质相同:告诉 React "这个更新不紧急,Suspense 别急着切换 fallback"。
六、RSC 交互:Suspense 与流式渲染
Suspense 不只是客户端机制。在 RSC 场景下,它是服务端流式渲染的协调器。
6.1 传统 SSR 的问题
服务端:
渲染整棵树(必须等最慢的数据查询完成)
↓
一次性发送 HTML
↓
客户端:
接收完整 HTML → 显示
Hydrate → 可交互
问题:一个慢查询阻塞整棵树。用户看到白屏直到所有数据都准备好。
6.2 RSC + Suspense 的流式渲染
服务端:
<App>
<Header /> ← 立即序列化发送
<Suspense fallback={<Skeleton />}>
<SlowContent /> ← 数据还没到,发占位符
</Suspense>
</App>
客户端(第一帧):
Header 显示
SlowContent 区域显示 Skeleton
服务端(数据到了):
Progressive JSON 补发 SlowContent 的 JSX
客户端(收到后):
Suspense 切换 → SlowContent 显示真实内容
一个慢查询不阻塞其他部分。用户立即看到 Header,SlowContent 区域先显示骨架屏,数据到了再切换。
6.3 Progressive JSON
RSC 不发送 HTML,发送的是渐进式 JSON:
第一帧(外壳立刻到达):
{ header: "$1", content: "$2", comments: "$3" }
第二帧(content 结构出来了):
/* $2 */ { body: "$4", sidebar: "$5" }
第三帧(comments 填充完毕):
/* $5 */ ["$6", "$7", "$8"]
占位符 $2 是一个 Promise,数据到了自动填充。一个慢的部分不阻塞其他部分。
Suspense 控制用户看到什么,和数据传输是解耦的。
七、缓存交互:Suspense 本身无缓存
Suspense 的一个常见误解:它提供缓存。
不。Suspense 本身不提供任何缓存。
// 没有缓存的情况
function UserProfile({ userId }) {
const data = fetchUser(userId) // 每次渲染都 fetch
return <div>{data.name}</div>
}
// 用户离开再回来 → 又 suspend → 又 fetch → 又显示 Loading
Suspense 只负责"throw thenable → 显示 fallback → thenable resolve → 切换回来"。缓存是你的责任。
7.1 与 React Query 的配合
function UserProfile({ userId }) {
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
suspense: true, // 启用 Suspense 模式
})
return <div>{data.name}</div>
}
React Query 提供缓存 + Suspense 支持。它内部的逻辑:
// 简化
function useQuery(options) {
const cached = queryCache.get(options.queryKey)
if (cached && !cached.isStale) {
return { data: cached.data } // 缓存命中,不 suspend
}
// 缓存未命中或过期
const promise = fetchAndCache(options)
throw promise // suspend
}
第一次:缓存未命中 → throw promise → suspend → 显示 Loading 第二次:缓存命中 → 直接返回数据 → 不 suspend
7.2 自己实现 cache-first Suspense
const cache = new Map()
function UserProfile({ userId }) {
const cached = cache.get(userId)
if (cached) {
return <div>{cached.name}</div>
}
// 没有缓存,throw thenable
throw fetchUser(userId).then((data) => {
cache.set(userId, data) // 缓存结果
return data
})
}
关键是:cache 在 Suspense 组件外部。组件重新渲染时检查 cache,命中就不 suspend。
八、实战模式
8.1 搜索框:Transition + Suspense
function SearchPage() {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
return (
<div>
<input
value={query}
onChange={(e) => {
setQuery(e.target.value) // 紧急:输入框立即响应
startTransition(() => {
// Transition 触发的 suspend 不会显示 fallback
// 旧结果保持显示,直到新结果准备好
})
}}
/>
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<Suspense fallback={<Spinner />}>
<SearchResults query={query} />
</Suspense>
</div>
</div>
)
}
isPending 告诉你"有 transition 正在进行"。你可以用它做视觉反馈(比如降低透明度),但不会闪 Loading。
8.2 表单提交:useOptimistic + Suspense
function CommentForm({ postId }) {
const [optimisticComments, addOptimistic] = useOptimistic(comments, (prev, newComment) => [
...prev,
{ ...newComment, pending: true },
])
async function handleSubmit(formData) {
addOptimistic({ text: formData.get('text') }) // 乐观更新
startTransition(async () => {
await submitComment(postId, formData)
// 乐观值自动替换为真实值
})
}
return (
<>
<Suspense fallback={<CommentsSkeleton />}>
<CommentList comments={optimisticComments} />
</Suspense>
<form action={handleSubmit}>
<textarea name="text" />
<button type="submit">发送</button>
</form>
</>
)
}
乐观更新立即显示,不需要等服务端响应。如果失败,自动回滚。
8.3 数据预加载
// 预加载:用户 hover 时就开始 fetch
function UserLink({ userId }) {
return (
<Link
href={`/user/${userId}`}
onMouseEnter={() => {
// 预加载数据,不 suspend
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
}}
>
User {userId}
</Link>
)
}
// 目标页面:数据已经在缓存里,不 suspend
function UserProfile({ userId }) {
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
suspense: true,
})
return <div>{data.name}</div>
}
用户 hover 时数据就开始加载。点击进入页面时,数据已经在缓存里,不 suspend,直接显示。
九、未来方向:Activity / Offscreen
React 19+ 引入了 <Activity> 组件(之前叫 <Offscreen>):
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
</Activity>
9.1 "隐藏但不卸载"
传统模式:
用户离开 Tab → 卸载组件 → 状态丢失
用户回到 Tab → 重新挂载 → 重新 fetch → 重新渲染
Activity 模式:
用户离开 Tab → 隐藏组件 → 状态保留 → DOM 可能被回收
用户回到 Tab → 显示组件 → 状态恢复 → 不需要重新 fetch
9.2 与 Suspense 的交互
<Activity mode="hidden">
<Suspense fallback={<Loading />}>
<UserProfile userId={1} />
</Suspense>
</Activity>
当 Activity mode="hidden" 时:
- 如果 UserProfile 已经加载完成 → 保持渲染结果
- 如果 UserProfile 还在 suspend → 继续等待数据,不显示 fallback
这意味着:用户切换到其他 Tab 时,后台数据加载继续进行。用户切回来时,数据已经准备好了。
十、总结:Suspense 的设计哲学
Suspense 解决的核心问题是:
协调"数据什么时候到"和"UI 什么时候揭示"这两个独立问题。
它不是 loading 状态管理。它是:
| 角色 | 说明 |
|---|---|
| 协议 | Thenable 协议,任何数据源都能配合 |
| 边界 | 数据边界,捕获 throw thenable |
| 协调器 | 协调数据获取和 UI 揭示 |
| 并发原语 | 与 Transition 配合,实现"旧内容保持" |
| 流式渲染器 | 与 RSC 配合,实现服务端流式渲染 |
最终回到五个 Law:
- Law 1 (Environment):Suspense 在 Server 和 Client 都能工作
- Law 2 (Description):Suspense 控制"什么时候展示什么 Description"
- Law 3 (Ownership):Suspense 边界拥有子树的渲染生命周期
- Law 4 (Lane):Transition 里的 Suspense 保持当前 UI,不切换 fallback
- Law 5 (Cost):Suspense 让用户立即看到可以看的部分,而不是等所有数据
Suspense 的最高境界不是"显示 Loading"。
而是:让用户尽可能快地看到有意义的内容,同时避免不必要的中间状态闪烁。