前端错误边界与可观测性:让白屏有证据

0阅读9分钟

白屏不可怕,没证据才可怕

去年接手过一个电商后台项目,用户隔三差五反馈「页面打不开」,但每次我们去查日志,Sentry 里只有零星的几条压缩后异常堆栈,既不知道用户当时在哪个路由,也不知道是哪个接口返回了异常数据。最夸张的一次,同一个页面在一周内被四个用户报告白屏,我们花了两天才定位到是某个字段从 null 变成了 undefined 导致的渲染崩溃——而这个差异只在特定订单状态下才出现。

这件事让我意识到:前端错误处理的核心问题不是「能不能捕获」,而是「捕获之后有没有足够的证据还原现场」。ErrorBoundary 负责兜底 UI,可观测性负责还原现场,两者缺一不可。

错误边界的工作原理

React 的 Error Boundary 机制从 React 16 开始引入,官方文档把它定义为「可以捕获子组件树中 JavaScript 错误的组件」。它的底层依赖 componentDidCatchgetDerivedStateFromError 两个生命周期方法,前者负责捕获错误并上报,后者负责渲染降级 UI。

这里有一个关键限制:Error Boundary 只能捕获渲染阶段的错误,事件处理函数里的异常、异步代码(setTimeoutPromise.then)、服务端渲染和错误边界组件自身的错误都不在它的管辖范围内。React 团队在 RFC 中讨论过这个设计决策——他们认为渲染阶段的错误最需要兜底,因为未捕获的渲染异常会导致整棵组件树卸载,页面直接白屏。

把这个原理画清楚:

流程图画布 · 115%
Mermaid 流程图加载中...

理解了这个流程,就能明白为什么单靠 ErrorBoundary 不够——它只解决了「不白屏」的问题,如果 componentDidCatch 里没有足够的上下文信息,开发团队拿到的仍然是一条无法复现的孤立异常。

分级部署错误边界

在实际项目中,我倾向于把错误边界分成三个层级,每层的职责和降级策略不同。

全局层:兜底整个应用

应用最外层放一个全局错误边界,职责是防止「整站白屏」。当出现意料之外的级联错误时,至少给用户一个可操作的页面,而不是一片空白。

// ❌ 坏做法:全局边界只返回一行文字,用户不知所措
class AppErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false }
 
  static getDerivedStateFromError() {
    return { hasError: true }
  }
 
  componentDidCatch(error: Error) {
    // 只上报了 error,没有上下文
    reportError(error)
  }
 
  render() {
    if (this.state.hasError) {
      return <p>出错了</p>
      // 用户看到这三个字,除了刷新什么也做不了
    }
    return this.props.children
  }
}
// ✅ 好做法:全局边界提供明确信息和恢复路径
class AppErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean; error: Error | null }
> {
  state = { hasError: false, error: null }
 
  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }
 
  componentDidCatch(error: Error, info: React.ErrorInfo) {
    reportError({
      error,
      // 关键差异:同时上报组件堆栈和运行时上下文
      componentStack: info.componentStack,
      route: location.pathname,
      buildVersion: BUILD_VERSION,
      userId: getCurrentUserId(),
    })
  }
 
  render() {
    if (this.state.hasError) {
      return (
        <GlobalFallback
          onRetry={() => this.setState({ hasError: false, error: null })}
          onBack={() => navigate(-1)}
        />
      )
      // 用户可以选择重试或返回上一页
    }
    return this.props.children
  }
}

两种写法的核心差异不在 UI 层面,在于 componentDidCatch 里是否记录了足够的定位信息。组件堆栈(componentStack)能告诉你错误发生在组件树的哪一层,配合路由和构建版本号,基本上能在 5 分钟内定位到代码位置。

功能区块层:隔离独立模块

一个后台页面通常包含多个独立模块——数据表格、图表、侧边筛选器。某个图表渲染崩了不应该影响表格数据的展示。Sentry 的工程博客也建议「按功能区域设置错误边界,让错误的爆炸半径尽可能小」。

// ❌ 坏做法:整个页面共用一个 ErrorBoundary
function DashboardPage() {
  return (
    <ErrorBoundary fallback={<PageFallback />}>
      <DataPanel />
      <ChartPanel />   {/* 这里崩了,DataPanel 也没了 */}
      <FilterPanel />
    </ErrorBoundary>
  )
}
// ✅ 好做法:每个独立模块有自己的错误边界
function DashboardPage() {
  return (
    <div className="dashboard-grid">
      <ErrorBoundary
        fallback={<ModuleFallback title="数据面板" />}
        // 标签用于监控平台的分组过滤
        tags={{ module: 'data-panel' }}
      >
        <DataPanel />
      </ErrorBoundary>
 
      <ErrorBoundary
        fallback={<ModuleFallback title="趋势图" />}
        tags={{ module: 'chart-panel' }}
      >
        <ChartPanel />
      </ErrorBoundary>
 
      <ErrorBoundary
        fallback={<ModuleFallback title="筛选器" />}
        tags={{ module: 'filter-panel' }}
      >
        <FilterPanel />
      </ErrorBoundary>
    </div>
  )
}

分区部署的另一个好处是监控平台可以按 module 标签聚合错误。上线一个月后拉个报表,chart-panel 的错误率是 2.3%、data-panel 是 0.1%——哪个模块最不稳定一目了然。

组件层:高风险组件的防护网

某些组件天然是错误高发区:接收外部数据的第三方组件、渲染用户上传内容的组件、使用 dangerouslySetInnerHTML 的组件。这类组件外面值得单独包一层 ErrorBoundary。

// 第三方图表组件可能在不同数据格式下抛异常
function SafeChart({ data, config }: ChartProps) {
  return (
    <ErrorBoundary
      fallback={<ChartUnavailable dataId={config.dataId} />}
      tags={{ component: 'third-party-chart', dataId: config.dataId }}
    >
      <ThirdPartyChart data={data} config={config} />
    </ErrorBoundary>
  )
}

可观测性:让错误有上下文

ErrorBoundary 解决「捕获」和「降级」,可观测性解决「定位」和「预防」。一套可观测体系需要回答三个问题:错误发生在谁的身上、他当时在做什么、系统当时的状态是什么。

结构化错误上报

直接把 error.message 扔到监控平台是远远不够的。我通常按以下结构组织一条错误日志:

字段说明示例
error.name错误类型TypeError
error.message错误信息Cannot read properties of undefined (reading 'map')
error.stack调用堆栈经过 SourceMap 还原后的堆栈
componentStackReact 组件堆栈in ChartPanel > in DashboardPage > in App
route当前路由/dashboard/orders
queryParams查询参数&#123; page: 3, status: 'pending' &#125;
userActions最近 10 步操作['click:filter', 'api:GET/orders', ...]
apiStates近期接口响应[&#123; url: '/api/orders', status: 500 &#125;]
buildVersion构建版本号a3f2c1d
userAgent浏览器/设备信息Chrome 125 / macOS
// ❌ 坏做法:上报一个裸 Error 对象
function componentDidCatch(error: Error) {
  Sentry.captureException(error)
  // 在 Sentry 里只能看到一行堆栈
  // 不知道用户在哪个页面、做了什么操作、接口返回了什么
}
// ✅ 好做法:上报结构化上下文
function componentDidCatch(error: Error, info: React.ErrorInfo) {
  Sentry.withScope((scope) => {
    scope.setTag('module', this.props.tags?.module ?? 'unknown')
    scope.setTag('build', BUILD_VERSION)
    scope.setContext('react', { componentStack: info.componentStack })
    scope.setContext('route', {
      path: location.pathname,
      query: Object.fromEntries(new URLSearchParams(location.search)),
    })
    scope.setContext('environment', {
      buildVersion: BUILD_VERSION,
      env: APP_ENV,
    })
    // 面包屑已经在 Sentry.init 中通过 integrations 自动记录了
    // 包括路由变化、点击事件、fetch/XHR 请求
    Sentry.captureException(error)
  })
}

差异在于:前者在 Sentry 里只能看到一条异常堆栈,需要反复联系用户问「你在哪个页面操作的时候出的问题」;后者直接能看到用户在 /dashboard/orders?page=3 触发了一个 500 响应,然后组件树在 ChartPanel 渲染时崩溃,完整的证据链直接支撑定位。

错误分类与分级

不是所有错误都值得报警。我把前端错误分成三类,每类对应不同的处理策略:

分类特征处理策略是否报警
可恢复错误网络超时、接口限流、数据加载中异常自动重试 + 展示 loading 或骨架屏否,只记录
业务空状态数据为空、无权限、资源不存在展示空状态 UI 或权限提示否,属于正常业务路径
真异常TypeError、ReferenceError、数据格式异常降级 UI + 立即上报 + 严重等级标记是,按影响面设置告警
// ❌ 坏做法:所有错误一视同仁
function handleApiError(error: unknown) {
  Sentry.captureException(error)
  showToast('加载失败,请稍后重试')
  // 网络超时和数据结构异常走了同一条路
  // 超时应该静默重试,不该和用户说「请稍后」
}
// ✅ 好做法:按错误类型分别处理
function handleApiError(error: unknown) {
  if (isNetworkError(error)) {
    // 网络错误:静默重试,不打扰用户
    return retryRequest(error.config, { maxRetries: 3 })
  }
 
  if (isBusinessError(error)) {
    // 业务错误:正常路径,不需要上报
    return showEmptyState(error.code)
  }
 
  // 真异常:上报 + 降级 + 报警
  Sentry.captureException(error, {
    tags: { errorCategory: 'unexpected' },
    extra: { responseStatus: error.status, responseData: error.data },
  })
  showFallbackUI()
  notifyOnCall(error)
}

面包屑追踪

Sentry 的面包屑(Breadcrumbs)机制是可观测性里最被低估的部分。它自动记录用户在出错前的操作序列——路由跳转、DOM 点击、console 调用、HTTP 请求——形成一条时间线。

面包屑类型自动记录需要手动添加
路由变化✅(react-router integration)
DOM 点击✅(默认开启)
HTTP 请求✅(fetch/XHR 拦截)
console.log✅(默认开启)
自定义业务事件✅(用户登录状态、权限变更等)
组件状态快照✅(关键表单数据、选中项等)

手动添加业务面包屑的方式很简单:

// 在关键业务节点添加面包屑
function handleOrderSubmit(orderId: string) {
  Sentry.addBreadcrumb({
    category: 'order',
    message: `提交订单: ${orderId}`,
    level: 'info',
    data: { orderId, itemCount: cart.items.length },
  })
  // ...提交逻辑
}

当这个用户后续触发了异常,在 Sentry 的面包屑时间线里就能看到「用户先添加了 3 件商品 → 提交了订单 → 接口 500 → 页面崩溃」的完整路径。

重试与恢复策略

错误边界捕获了异常、展示了降级 UI 之后,用户最自然的反应是「再试一次」。但直接让用户刷新页面是最差的恢复体验。更好的做法是在错误边界内部提供恢复机制。

恢复方式适用场景实现成本用户体验
组件内重试接口临时故障、网络抖动最佳,无感恢复
重置错误状态数据格式异常、依赖外部状态好,点击按钮即可
路由跳转当前路由数据彻底不可用一般,需要用户重新导航
页面刷新应用状态完全损坏最低最差,丢失所有上下文
// ✅ 带重试的错误边界
function ErrorBoundaryWithRetry({
  children,
  fallback,
  maxRetries = 2,
  ...rest
}: ErrorBoundaryProps) {
  const [retryCount, setRetryCount] = useState(0)
 
  return (
    <ErrorBoundary
      {...rest}
      fallbackRender={({ error, resetErrorBoundary }) => {
        // 在自动重试次数内静默重试
        if (retryCount < maxRetries) {
          setTimeout(() => {
            setRetryCount((c) => c + 1)
            resetErrorBoundary()
          }, 1000 * (retryCount + 1))
          // 指数退避:1s, 2s, 3s
          return <LoadingHint retryCount={retryCount + 1} />
        }
        // 超过重试次数,展示可操作的降级 UI
        return fallback ?? (
          <FallbackCard
            error={error}
            onRetry={() => {
              setRetryCount(0)
              resetErrorBoundary()
            }}
          />
        )
      }}
    >
      {children}
    </ErrorBoundary>
  )
}

重试策略有两个容易踩的坑。第一是无限重试:如果后端服务确实宕了,前端疯狂重试只会加重服务端压力,必须设置最大重试次数和退避间隔。第二是重试时不清理状态:如果错误是由脏数据导致的,重置错误边界后同样的数据还会触发同样的错误,需要在重试前重新拉取数据。

错误处理的反面模式

做了这些建设之后,我复盘了之前项目里常见的几个反面模式,写出来当检查项用。

// ❌ 模式一:吞掉错误
try {
  const data = JSON.parse(response)
  return <Chart data={data} />
} catch {
  return <div>--</div>
  // 错误被静默吞掉了,页面上显示一个短横线
  // 开发不知道这里出过错,数据团队也不知道数据有异常
}
// ❌ 模式二:catch 了但不上报
async function fetchData() {
  try {
    const res = await fetch('/api/orders')
    return await res.json()
  } catch (error) {
    console.error(error)
    // console.error 在生产环境没人看
    // 错误在这里消失了
    return null
  }
}
// ❌ 模式三:错误边界里没有状态重置
class MyErrorBoundary extends React.Component {
  static getDerivedStateFromError() {
    return { hasError: true }
  }
 
  render() {
    if (this.state.hasError) {
      return <p>出错了</p>
      // 即使路由变了,hasError 仍然是 true
      // 用户导航到其他页面仍然看到降级 UI
      // 必须同时监听路由变化来重置状态
    }
    return this.props.children
  }
}
// ❌ 模式四:在事件处理函数里依赖 ErrorBoundary
function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
  const handleClick = async () => {
    await onSubmit()
    // 如果 onSubmit 内部抛异常
    // ErrorBoundary 不会捕获它——事件处理函数不在渲染阶段
  }
  return <button onClick={handleClick}>提交</button>
}
 
// ✅ 事件处理函数需要自己 try-catch
function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
  const handleClick = async () => {
    try {
      await onSubmit()
    } catch (error) {
      // 这里需要手动上报
      Sentry.captureException(error)
      showToast('提交失败')
    }
  }
  return <button onClick={handleClick}>提交</button>
}

这四个反面模式对应四种不同的遗漏:吞错误、不上报、不恢复、捕获范围误解。在代码审查时我通常会逐一检查。

落地检查清单

以下清单按实施阶段分组,可以在项目里直接使用。

阶段一:基础建设

  • 应用最外层部署全局 ErrorBoundary,包含降级 UI 和上报逻辑
  • Sentry(或其他监控平台)完成初始化,开启 reactRouterV6Integration 自动记录路由面包屑
  • 配置 SourceMap 上传,确保生产环境堆栈可还原
  • 设置 beforeSend 钩子,过滤开发环境错误和已知的第三方脚本报错
  • 错误上报包含 buildVersionenvuserId 等基础上下文标签

阶段二:分级部署

  • 按功能模块(非按页面)部署分区 ErrorBoundary,每个带 module 标签
  • 高风险第三方组件外包独立 ErrorBoundary
  • 全局边界和分区边界的 componentDidCatch 使用统一的 reportError 函数
  • 降级 UI 提供至少一种恢复路径(重试、返回、导航到首页)

阶段三:错误分类与处理

  • 接口错误处理层区分网络错误(重试)、业务错误(展示空状态)和真异常(上报)
  • 事件处理函数中的异步错误有独立的 try-catch 和上报
  • 重试策略设置了最大次数和指数退避间隔
  • 没有 catch &#123;&#125; 静默吞掉错误的代码路径

阶段四:可观测性完善

  • 关键业务节点手动添加 Sentry 面包屑(订单提交、支付确认、表单提交)
  • 监控平台按 moduleerrorCategorybuildVersion 配置告警规则
  • 每周查看错误 Top 10 列表,推动修复高频问题
  • 新页面或新功能上线时同步检查错误边界覆盖

小结

ErrorBoundary 和可观测性不是两个独立的话题。ErrorBoundary 没有上下文上报等于白捕获,可观测性没有 ErrorBoundary 兜底等于只看数据不让用户用。我的经验是把它们当作一个整体来设计:错误边界负责「不让用户看到崩溃」,可观测性负责「让工程师看到真相」。

白屏本身不可怕——任何复杂系统都会出错。可怕的是白屏之后,团队花三天时间还没定位到原因。把证据链做完整,白屏就变成了一个可以快速修复的工程问题。

参考资料

评论 0

0 / 1000