Suspense 与错误边界深入
面试向讲清 Suspense 与错误边界:子组件 throw Promise 的挂起机制、fallback 与边界位置规则、 已显示内容为何会重新闪 fallback、错误边界能抓和不能抓的四类错误、use() 读取 Promise 的两个硬约束、 React 19 错误处理变化与错误边界重置方案。
一句话概括
Suspense 管”还没好”,错误边界管”出错了”。 它俩是一对,但抓的东西完全不同:
- Suspense 只认
throw出来的 Promise(pending)——挂起,显示 fallback。 - 错误边界只认
throw出来的 Error(reject 或渲染报错)——兜底,显示降级 UI。
一个异步组件的一生需要两个边界兜着:外面套错误边界,里面套 Suspense。面试里这题通常是连环问:“Suspense 怎么实现的?” → “那请求失败了怎么办?” → “错误边界能抓所有错误吗?” 顺着这条链答下来就是满分。
核心知识点
1. Suspense 的机制:throw 一个 Promise
组件的 render 里没有数据怎么办?直接 throw 一个 Promise 出去。React 的 reconciler 在渲染时捕获它,往上找最近的 <Suspense>,显示 fallback;等 Promise resolve,再回来重新渲染一遍。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 手写版:pending 就 throw promise,error 就 throw error,success 就返回
function wrapPromise(promise) {
let status = 'pending';
let result;
promise.then(
r => { status = 'success'; result = r; },
e => { status = 'error'; result = e; }
);
return {
read() {
if (status === 'pending') throw promise; // ← Suspense 就靠这一行
if (status === 'error') throw result; // ← 错误边界就靠这一行
return result;
}
};
}
<Suspense fallback={<Skeleton />}>
<UserProfile /> {/* 里面 read() 一 throw promise,就显示 Skeleton */}
</Suspense>
React 19 之后你不用手写这套了,用 use 直接读 Promise,底层是一样的。
什么会真的触发挂起(官方列举):
lazy()懒加载组件代码use(promise)读取 Promise- 带
precedence属性的<link rel="stylesheet"> - 流式 SSR 中等待某个边界的 HTML
最容易踩的坑:Suspense 感知不到 useEffect 或事件处理函数里发起的请求。 那种写法里组件没有 throw 任何东西,Suspense 根本不会被激活。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 这样写 Suspense 完全不起作用,fallback 永远不会出现
function User() {
const [data, setData] = useState(null);
useEffect(() => { fetchUser().then(setData); }, []); // 组件没挂起,直接渲染了 null
return <div>{data?.name}</div>;
}
// ✅ 用 use 读缓存过的 Promise,才会真正挂起
function User({ userPromise }) {
const data = use(userPromise); // pending → 挂起 → 外层 fallback
return <div>{data.name}</div>;
}
2. fallback 与边界位置的三条规则
- 挂起时用最近的那个父级 Suspense 的 fallback,不是最外层的。所以嵌套边界可以做出”先出骨架、再出头像、再出评论”的渐进效果。
- 一个边界内的整棵树作为一个单元——里面只要有一个子组件挂起,整块都不显示,一起出现。
- fallback 自己挂起的话,会冒泡到上一级 Suspense(所以 fallback 要写成纯静态的骨架,别在里面发请求)。
“已显示的内容再次挂起”是个大坑:如果内容已经显示出来了,然后它又因为新数据挂起了,React 会重新显示 fallback,页面闪一下。解决办法是把它包进 transition:
1
2
3
4
5
// ✅ 用 startTransition / useDeferredValue,React 会保留旧内容,不闪 fallback
const [isPending, startTransition] = useTransition();
startTransition(() => setUserId(nextId));
官方原文:已显示内容的树再次挂起时会再显示 fallback,除非这次更新来自 startTransition 或 useDeferredValue。这也是为什么「支持 Suspense 的路由器」都默认把导航包进 transition。
顺带两个容易忽略的细节:
- React 19.2 起,流式 SSR 会批量揭示挂起内容:两次揭示之间至少间隔 300ms,窗口内就绪的边界一起出现,避免一个个”蹦”出来。
- 内容因为重新挂起被隐藏时,React 会清理那一遍的 layout Effects(
useLayoutEffect),等内容再次显示时重新触发——所以依赖测量 DOM 的 Effect 不会在隐藏状态下跑出错误的数值。
3. 错误边界怎么写:目前只有类组件能写
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import * as React from 'react';
class ErrorBoundary extends React.Component {
state = { hasError: false };
// 必备:渲染出错时改状态,触发下次渲染显示降级 UI
static getDerivedStateFromError(error) {
return { hasError: true };
}
// 可选:副作用,比如上报
componentDidCatch(error, info) {
logToService(error, info.componentStack);
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
// 用法:错误边界在外,Suspense 在内
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<Skeleton />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
错误边界能抓:子组件树在渲染期间、生命周期、构造函数里抛出的错误。
错误边界抓不到(这是必考题,官方列了四类):
| 抓不到的场景 | 例子 | 怎么办 |
|---|---|---|
| 事件处理器 | onClick={() => { throw err }} | 自己 try/catch |
| 异步代码 | setTimeout、requestAnimationFrame、.then() 回调里的错 | 自己 try/catch 或 .catch() |
| 服务端渲染 | SSR 期间的错误 | 交给框架 / onRecoverableError |
| 边界自身抛错 | 边界组件自己 render 里出错 | 再往上套一层边界 |
注意那个例外:startTransition 回调里抛出的错误会被错误边界抓到(官方文档明确写了),因为它是同步调用的。
4. use() 的两个硬约束
React 19 的 use 让”读 Promise”变得很自然,但它有两条不能违反的规矩:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ❌ 坑1:把 use 包在 try-catch 里 —— 会报 "Suspense Exception: This is not a real error!"
// 因为 use 就是靠 throw 来跟 Suspense 通信的,你的 catch 把信号吃掉了
try {
const data = use(promise);
} catch (e) { /* 永远走不到正确的分支 */ }
// ✅ 交给错误边界,或者提前 .catch() 把 reject 转成正常值
const data = use(promise.catch(() => ({ name: '加载失败' })));
// ❌ 坑2:每次渲染都新建 Promise —— 无限挂起 / 死循环
function Albums() {
const data = use(fetch('/api/albums')); // 每次渲染都是新 Promise
}
// ✅ 把 Promise 缓存起来,同一个 key 永远返回同一个 Promise 实例
const cache = new Map();
function getAlbums(key) {
if (!cache.has(key)) cache.set(key, fetch(`/api/${key}`).then(r => r.json()));
return cache.get(key);
}
Promise 必须缓存,保证跨渲染复用同一个实例。实际项目里直接用 TanStack Query / SWR 的 suspense 模式,它们内置了缓存。
5. React 19 的错误处理变化
- 渲染错误不再被 rethrow。 React 18 时一个错误会打三条日志(原始错误 + 重新抛出 + componentStack),19 只打一条合并后的。
- 未被捕获的错误上报给
window.reportError(不再重复抛)。 - 新增两个 root 选项,和原有的
onRecoverableError一起,把全局错误上报收口到一个地方:
1
2
3
4
5
createRoot(container, {
onCaughtError: (error, info) => {}, // 被错误边界捕获的
onUncaughtError: (error, info) => {}, // 没有任何边界接住的
onRecoverableError: (error, info) => {} // React 自动恢复的(如 hydration 不匹配)
});
注意传参位置:root.render(App, { onUncaughtError }) 是错的,选项要给 createRoot。
6. 错误边界怎么”重置”
边界一旦抓到错,子树就被替换成降级 UI 了,它不会自己恢复。两种常用办法:
1
2
3
4
5
6
7
8
9
10
11
// 办法一:key 变了就强制重挂载,错误状态自然清空
<ErrorBoundary key={userId} fallback={<ErrorView />}>
<UserProfile userId={userId} />
</ErrorBoundary>
// 办法二:用 react-error-boundary 的 resetKeys / resetErrorBoundary
<ErrorBoundary resetKeys={[userId]} FallbackComponent={ErrorFallback}>
<UserProfile userId={userId} />
</ErrorBoundary>
重置时要记得把对应的数据缓存也失效掉,否则重试还是读到同一个 reject 的 Promise,会再次立刻报错。
其实你每天都在用
- 路由懒加载:
lazy(() => import('./Page'))+ Suspense,首屏 JS 直接瘦身。 - 骨架屏:Suspense 的 fallback 放骨架屏而不是转圈,体验好一个档次。
- 后台管理多卡片独立 loading:每张卡片套自己的边界,谁先好谁先出,不用等最慢那个。
- 图表 / 富文本 / 编辑器等重型组件分包:点开才加载,配 Suspense 占好位置防布局跳动。
- 整页兜底 + 重试按钮:错误边界里放一个”重试”,比白屏强一百倍。
- Next.js App Router:
loading.tsx本质就是 Suspense 边界,error.tsx就是错误边界,框架帮你套好了。
常见误解(FAQ)
❌ 误区1:”Suspense 能捕获请求失败。” 不能。Suspense 只处理 pending(throw 出来的 Promise)。Promise reject 之后抛的是 Error,会穿过 Suspense 一路冒泡到最近的错误边界。所以两个边界必须配套用。
❌ 误区2:”用了 Suspense 就不用自己写 loading 状态了。” 取决于你在哪发请求。useEffect 里发的请求 Suspense 完全感知不到,fallback 不会出现。只有 lazy 和 use(promise) 这类”渲染期间 throw”的写法才被它管。
❌ 误区3:”错误边界能抓住组件里所有的异常。” 只有渲染期的。事件处理器、setTimeout 回调、.then() 里的错它一个都抓不到——那些得自己 try/catch。这是面试里问错误边界时最想听到的那句。
❌ 误区4:”use 可以用 try-catch 包起来处理 reject。” 不行,官方会直接报 Suspense Exception: This is not a real error!。use 靠 throw 工作,try-catch 会吞掉挂起信号。要么交给错误边界,要么在 Promise 上先 .catch() 兜底。
❌ 误区5:”SSR 时服务端报错,错误边界会立刻渲染降级 UI。” 不一定。流式 SSR 下服务端组件抛错,React 会先发 Suspense 的 fallback HTML 给客户端;客户端 hydration 后重试渲染,重试还失败才会走到错误边界。也就是说错误边界接住的是”客户端重试那一次”,SSR 那一次它压根没参与——所以错误可能要到水合之后才冒出来,服务端日志和用户看到的东西对不上。
❌ 误区6:”函数组件也能写错误边界。” 目前官方没有 Hooks 等价物(getDerivedStateFromError / componentDidCatch 只有类组件有)。要么写类组件,要么用 react-error-boundary。
一句话总结
Suspense 只接 Promise、错误边界只接 Error,异步组件的标配是「错误边界在外、Suspense 在内」;记住错误边界抓不到事件处理器和 setTimeout 里的错,use() 不能包 try-catch 且 Promise 必须缓存,这题就答全了。