学会这套组合后,你能让页面在接口挂了、组件崩了、懒加载失败时依然可用,而不是直接白屏。
第一步:先写一个能复用的错误边界
错误边界只能是 class 组件(函数组件目前没有官方等价物),核心是两个方法:`getDerivedStateFromError` 负责改状态渲染降级 UI,`componentDidCatch` 负责上报日志。
import React from 'react';
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, info) {
// 这里做副作用:上报、打点
console.error('边界捕获:', error, info.componentStack);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return this.props.fallback(this.state.error, this.reset);
}
return this.props.children;
}
}
用的时候传一个 render 函数而不是 JSX 片段,这样降级 UI 里能拿到错误对象和「重试」回调:
<ErrorBoundary fallback={(err, reset) => (
<div className="panel-error">
<p>这块内容加载失败</p>
<button onClick={reset}>重试</button>
</div>
)}>
<CommentList />
</ErrorBoundary>
注意:`getDerivedStateFromError` 只负责渲染降级 UI,不要在里面写上报,那是异步副作用;`componentDidCatch` 才是上报的位置。
第二步:按粒度摆放边界,别只放一层
只在根节点套一个边界,结果是任何一个小组件崩了整页变错误页。合理做法是三层:
- 根级:兜底,防止白屏;
- 路由级:每个页面一个,切换路由时自动重置;
- 区块级:评论区、推荐位、图表这类「坏了也不影响主体」的模块各包一个。
路由级重置有个偷懒又有效的写法——用 `key` 绑路径:
<ErrorBoundary key={location.pathname} fallback={...}>
<Outlet />
</ErrorBoundary>
路径一变,组件重新挂载,错误状态自然清空。
第三步:Suspense 处理「还没好」
`Suspense` 管的是等待,`ErrorBoundary` 管的是失败,两者职责不重叠,必须搭配用。
const Chart = React.lazy(() => import('./Chart'));
<ErrorBoundary fallback={renderError}>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
</ErrorBoundary>
顺序建议是 ErrorBoundary 在外、Suspense 在内:懒加载 chunk 拉取失败时抛出的错误会向上冒泡,被外层边界接住;同时边界重置后,里面的 Suspense 也会重新走一遍加载流程。
注意:Suspense 本身不捕获错误。懒加载 404(发版后 hash 变了最常见)会直接冒到最近的错误边界,如果你没套边界,整个应用崩。
第四步:用 transition 避免 fallback 闪烁
React 18 里有个细节很容易踩:已经展示过的内容再次挂起时,普通更新会把内容卸载、回到 `fallback`,页面闪一下骨架屏,体验很差。用 `useTransition` 把这次更新标成「非紧急」,React 会保留旧内容,只把 `isPending` 置为 true:
const [isPending, startTransition] = useTransition();
<button onClick={() => startTransition(() => setTab('chart'))}>
图表
</button>
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<Suspense fallback={<Skeleton />}>
{tab === 'chart' ? <Chart /> : <Table />}
</Suspense>
</div>
这样切换 tab 时旧界面留场、变暗提示加载,而不是整块消失。
注意:`startTransition` 只降低更新优先级,不改变数据获取本身。如果你的数据是 useEffect 里 fetch 的,它不会触发 Suspense,这套机制只在「读数据时抛 Promise」的框架(React Query、Relay、Next.js RSC 等)下生效。
第五步:给降级 UI 一点诚意
降级 UI 不是「出错了」四个字就完事。好的降级至少包含:
- 说明这块内容是什么(「评论暂时加载失败」);
- 一个可点的重试按钮,而不是让用户刷新整页;
- 如果是懒加载失败,重试前先重新拉一次 chunk,`reset` 后往往一次就成功;
- 骨架屏的尺寸贴近真实内容,避免加载完成后布局跳动。
第六步:处理边界抓不到的错误
错误边界有明确的盲区:事件处理器、`setTimeout`、Promise 回调、边界自身抛出的错误、服务端渲染,它都抓不到。
- 事件里用 try/catch,或统一包一层 `safeHandler`;
- 全局兜底加 `window.addEventListener('error')` 和 `unhandledrejection`;
- React 19 可以在 `createRoot` 时传 `onUncaughtError` / `onCaughtError`,作为最后一道防线统一上报;
- SSR 场景用 `hydrateRoot` 的 `onRecoverableError` 处理水合不匹配导致的客户端重渲染。
注意:别指望一个根级边界能兜住一切。明确它管的是「渲染阶段抛出的错误」,剩下的靠全局监听和手动 try/catch 补齐。
小结
- 错误边界必须是 class,用 `getDerivedStateFromError` 改状态、`componentDidCatch` 上报;
- 边界要分层摆放:根级兜底、路由级重置、区块级隔离;
- Suspense 管等待、边界管失败,组合时边界在外层;
- 已展示内容的再次加载用 `useTransition` 标记,避免 fallback 闪烁;
- 降级 UI 要有说明和重试按钮,重置后能真正恢复;
- 事件、异步、SSR 的错误边界抓不到,需全局监听与 try/catch 补齐。