Notes

Error Boundary:兜底、重试与上报(自研 vs Sentry)(机制/架构视角)

一句话结论:Error Boundary 负责处理 React 渲染链路上的“同步异常”(render/生命周期/构造函数等)——当子树抛错时,React 会在最近的 Error Boundary 处中止该子树的提交并渲染 fallback;真正的工程落地关键在于:边界如何分层、如何 reset/retry、以及错误上报体系(自研或 Sentry)如何做到版本关联、去重、采样、隐私与可观测闭环

1. Error Boundary 能捕获什么?不能捕获什么?(必须先讲清)

1.1 能捕获(React 渲染链路的同步错误)

通常包括:

  • render 阶段抛错(函数组件执行、class render)
  • 生命周期抛错(class 组件的生命周期)
  • 子组件构造函数抛错(class 组件)

当这些错误发生时,React 会沿 Fiber 向上寻找最近的 Error Boundary。

1.2 不能捕获(最常见误区)

Error Boundary 捕获不到

  • 事件回调里的错误(onClick/onChange 内 throw)
  • 异步回调里的错误(setTimeout / promise 链 / async/await 中 throw)
  • 自定义事件/订阅回调里的错误(WebSocket message、RxJS subscribe 等)
  • SSR 服务端执行时抛出的错误(这属于服务端错误处理体系)

这些错误需要靠:

  • try/catch(业务层)
  • window.onerror / unhandledrejection(全局兜底)
  • 统一请求层/订阅层的错误处理
  • 服务端日志与 tracing

2. 机制:React 在出错时做了什么?

把它放到你已有的 Fiber 认知里更好理解:

  • render 阶段:构建 workInProgress 树,可能会执行到某个组件并抛错
  • React 捕获到错误后,会尝试找到最近的 Error Boundary(本质上是一个带特定能力的祖先 Fiber)
  • 一旦找到边界:
    • React 会把该边界标记为需要处理错误(可理解为“在这个边界处降级”)
    • 丢弃出错子树的本次渲染结果
    • 在 commit 时,渲染边界的 fallback UI(而不是把错误子树提交到 DOM)

这个过程体现了 React 的核心边界:

  • render 可以失败、可以丢弃
  • commit 要么成功提交一棵一致的 UI,要么回退到 fallback

3. Error Boundary 与 Suspense:一个处理“错”,一个处理“等”

  • Suspense 处理的是 pending(等待):suspend → fallback(loading)
  • Error Boundary 处理的是 error(异常):throw error → fallback(error UI)

架构上建议把两者分治:

  • 同一区域既可能“等”也可能“错”时,通常是:
    • 外层 ErrorBoundary
    • 内层 Suspense

这样:

  • 加载失败不会被 loading UI 吞掉
  • 错误 UI 不会和 loading 状态混在一起

4. “边界怎么切”:错误兜底的分层策略(P0)

4.1 三层边界(推荐)

  1. App-level(全局):兜底最后一道防线(避免白屏)
  2. Route-level(页面级):页面可降级但壳不崩(导航/菜单稳定)
  3. Section-level(模块级):列表/详情/评论等局部失败不影响整体

4.2 反例

  • 只有一个全局边界:任何小错误都导致整页错误页(体验差)
  • 过度碎片化:上报量暴增、fallback 频繁闪烁、用户迷惑

5. Recovery:如何“重试/恢复”(resetKeys / onReset)

一个好的错误兜底不只是展示错误信息,更关键是能恢复

  • 用户点“重试”
  • 或者路由变化/参数变化时自动 reset

5.1 使用 react-error-boundary(推荐的工程化方式)

import { ErrorBoundary } from 'react-error-boundary'

function ErrorFallback({ error, resetErrorBoundary }: any) {
  return (
    <div>
      <p>模块加载失败:{String(error?.message ?? error)}</p>
      <button onClick={resetErrorBoundary}>重试</button>
    </div>
  )
}

export function Section() {
  const queryKey = 'users:list'

  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      resetKeys={[queryKey]}
      onReset={() => {
        // 清缓存/重拉数据等
      }}
      onError={(error, info) => {
        // 上报(见下文)
      }}
    >
      <UsersList />
    </ErrorBoundary>
  )
}

要点:

  • resetKeys 让边界在关键输入变化时自动恢复
  • onReset 做“恢复动作”(清缓存、invalidate query、reset store)

6. 上报体系:自研 vs Sentry(适配不同企业策略)

6.1 共同目标(无论自研还是 Sentry 都要做到)

  • 版本关联:release / commit / buildId
  • 去重聚合:按 stack + message + route 聚合
  • 采样与限流:避免错误风暴打爆后端
  • 上下文:用户/租户/路由/feature flag/网络状态
  • SourceMap:线上堆栈可还原(注意安全策略)
  • 隐私合规:PII 脱敏、字段白名单

6.2 方案 A:接入 Sentry(开箱即用,适合效率优先)

优点:

  • 聚合、告警、趋势、Release、SourceMap、性能/Tracing 能力成熟
  • 生态完善,React 集成很顺

关键落地点:

  1. 初始化与 release
import * as Sentry from '@sentry/react'

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  release: process.env.APP_RELEASE, // commit hash 或版本号
  environment: process.env.APP_ENV,
  tracesSampleRate: 0.0, // 需要再开
})
  1. 在 Error Boundary 的 onError 中 capture
onError={(error, info) => {
  Sentry.withScope(scope => {
    scope.setTag('boundary', 'users-section')
    scope.setContext('react', { componentStack: info?.componentStack })
    Sentry.captureException(error)
  })
}}
  1. 事件回调/异步错误
  • Sentry 通常也能通过全局 hook 捕获 window.onerror / unhandledrejection,但你仍建议在关键异步链路里显式捕获并补充上下文。

适配建议:

  • 大多数团队采用:Sentry 做主链路 + 自研做补充(业务字段/审计)

6.3 方案 B:自研上报(可控、适合合规/私有化/成本敏感)

自研的核心不是“发一个请求”,而是要做“可运营的错误平台”的最小闭环。

6.3.1 事件模型(建议)

  • eventId
  • timestamp
  • release(版本)
  • route / pathname / search
  • errorName / message
  • stack
  • componentStack(Error Boundary 能提供)
  • userId / tenantId(可选,注意脱敏)
  • device / ua / network
  • tags(feature / boundary / module)

6.3.2 客户端 SDK(最小实现)

type ReportPayload = {
  release: string
  message: string
  stack?: string
  componentStack?: string
  route?: string
  tags?: Record<string, string>
}

export async function reportError(payload: ReportPayload) {
  // 去重/采样(示意)
  const sampled = Math.random() < 0.1
  if (!sampled) return

  navigator.sendBeacon?.('/api/fe-error', JSON.stringify(payload))
    ?? fetch('/api/fe-error', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(payload),
      keepalive: true,
    })
}

6.3.3 在 Error Boundary 中上报

onError={(error, info) => {
  reportError({
    release: __APP_RELEASE__,
    message: String(error?.message ?? error),
    stack: error?.stack,
    componentStack: info?.componentStack,
    route: location.pathname + location.search,
    tags: { boundary: 'users-section' },
  })
}}

6.3.4 服务端接收(全栈视角建议)

  • 做限流(IP/租户/版本维度)
  • 做聚合键(stack hash)
  • 存储:ClickHouse/ES/PG 都行(看规模)
  • 告警:按“版本新增错误”“错误率突增”“关键路径错误”

自研策略更适合:私有化部署、强合规(不出网)、或希望把错误与业务指标/审计打通的企业。

6.4 混合策略:同一套边界,双通道可切换

很多企业会要求:

  • 国内/私有化:走自研
  • 海外/效率优先:走 Sentry

建议做成“上报适配层”:

export interface ErrorReporter {
  capture(error: unknown, ctx: Record<string, any>): void
}

export const reporter: ErrorReporter = isSentryEnabled
  ? sentryReporter
  : selfHostedReporter

业务侧只依赖 reporter.capture,避免 vendor lock-in 扩散。

7. Next.js 与 TanStack Router 的落地点

7.1 Next.js App Router

  • route segment 可以用 error.tsx 做段级错误页
  • 仍建议在客户端交互密集模块内使用组件级 Error Boundary(更细粒度、更可恢复)
  • 上报可在 error.tsx 内或通过统一 client SDK 完成

7.2 TanStack Router + Vite

  • 路由层可在 route component 外包一层 route-level Error Boundary
  • 模块层用 section-level Error Boundary
  • 配合 TanStack Query:错误页的“重试”通常对应 queryClient.invalidateQueries(...)

8. Checklist

  • 我是否清楚哪些错误 Error Boundary 捕获不到?(事件/异步/订阅)
  • 边界是否分层(app/route/section),壳是否稳定?
  • fallback 是否提供恢复路径(重试/返回/刷新),并且 resetKeys 合理?
  • 上报是否做了 release/version 关联与 SourceMap 策略?
  • 上报是否做了采样/限流/去重,避免错误风暴?
  • 是否有隐私脱敏策略(PII)?

关联阅读

cd ..