页面崩溃监控

页面崩溃是最糟糕的用户体验之一。用户看到白屏或页面卡死,你却完全不知道发生了什么。因为崩溃的瞬间,JS 执行环境已经挂了,常规的 try-catch 和错误上报全都不起作用。

所以崩溃监控的核心思路是:在崩溃发生前持续"留痕",崩溃恢复后检查这些痕迹来还原现场

我用过三种手段,覆盖不同的崩溃场景:

手段适用场景原理
心跳检测页面卡死、主线程阻塞、浏览器杀进程定期写入时间戳,下次加载时检测间隔
全局错误监听JS 运行时错误、未捕获的 Promise 异常error + unhandledrejection 事件
React 错误边界组件渲染异常componentDidCatch 捕获组件树错误

下面逐个看实现。

心跳检测

心跳检测的思路很朴素:页面正常运行时,每隔几秒往 localStorage 写一个时间戳。如果页面崩溃了,这个时间戳就会"停"在崩溃前的那一刻。下次用户打开页面时,检查上次心跳的时间差,超过阈值就说明上次崩了。

let heartbeatTimer: ReturnType<typeof setInterval> | null = null
let lastHeartbeat = Date.now()

function startHeartbeat() {
  heartbeatTimer = setInterval(() => {
    lastHeartbeat = Date.now()
    localStorage.setItem('last_heartbeat', lastHeartbeat.toString())

    // 用 sendBeacon 而不是 fetch,因为它是专门为页面卸载时设计的
    // 即使页面关闭了,浏览器也会保证请求发出去
    navigator.sendBeacon(
      '/api/heartbeat',
      JSON.stringify({
        timestamp: lastHeartbeat,
        url: window.location.href,
      })
    )
  }, 5000)
}

function stopHeartbeat() {
  if (heartbeatTimer) {
    clearInterval(heartbeatTimer)
    heartbeatTimer = null
  }
}

// 页面加载时启动心跳
startHeartbeat()

// 页面卸载时停止心跳
window.addEventListener('beforeunload', stopHeartbeat)

页面加载时,检查上次心跳是否"断"了:

function checkPreviousHeartbeat() {
  const last = localStorage.getItem('last_heartbeat')
  if (!last) return

  const timeSince = Date.now() - parseInt(last)
  if (timeSince > 30000) {
    // 超过 30 秒无心跳,大概率是崩溃了
    reportCrash({
      type: 'heartbeat_missing',
      timeSince,
    })
  }
}

checkPreviousHeartbeat()

局限性:心跳检测只能知道"页面挂了",但不知道为什么挂的。它适合做兜底方案,配合其他手段一起用。

全局错误监听

心跳检测是"事后推断",全局错误监听则是"实时捕获"。两种事件需要分别处理:

// JS 运行时错误(同步错误、语法错误等)
window.addEventListener(
  'error',
  (event) => {
    handleError({
      type: 'window_error',
      message: event.message,
      filename: event.filename,
      lineno: event.lineno,
      colno: event.colno,
      error: event.error?.toString(),
      stack: event.error?.stack,
    })
  },
  true // 捕获阶段,比冒泡更早触发
)

// 未捕获的 Promise 异常
window.addEventListener('unhandledrejection', (event) => {
  handleError({
    type: 'unhandled_rejection',
    reason: event.reason?.toString(),
    stack: event.reason?.stack,
  })
})

资源加载失败(图片、脚本、样式表加载异常)也需要监控,但处理方式不同。资源错误没有 stack,有用的信息是资源地址和失败原因(404、DNS 解析失败、网络断开、CORS 拦截等都可能触发):

window.addEventListener(
  'error',
  (event) => {
    const target = event.target as HTMLElement
    if (!target) return

    const resourceTags = ['IMG', 'SCRIPT', 'LINK']
    if (resourceTags.includes(target.tagName)) {
      report({
        type: 'resource_error',
        tag: target.tagName,
        src: (target as HTMLImageElement).src || (target as HTMLLinkElement).href,
        url: window.location.href,
        timestamp: Date.now(),
      })
    }
  },
  true
)

上报时附带环境信息,方便排查:

function handleError(errorData: Record<string, unknown>) {
  report({
    ...errorData,
    url: window.location.href,
    userAgent: navigator.userAgent,
    timestamp: Date.now(),
    screen: `${window.screen.width}x${window.screen.height}`,
    // performance.memory 是 Chrome 特有的,其他浏览器没有
    memory: (performance as any).memory
      ? {
          jsHeapSizeLimit: (performance as any).memory.jsHeapSizeLimit,
          totalJSHeapSize: (performance as any).memory.totalJSHeapSize,
          usedJSHeapSize: (performance as any).memory.usedJSHeapSize,
        }
      : null,
  })
}

局限性:全局错误监听能捕获大部分 JS 错误,但有两种情况抓不到:

  1. React 组件渲染错误 — 会被 React 内部吞掉,需要错误边界
  2. 崩溃前的最后几秒 — 错误可能来不及上报页面就挂了,所以心跳检测仍然是必要的兜底

React 错误边界

React 的渲染错误不会触发 window.error,因为 React 内部已经 catch 了。错误边界(Error Boundary)是 React 提供的官方方案,专门捕获组件树中的渲染异常。

export default class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean; error: Error | null }> {
  constructor(props: { children: React.ReactNode }) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    report({
      type: 'react_error_boundary',
      componentStack: errorInfo.componentStack,
      error: error.toString(),
      stack: error.stack,
      url: window.location.href,
      timestamp: Date.now(),
    })
  }

  render() {
    if (this.state.hasError) {
      return (
        <div style={{ padding: 20, textAlign: 'center' }}>
          <h2>页面出错了</h2>
          <button onClick={() => window.location.reload()}>重新加载页面</button>
        </div>
      )
    }

    return this.props.children
  }
}

使用时包裹在可能出错的组件外面:

function App(props: { children: React.ReactNode }) {
  return <ErrorBoundary>{props.children}</ErrorBoundary>
}

一个常见误区:错误边界不是万能的。它只捕获以下场景:

  • 子组件渲染过程中的错误
  • 生命周期方法中的错误
  • 构造函数中的错误

以下场景它抓不到

  • 事件处理函数中的错误(用 try-catch)
  • 异步代码(setTimeout、Promise)
  • 服务端渲染
  • 错误边界组件自身的错误

三者怎么配合

实际项目中,这三种手段是互补的:

崩溃类型心跳检测全局错误监听React 错误边界
JS 运行时错误能检测到能捕获
未捕获 Promise 异常能检测到能捕获
React 组件渲染错误能检测到抓不到能捕获
页面卡死/主线程阻塞能检测到抓不到抓不到
浏览器杀进程(OOM)能检测到抓不到抓不到

心跳检测是兜底,全局错误监听覆盖大多数 JS 错误,React 错误边界补上 React 专属的盲区。三者一起用,才能尽量减少"页面崩了你却不知道"的情况。