React Error Boundary:别让一个组件错误拖垮整页

上周三下午,运营群里连着几条消息:后台首页打不开了,白屏,什么都点不了。

我打开一看,确实是整页空白,控制台里一条 React 报错,栈指向仪表盘上一个不起眼的收入图表组件。后端那天调整了一个字段,图表组件拿到一个预期外的 null,解析时抛了异常。问题本身只影响那一小块图表,可结果是整页——包括旁边完全正常的订单列表、顶部导航、侧边菜单——全没了。

这就是 React 的默认行为:渲染阶段一个未捕获的错误,会一路冒到顶,把整棵组件树卸载掉。一个头像组件读了空字段、一个富文本组件遇到脏 HTML、一个图表组件解析失败,本该是局部故障,没有边界就成了全站白屏。Error Boundary 要做的,就是给页面划出隔离区:某块 UI 炸了,展示兜底,别让爆炸波及全页。这次事故之后,我把项目里的边界策略认真理了一遍。

Error Boundary 能捕获什么

React 的 Error Boundary 能捕获子组件在渲染、生命周期方法、构造函数里抛出的错误。React 18 已经稳定,但 Error Boundary 到现在仍然只能用 class 组件写——官方还没给出 Hooks 版的等价物:

1class ErrorBoundary extends React.Component {
2  state = { hasError: false }
3
4  static getDerivedStateFromError() {
5    return { hasError: true }
6  }
7
8  componentDidCatch(error, info) {
9    reportError(error, info)
10  }
11
12  render() {
13    if (this.state.hasError) {
14      return this.props.fallback ?? <div>模块加载失败</div>
15    }
16
17    return this.props.children
18  }
19}

getDerivedStateFromError 负责在下一次渲染时切到 fallback 界面,componentDidCatch 负责拿到 componentStack 这类信息去上报,两者分工不同,最好都实现。用起来就是把可能出事的子树包起来:

1<ErrorBoundary>
2  <ChartPanel />
3</ErrorBoundary>

这样 ChartPanel 渲染时再炸,也只波及这一块,不会掀翻整页。回到那次事故,如果图表当时被这么包一层,运营看到的就是一张「图表加载失败」的小卡片,而不是白屏。

它捕不到的那些错误

Error Boundary 的能力边界必须讲清楚,否则会误以为包一层就万事大吉。它捕获不到:

  • 事件处理函数里抛的错误
  • 异步回调、setTimeoutPromise.reject 里的错误
  • 服务端渲染阶段的错误
  • Error Boundary 自己内部抛的错误

原因是这些错误不发生在 React 的渲染流程里,冒不到边界那儿。比如:

1async function handleClick() {
2  await api.save()
3  throw new Error('submit failed') // Error Boundary 接不住
4}

这类得在事件里自己 try/catch:

1async function handleSubmit() {
2  try {
3    await api.save()
4  } catch (error) {
5    setError('保存失败,请稍后重试')
6    reportError(error)
7  }
8}

接口失败尤其不能指望 Error Boundary 来接。API 报错是一种业务状态,正确做法是渲染错误提示、重试按钮或字段级报错,而不是让组件直接 throw——真 throw 出去,反而会把整块 UI 打进 fallback,用户连重试按钮都看不到。至于那些游离在渲染之外的 Promise.reject,可以挂一个全局的 unhandledrejection 监听做补充上报,但这是另一条链路,和 Error Boundary 各管各的。

硬要把异步错误送进边界

有时候确实希望某个异步失败也走统一的边界降级,而不是每个组件各写各的错误 UI。有个常见技巧:在事件里 catch 到错误后,用一个 setState 把它「重新抛」到渲染阶段,这样 Error Boundary 就能接住:

1function useErrorHandler() {
2  const [, setState] = React.useState()
3  return React.useCallback((error: unknown) => {
4    setState(() => {
5      throw error
6    })
7  }, [])
8}

用的时候:

1const throwToBoundary = useErrorHandler()
2
3async function load() {
4  try {
5    await api.fetchData()
6  } catch (error) {
7    throwToBoundary(error) // 交给最近的 Error Boundary
8  }
9}

社区里 react-error-boundary 这个库把这套封装得更完整,它导出一个函数式的 ErrorBoundary 组件加 useErrorBoundary Hook,还内建了 onResetresetKeys

1import { ErrorBoundary } from 'react-error-boundary'
2
3<ErrorBoundary
4  FallbackComponent={ModuleError}
5  onReset={() => refetch()}
6  resetKeys={[queryKey]}
7>
8  <OrderTable />
9</ErrorBoundary>

resetKeys 里任一值变化,边界自动重置,省得自己拿 key 硬重挂。要不要引这个库看团队偏好——它本质还是那个 class 组件的封装,React 到现在也没提供官方的 Hooks 版 Error Boundary,所以底层机制没变,变的只是写法更顺手。但要克制:不是所有异步错误都值得「送进边界」,能在原地展示重试的,就别升级成整块降级。

边界放在哪几层

那次白屏还暴露一个问题:我们当时只在 App 根部放了一个边界。根边界确实能防止「整个应用彻底崩」,但它的 fallback 粒度太粗——图表一炸,用户看到的还是一个盖住全页的大错误页,订单列表明明好好的也一起没了。

更实用的是多层边界,让局部失败只降级局部。我现在会在这几个位置放:

  • 路由级:每个页面外面包一层大范围的 fallback,一个页面挂了不影响切到别的页面
  • 模块级:图表、富文本、地图、编辑器这类高风险、又相对独立的块
  • 微前端子应用之间的边界
  • 接入的第三方组件外围

仪表盘现在长这样:

1<DashboardLayout>
2  <ErrorBoundary fallback={<CardError title="图表加载失败" />}>
3    <RevenueChart />
4  </ErrorBoundary>
5  <ErrorBoundary fallback={<CardError title="列表加载失败" />}>
6    <OrderTable />
7  </ErrorBoundary>
8</DashboardLayout>

图表坏了,列表照样能看,用户还能一眼看出是哪块出了问题。同样是后端改字段导致图表崩,换成这个结构就只掉一张卡片。边界粒度的取舍在于:太粗,一崩一大片;太细,满屏都是包裹组件、也难维护。我的经验是按「能不能独立失败、值不值得独立降级」来切,图表和列表各自成块就够了,不必给每个按钮都套边界。

微前端场景下这层隔离尤其值钱。我们有一块后台是多个团队各自的子应用拼起来的,一个子应用发版带了 bug,绝不该把整个工作台拖垮。给每个子应用挂一层边界,等于给它们之间加了防火墙——某个子应用白屏,降级成一块「该模块暂时不可用」的占位,其余模块照常。这也是为什么根部那一个边界远远不够:它防的是「全站崩」,防不了「一个团队的代码带崩另一个团队」。

fallback 要能让人行动

兜底 UI 别只写「出错了」。那次事故如果只甩用户一句「加载失败」,他们除了刷新也做不了别的。一个像样的 fallback 至少要说清:哪个模块失败了、能不能重试、会不会影响数据安全、实在不行怎么刷新或找谁。

1function ModuleError({ title, onRetry }) {
2  return (
3    <section>
4      <h3>{title}</h3>
5      <p>当前模块暂时无法显示。</p>
6      <button type="button" onClick={onRetry}>重试</button>
7    </section>
8  )
9}

要区分错误来源来决定「重试」意味着什么:错误来自接口,重试就重新请求;错误来自渲染脏数据,重试大概率没用——组件拿到的还是那份坏数据,只会再炸一次,这时更该上报加提示刷新,而不是给个假的重试按钮骗用户反复点。

错误上报要带上下文

事故当天最耽误时间的,是最初那条上报里只有一句 error message,看不出到底是哪个组件、哪个路由。所以 componentDidCatch 里不能只上报 message,至少带上:

  • error stack
  • component stack
  • 当前路由
  • 用户环境、应用版本
  • 关键 props 摘要
1componentDidCatch(error, info) {
2  reportError('react.render', error, {
3    componentStack: info.componentStack,
4    path: location.pathname,
5    version: APP_VERSION,
6  })
7}

React 提供的 component stack 特别值钱,它能直接告诉你错误发生在组件树的哪个位置——那次靠它才一眼定位到收入图表,而不是在压缩过的 stack 里瞎猜。没有这份信息,线上错误定位基本靠运气。

有一点要守住:敏感数据不上报。用户输入、token、隐私字段在拼上下文时要过滤掉,别为了排查方便把不该出库的东西送到监控平台。

还有个开发时容易被误导的地方:开发模式下即使有 Error Boundary,React 也会先把错误 overlay 弹到全屏,让你以为边界没生效。其实那只是 dev 的调试遮罩,关掉或者切到生产构建,边界的 fallback 就正常显示了。这个坑第一次遇到会白白怀疑半天自己的边界写错了。

边界之外,还要有全局补漏

Error Boundary 只管渲染树里的错误,页面上还有一大片它够不到的地方——事件回调、异步任务、资源加载失败。这些得靠全局监听补齐,和边界配合成两道防线:

1window.addEventListener('error', (event) => {
2  reportError('window.error', event.error, {
3    path: location.pathname,
4    version: APP_VERSION,
5  })
6})
7
8window.addEventListener('unhandledrejection', (event) => {
9  reportError('unhandled.rejection', event.reason, {
10    path: location.pathname,
11  })
12})

error 事件能接住脚本运行时错误和一部分资源加载失败,unhandledrejection 接住没人 catch 的 Promise。它俩不会渲染 fallback UI,只负责别让错误无声无息地消失在监控里。加了这两层之后,我们的线上错误覆盖率明显完整了——之前有一批异步错误因为根本没进任何 catch,监控上一片空白,出了问题只能靠用户反馈。

要提醒的是,这两个全局监听拿到的信息比 Error Boundary 粗,没有 component stack,定位不如渲染错误精确。所以它们是补漏,不是替代——能进边界的错误还是尽量走边界,拿到组件树位置那份上下文。

和 Suspense 搭在一起

React 18 稳定之后,Suspense 用得多了起来。数据请求库配合 Suspense 时,「加载中」交给 Suspense 的 fallback,「加载失败」就正好交给 Error Boundary——两者是天然的搭档:

1<ErrorBoundary fallback={<CardError title="图表加载失败" onRetry={refetch} />}>
2  <Suspense fallback={<Skeleton />}>
3    <RevenueChart />
4  </Suspense>
5</ErrorBoundary>

这里的顺序有讲究:Error Boundary 在外、Suspense 在内。数据在 pending 时,RevenueChart 会 throw 一个 Promise,被里层 Suspense 接住显示骨架屏;数据请求真的 reject 时,抛的是普通 error,穿过 Suspense 落到外层 Error Boundary。把两层顺序写反,加载态和错误态就会互相打架。

要注意的是,这套「用 Suspense 做数据加载」在 2023 年还没完全定型。React 官方的 use() 这类读取 Promise 的稳定 API 还没发布,目前主要是靠 TanStack Query 的 suspense: true 选项、或者各数据库自己的 Suspense 集成在用。所以我在项目里只在有成熟库支撑的地方这么写,不自己手搓 Suspense 数据源,那部分还太新,先观望。

用 key 重置边界

边界进入错误状态后会一直显示 fallback,直到重新挂载。有些场景需要主动重置——比如用户从出错的详情页 A 切到详情页 B,B 的数据没问题,就不该继续顶着 A 的错误页:

1<ErrorBoundary key={location.pathname}>
2  <Page />
3</ErrorBoundary>

路径一变,key 变了,React 会把这个边界当成新组件重新挂载,错误状态自然清空。模块级也能用数据 id 当 key,让「换一条数据」等价于「给这块重新一次机会」。这是利用 React 的 reconciliation 规则做的一个小技巧,比在边界里手写一堆重置逻辑干净。

但用 key 重挂有个副作用要清楚:它会把整棵子树卸载再重建,子组件的内部状态、已加载的数据都会丢。如果只是想重试而不想全丢,更合适的是给 fallback 一个「重试」按钮,内部调用 setStatehasError 清回 false,让边界自己回到正常渲染,而不是靠外面换 key 强拆。两种方式对应两种意图:换 key 是「这是一份全新的东西」,清 state 是「同一份东西再试一次」,别混用。

事故之后的检查项

那次白屏收尾时,我照着这几条把项目过了一遍,现在也当成默认检查清单:

路由级有没有错误层面的 fallback;图表、富文本这类高风险模块有没有各自的局部边界;API 错误是不是按业务状态处理、而不是 throw 给边界;fallback 是不是说得清、能重试;上报有没有带 component stack 和路由;边界能不能在路由或数据变化后重置。

Error Boundary 不是用来掩盖错误的。线上系统不可能没有错误,关键是错误来的时候,别让用户对着一整片空白发呆——这周三那次,如果早有这层防护,运营群里那几条消息本可以不用出现。