#Next.js#React

React 错误边界与异常处理

一个组件 throw 就白屏?错误边界(Error Boundary)把崩溃控制在局部。本文讲清 Error Boundary 的用法(class 组件 + getDerivedStateFromError)、哪些错误它捕获不到(事件/异步/SSR)、react-error-boundary 库,以及一套完整的前端异常处理策略(边界兜底 + 全局上报)。

本文要点

  • 主题聚焦:Next.js、React
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年4月14日·更新于 2026年7月23日

线上告警:某个页面白屏,用户反馈"突然就什么都没了"。你打开监控一看,是一个不起眼的列表组件渲染时读了个 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 版本。一个最小可用的错误边界长这样:

tsx
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 调用栈),能告诉你"错误发生在哪个组件",对排查极有价值。

用法就一句——把它包在你想保护的区域外面:

tsx
<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 原生手段:

tsx
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 设计得很克制:

tsx
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 自然清空:

tsx
// 用 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 是你的最后一道防线,它再挂,就真的没救了。

六、布局策略:局部边界 + 根级边界

错误边界放哪儿,决定了"崩溃波及的范围"。我推荐两层布局:

  1. 根级 Error Boundary:套在整个应用最外面,是最后防线。它兜的是"所有局部边界都没拦住"的异常,fallback 通常是个完整的"应用崩溃"页(带"回到首页""联系支持")。Next.js App Router 里对应 app/global-error.tsx,它替换根 layout,所以必须自带 <html> 和 <body>。
  2. 关键区域局部 Error Boundary:套在独立、易错、非核心的区块外面——侧边卡片、第三方图表、富文本编辑器、广告位、用户自定义内容。原则:这部分挂了,不应该影响主体功能。
tsx
// 根级(在 providers / layout 最外层)
<ErrorBoundary fallback={<AppCrash />}>
  <App />
  {/* 同时,在每个易错模块外面套局部边界 */}
  <ErrorBoundary fallback={<ChartSkeleton />}>
    <RevenueChart />
  </ErrorBoundary>
</ErrorBoundary>

一个常被忽略的点:Suspense 和 Error Boundary 的嵌套顺序有讲究。想让"加载中"和"加载失败"的 fallback 分开,必须把 ErrorBoundary 放在 Suspense 外面——否则 Error Boundary 会先捕获到 Suspense 抛出的 promise,行为就乱了:

tsx
// 正确顺序:ErrorBoundary 在外,Suspense 在内
<ErrorBoundary fallback={<Error />}>
  <Suspense fallback={<Spinner />}>
    <ComponentThatSuspends />
  </Suspense>
</ErrorBoundary>

七、全局上报 + source map:让 stack 可读

Error Boundary 的 componentDidCatch 只能拿到渲染阶段的错。要把所有异常都收上来,还得在浏览器层挂全局监听:

ts
// 同步错误
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 长这样:

text
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 步骤即可,框架已经替你处理了生成和上传的细节。

小结

  1. React 任何组件渲染阶段 throw,整棵树白屏——这是有意为之(避免展示损坏 UI),代价是开发者必须主动用 Error Boundary 把崩溃控制在局部,否则全员陪葬。
  2. Error Boundary 必须是 class 组件,靠 getDerivedStateFromError(切 fallback)+ componentDidCatch(上报,带组件栈)两个生命周期协作。Next.js App Router 用 error.tsx / global-error.tsx 做路由级封装(必须 "use client"),组件级可用 next/error 的 unstable_catchError。
  3. 它捕获不到事件处理器、异步(setTimeout/Promise)、SSR 的错误——这些得用 try/catch;再用 window.onerror + unhandledrejection 兜漏网的。三套机制各管一段。startTransition 里的未处理错误是个例外,会冒泡到边界。
  4. react-error-boundary 库给出函数组件友好的封装,并提供 resetErrorBoundary——配合"清掉坏数据 + 重新渲染",让用户点"重试"恢复,而不是对死页刷新。fallback 必须静态零依赖,它是最后一道防线。
  5. 布局上做"根级 + 关键区域局部"两层边界,ErrorBoundary 必须套在 Suspense 外面;生产环境务必上传 source map 到 Sentry,否则上报的 stack 全是混淆坐标,没法排查。

金句:错误边界不是让错误不发生,而是让错误发生时,只塌一间房,而不是倒整栋楼。 渲染崩溃交给边界,交互错误交给 try/catch,漏网之鱼交给全局监听——三道防线 + 一份可读的 source map,才叫完整的异常处理。

Related

相关推荐