React 错误边界与异常处理
一个组件 throw 就白屏?错误边界(Error Boundary)把崩溃控制在局部。本文讲清 Error Boundary 的用法(class 组件 + getDerivedStateFromError)、哪些错误它捕获不到(事件/异步/SSR)、react-error-boundary 库,以及一套完整的前端异常处理策略(边界兜底 + 全局上报)。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
线上告警:某个页面白屏,用户反馈"突然就什么都没了"。你打开监控一看,是一个不起眼的列表组件渲染时读了个 undefined.map,抛了个 TypeError——就这一个异常,整页没了。
这是 React 的默认崩溃模型:任何一个组件在渲染阶段 throw,整棵组件树卸载,页面白屏。 不是 React 小气,是它确实没别的选择——渲染到一半,内部状态机已经损坏,继续渲染下去 UI 和数据会对不上,展示出来的界面可能错得离谱。React 把"该停就停"这件事做得很干脆,代价是开发者必须主动用**错误边界(Error Boundary)**把崩溃控制在局部。否则一个边角组件的 bug,就能干掉整个页面。
但 Error Boundary 不是万能的。它只管"渲染阶段",事件处理器、setTimeout、Promise 这些它一个都捕获不了——这是新手最容易踩的坑。本文讲清楚:Error Boundary 怎么写、捕获什么、捕获不到什么,以及怎么和全局上报(Sentry)拼成一套完整的前端异常处理策略。
一、React 的崩溃模型:一个 throw,白屏一整棵树
先讲清楚 React 为什么"脆弱"。在 Error Boundary 出现之前(React 15 及以前),组件 throw 后的体验是这样的:控制台一堆报错,UI 卡在损坏的中间态,各种诡异交互。React 16 引入 Fiber 架构后规则变得明确——任何在渲染阶段未被捕获的异常,都会让整棵树卸载,直接白屏。 官方的态度大意是:与其展示一个错误的 UI,不如不展示。
这个行为本身是对的——展示错误 UI 比白屏更危险(用户可能基于错误数据做了决策)。但它把问题摆到了台面:你得主动用错误边界兜住崩溃,否则就得全员陪葬。一个典型场景:首页主体是一个数据看板,旁边挂了个"热门活动"小卡片。小卡片的接口今天返回了奇怪的结构,组件 data.items.map 炸了——结果看板跟着一起没了。看板没做错任何事,却要为小卡片陪葬。错误边界要做的就是:让小卡片的崩溃只停留在小卡片那块,看板照常工作。
二、Error Boundary 的写法:class 组件 + 两个生命周期
错误边界必须是 class 组件——截至当前,React 还没给函数组件提供等价 API,componentDidCatch 和 getDerivedStateFromError 都没有 hook 版本。一个最小可用的错误边界长这样:
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
fallback: ReactNode;
}
interface State {
hasError: boolean;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
// 渲染阶段调用:返回新 state,触发 fallback 渲染
static getDerivedStateFromError(): State {
return { hasError: true };
}
// 提交阶段调用:拿到错误和组件栈,适合上报
componentDidCatch(error: Error, info: { componentStack: string }) {
reportError({ error, componentStack: info.componentStack });
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
两个生命周期分工不同,别搞混:
getDerivedStateFromError(error):渲染阶段调用,返回的 state 会在本次更新里立即用。它存在的唯一目的就是"把组件切到 fallback"——别在这里做副作用(发请求、打日志),渲染阶段不允许。componentDidCatch(error, info):提交阶段调用,UI 已经切到 fallback 了。这里才是上报日志、console.error的地方。info.componentStack是 React 拼出来的组件栈(不是 JS 调用栈),能告诉你"错误发生在哪个组件",对排查极有价值。
用法就一句——把它包在你想保护的区域外面:
<ErrorBoundary fallback={<div>这块挂了,但我们还在</div>}>
<FlakyCard />
</ErrorBoundary>
Next.js 的 App Router 在这层做了个约定:error.tsx 文件就是路由级的错误边界,框架自动把它当 fallback。注意它必须是客户端组件("use client"),因为错误恢复依赖 state,而 state 只在 client 跑。这一版的 error.tsx 接收的不是老的 reset,而是 unstable_retry——调用它会重新拉取并重新渲染该路由段(下面恢复一节会细说)。
三、它捕获不到的:事件处理器、异步代码、SSR
这是 Error Boundary 最大的"坑点":它只捕获子树在渲染阶段和生命周期里抛出的错误。下面这些它一个都管不了:
- 事件处理器:
onClick里 throw,Error Boundary 无动于衷。因为事件处理发生在渲染之外,React 根本不知道。 - 异步代码:
setTimeout、setInterval、requestAnimationFrame里的异常,触发时渲染早就结束了。 - Promise / async-await 的 reject:同理,异步边界之外。
- SSR 错误:服务端渲染时没有 commit 阶段,Error Boundary 在服务端不工作。
- Error Boundary 自身 throw:边界自己挂了,自己也救不了自己——所以 fallback 里别写复杂逻辑。
那这些捕获不到的怎么办?老老实实用 JS 原生手段:
function SaveButton() {
const handleClick = async () => {
try {
await saveData(); // 异步错误,Error Boundary 救不了你
} catch (err) {
reportError(err);
toast.error("保存失败,请重试");
}
};
return <button onClick={handleClick}>保存</button>;
}
记一个心智模型:Error Boundary 兜的是"渲染时崩溃",try/catch 兜的是"交互时出错",全局监听兜的是"漏网的"。 三者各管一段,缺一不可。一个反直觉但好用的点:如果你把事件里的重活包进 useTransition 的 startTransition,它的未处理错误会冒泡到最近的错误边界——等于借道让"交互错误"也享受边界的 fallback。
四、react-error-boundary:函数组件友好的封装
每次都手写一遍 class 太烦,而且 class 在函数组件为主的代码库里很扎眼。社区库 react-error-boundary 把这层封装好了,API 设计得很克制:
import { ErrorBoundary } from "react-error-boundary";
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>出错了:{error.message}</p>
<button onClick={resetErrorBoundary}>重试</button>
</div>
)}
onError={(error, info) => reportError({ error, info })}
onReset={() => {
// 通常在这里重置触发错误的外部状态(比如清掉坏掉的 query 缓存)
queryClient.invalidateQueries({ queryKey: ["flaky"] });
}}
>
<FlakyChart />
</ErrorBoundary>
它比手写 class 多出来的能力才是重点:resetErrorBoundary——一个能让错误边界从 fallback 状态恢复出来的函数。Next.js App Router 这一版里也有官方的对应物:error.tsx 的 unstable_retry 会重新拉取并渲染该路由段;如果想要不绑路由的组件级边界,可以用 next/error 导出的 unstable_catchError 包一个 fallback 组件,效果类似手写 class 但不用自己写。三条路任选,核心都是"fallback + 恢复"。
五、恢复:给用户一个"重试"按钮,而不是死页
很多人写完 fallback 就不管了,渲染出一个红色的"出错了"就结束。这等价于让用户对着死页刷新浏览器——体验极差。好的错误边界是可恢复的。
恢复的核心思路:把"导致崩溃的那部分 state"重置掉,然后重新渲染。手写边界的常见做法是给 Error Boundary 一个新的 key,它就会卸载重建,内部 state 自然清空:
// 用 key 强制重建边界
const [retryKey, setRetryKey] = useState(0);
<ErrorBoundary key={retryKey} fallback={<RedBox onRetry={() => setRetryKey(k => k + 1)} />}>
<FlakyList />
</ErrorBoundary>
react-error-boundary 的 resetErrorBoundary、Next.js 的 unstable_retry 都帮你把"重置 + 重渲染"封装好了,但原理一致。更彻底的做法是配合数据层——大多数渲染崩溃的根因是"缓存里那份坏数据",重试前先把 react-query / SWR 对应的 query 清掉,否则重试一百次还是同一个错。
一条铁律:fallback UI 必须是"静态、零依赖"的。 别在 fallback 里读 context、别 fetch、别用任何可能再次 throw 的逻辑——fallback 是你的最后一道防线,它再挂,就真的没救了。
六、布局策略:局部边界 + 根级边界
错误边界放哪儿,决定了"崩溃波及的范围"。我推荐两层布局:
- 根级 Error Boundary:套在整个应用最外面,是最后防线。它兜的是"所有局部边界都没拦住"的异常,fallback 通常是个完整的"应用崩溃"页(带"回到首页""联系支持")。Next.js App Router 里对应
app/global-error.tsx,它替换根 layout,所以必须自带<html>和<body>。 - 关键区域局部 Error Boundary:套在独立、易错、非核心的区块外面——侧边卡片、第三方图表、富文本编辑器、广告位、用户自定义内容。原则:这部分挂了,不应该影响主体功能。
// 根级(在 providers / layout 最外层)
<ErrorBoundary fallback={<AppCrash />}>
<App />
{/* 同时,在每个易错模块外面套局部边界 */}
<ErrorBoundary fallback={<ChartSkeleton />}>
<RevenueChart />
</ErrorBoundary>
</ErrorBoundary>
一个常被忽略的点:Suspense 和 Error Boundary 的嵌套顺序有讲究。想让"加载中"和"加载失败"的 fallback 分开,必须把 ErrorBoundary 放在 Suspense 外面——否则 Error Boundary 会先捕获到 Suspense 抛出的 promise,行为就乱了:
// 正确顺序:ErrorBoundary 在外,Suspense 在内
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Spinner />}>
<ComponentThatSuspends />
</Suspense>
</ErrorBoundary>
七、全局上报 + source map:让 stack 可读
Error Boundary 的 componentDidCatch 只能拿到渲染阶段的错。要把所有异常都收上来,还得在浏览器层挂全局监听:
// 同步错误
window.addEventListener("error", (event) => {
reportError({ type: "window.error", error: event.error });
});
// 未处理的 Promise reject
window.addEventListener("unhandledrejection", (event) => {
reportError({ type: "unhandledrejection", reason: event.reason });
});
三个来源(Error Boundary、window.error、unhandledrejection)统一汇到 reportError,再批量发到 Sentry / 自建日志。用 Sentry 的话其实不用自己拼——@sentry/react 提供了 Sentry.ErrorBoundary 组件并自动挂载全局钩子,直接抄它的封装即可。
最后是生产环境必须做的一步:上传 source map。 线上 JS 都是压缩混淆过的,上报上来的 stack 长这样:
at o.a.render (main.7f3b2c.js:2:18453)
at ... (main.7f3b2c.js:2:18990)
这种 stack 等于没报。做法是:构建时生成 source map,但不要部署到生产服务器(防止源码泄露),而是用 @sentry/cli 或 Sentry 的 Webpack/Vite 插件把 map 直接上传到 Sentry。Sentry 收到错误后用 map 还原,你看到的就是可读的原始 stack 和源码上下文。Next.js 这步基本零配置——Sentry 集成好后 CI 里加个 upload 步骤即可,框架已经替你处理了生成和上传的细节。
小结
- React 任何组件渲染阶段 throw,整棵树白屏——这是有意为之(避免展示损坏 UI),代价是开发者必须主动用 Error Boundary 把崩溃控制在局部,否则全员陪葬。
- Error Boundary 必须是 class 组件,靠
getDerivedStateFromError(切 fallback)+componentDidCatch(上报,带组件栈)两个生命周期协作。Next.js App Router 用error.tsx/global-error.tsx做路由级封装(必须"use client"),组件级可用next/error的unstable_catchError。 - 它捕获不到事件处理器、异步(setTimeout/Promise)、SSR 的错误——这些得用
try/catch;再用window.onerror+unhandledrejection兜漏网的。三套机制各管一段。startTransition里的未处理错误是个例外,会冒泡到边界。 react-error-boundary库给出函数组件友好的封装,并提供resetErrorBoundary——配合"清掉坏数据 + 重新渲染",让用户点"重试"恢复,而不是对死页刷新。fallback 必须静态零依赖,它是最后一道防线。- 布局上做"根级 + 关键区域局部"两层边界,ErrorBoundary 必须套在 Suspense 外面;生产环境务必上传 source map 到 Sentry,否则上报的 stack 全是混淆坐标,没法排查。
金句:错误边界不是让错误不发生,而是让错误发生时,只塌一间房,而不是倒整栋楼。 渲染崩溃交给边界,交互错误交给 try/catch,漏网之鱼交给全局监听——三道防线 + 一份可读的 source map,才叫完整的异常处理。
Related