Vue 基础体系 · 第 18/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 错误处理与可观测性:Error Boundary、日志、性能和发布诊断

在前端应用中,“出现错误”与“能够诊断错误”是两件不同的事。错误处理决定页面是否崩溃、是否展示降级界面、是否允许用户重试;可观测性则决定开发者能否回答以下问题:

  • 错误发生在什么版本、什么页面和什么用户操作中?
  • 是哪个组件、请求或异步任务导致的?
  • 影响了多少用户,是否与某次发布或某个特性开关相关?
  • 错误发生前,页面是否已经出现长任务、请求变慢或资源加载失败?
  • 修复发布后,错误率和性能是否恢复?

Vue 3 提供了组件级错误捕获、应用级错误处理和开发期警告处理能力,但它没有一个名为 ErrorBoundary 的内置组件。工程中的 Error Boundary 通常是基于 onErrorCaptured 自己实现的组件模式。


一、先建立错误处理的边界

1. 什么算作 Vue 错误

Vue 运行时会对许多与组件生命周期相关的错误进行捕获,并交给 Vue 的错误处理链。常见来源包括:

  • 组件渲染函数或模板表达式;
  • setup()
  • 生命周期钩子;
  • watchwatchEffect 回调;
  • 事件处理器;
  • 自定义指令钩子;
  • 过渡钩子;
  • 异步组件和 async setup() 相关流程。

Vue 应用级错误处理器的形式是:

app.config.errorHandler = (err, instance, info) => {
  // err:原始错误
  // instance:发生错误的组件实例,可能为 null
  // info:Vue 提供的错误来源描述
}

这里的 info 是 Vue 运行时生成的上下文信息,例如渲染、生命周期、事件处理等。它适合用于分类和检索,但不应被当作稳定的机器协议;不同 Vue 版本或不同运行路径可能产生不同描述。

2. Vue 错误边界不是“捕获所有 JavaScript 异常”

一个 Vue 错误边界通常只能捕获其后代组件树中的 Vue 错误。下面的错误不应假定会被 onErrorCaptured 捕获:

setTimeout(() => {
  throw new Error('timer error')
}, 0)

这是浏览器定时器回调中的异常,不一定经过 Vue 的调用包装。应使用浏览器级兜底:

window.addEventListener('error', (event) => {
  reportError(event.error ?? new Error(event.message), {
    source: 'window.error',
    filename: event.filename,
    line: event.lineno,
    column: event.colno,
  })
})

window.addEventListener('unhandledrejection', (event) => {
  reportError(normalizeError(event.reason), {
    source: 'unhandledrejection',
  })
})

这两类处理覆盖的是:

  • error:脚本执行异常、资源加载异常等浏览器错误;
  • unhandledrejection:没有被处理的 Promise rejection。

它们是全局兜底,不应替代 Vue 组件级降级。全局监听器只能记录错误,通常无法准确知道应该替换哪个局部界面。


二、Vue 的错误传播链

Vue 的错误处理通常可以抽象成下面的路径:

flowchart TD
    A[组件渲染/生命周期/事件/watch 异常] --> B[最近的 onErrorCaptured]
    B -->|返回 false| C[停止继续向上冒泡]
    B -->|未返回 false| D[继续向父组件传播]
    D --> E[app.config.errorHandler]
    E --> F[日志与监控系统]
    
    G[浏览器 error] --> F
    H[未处理 Promise rejection] --> F

onErrorCaptured 的基本用法:

<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'

const error = ref<unknown>(null)

onErrorCaptured((err, instance, info) => {
  error.value = err

  console.error('子树错误', {
    err,
    instance,
    info,
  })

  // 不返回 false:允许继续传播到父级和 app.config.errorHandler
})
</script>

<template>
  <div v-if="error">
    子模块暂时不可用,请刷新后重试。
  </div>

  <slot v-else />
</template>

传播有两个容易混淆的规则:

  1. onErrorCaptured 主要捕获后代组件错误,而不是把自身所有错误都变成可捕获错误。
  2. 如果钩子返回 false,Vue 会停止该错误继续向上传播。这样可以避免全局错误处理器再次处理,但也可能让监控系统完全丢失这次错误。

因此,除非已经明确完成了记录和告警,否则不要随意返回 false

app.config.errorHandler

应用级处理器适合做统一记录:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { reportError } from './observability'

const app = createApp(App)

app.config.errorHandler = (err, instance, info) => {
  reportError(err, {
    source: 'vue',
    info,
    componentName: instance?.type
      ? getComponentName(instance.type)
      : undefined,
  })
}

app.mount('#app')

function getComponentName(type: unknown): string | undefined {
  if (typeof type !== 'object' || type === null) {
    return undefined
  }

  const value = type as { name?: string; __name?: string }
  return value.name ?? value.__name
}

instance 可能为空,因此日志代码必须允许组件实例缺失。也不要直接序列化整个组件实例:它包含响应式对象、父子关系和内部字段,可能造成循环引用、隐私泄露或巨大日志。

app.config.warnHandler

Vue 还提供开发期警告处理器:

app.config.warnHandler = (message, instance, trace) => {
  console.warn('[Vue warning]', {
    message,
    trace,
  })
}

它适合在本地开发或测试环境中把警告转换为更容易检索的输出。生产构建通常会移除或裁剪开发期警告,因此不能把 warnHandler 当作生产错误监控机制。


三、实现一个可恢复的 Error Boundary

一个有用的 Error Boundary 不只是显示“出错了”,还需要完成四件事:

  1. 捕获子树错误;
  2. 将错误转换为用户可理解的状态;
  3. 记录开发者需要的上下文;
  4. 通过重新挂载子树提供恢复机会。

1. 错误规范化

JavaScript 中抛出的值不一定是 Error

throw 'failed'
throw { code: 'NETWORK_ERROR' }
return Promise.reject(null)

因此应先统一结构:

export interface NormalizedError {
  name: string
  message: string
  stack?: string
  code?: string
  cause?: unknown
}

export function normalizeError(input: unknown): NormalizedError {
  if (input instanceof Error) {
    const error = input as Error & { code?: string }

    return {
      name: error.name || 'Error',
      message: error.message || 'Unknown error',
      stack: error.stack,
      code: error.code,
      cause: error.cause,
    }
  }

  if (typeof input === 'string') {
    return {
      name: 'ThrownString',
      message: input,
    }
  }

  try {
    return {
      name: 'ThrownValue',
      message: JSON.stringify(input),
    }
  } catch {
    return {
      name: 'ThrownValue',
      message: String(input),
    }
  }
}

规范化的目的不是改变原始错误,而是让日志管道具有稳定字段。原始值可以作为受控的 cause 保存,但不应无条件将整个对象发送到服务端。

2. Boundary 组件

下面是一个可运行的 Vue 3 <script setup> 示例:

<!-- ErrorBoundary.vue -->
<script setup lang="ts">
import { nextTick, onErrorCaptured, ref } from 'vue'
import { normalizeError, reportError } from './observability'

const error = ref<ReturnType<typeof normalizeError> | null>(null)
const attempt = ref(0)

onErrorCaptured((rawError, instance, info) => {
  const normalized = normalizeError(rawError)

  error.value = normalized

  reportError(rawError, {
    source: 'error-boundary',
    vueInfo: info,
    componentName: getComponentName(instance?.type),
  })

  // 不返回 false,让 app.config.errorHandler 也能收到这次错误。
})

async function retry() {
  error.value = null
  attempt.value += 1

  // 等待当前错误状态完成更新,再重新挂载子树。
  await nextTick()
}

function getComponentName(type: unknown): string | undefined {
  if (typeof type !== 'object' || type === null) {
    return undefined
  }

  const value = type as { name?: string; __name?: string }
  return value.name ?? value.__name
}
</script>

<template>
  <section v-if="error" role="alert">
    <h2>该模块暂时不可用</h2>
    <p>请稍后重试。如果问题持续存在,请联系支持人员。</p>

    <details>
      <summary>错误信息</summary>
      <pre>{{ error.name }}: {{ error.message }}</pre>
    </details>

    <button type="button" @click="retry">
      重试
    </button>
  </section>

  <!-- key 变化后,后代组件会重新创建 -->
  <div v-else :key="attempt">
    <slot />
  </div>
</template>

使用方式:

<script setup lang="ts">
import ErrorBoundary from './ErrorBoundary.vue'
import UserDashboard from './UserDashboard.vue'
</script>

<template>
  <ErrorBoundary>
    <UserDashboard />
  </ErrorBoundary>
</template>

UserDashboard 的渲染、生命周期或 Vue 管理的事件处理器抛出错误时,Boundary 会把正常内容替换成降级界面。点击重试后,attempt 改变,包裹后代的节点被重新创建,从而重置后代组件的本地状态。

3. 重试不等于恢复

重新挂载只能恢复组件内存状态,不能自动修复以下问题:

  • 服务端持续返回 500;
  • 当前用户没有权限;
  • 发布包中确实存在确定性 bug;
  • 依赖资源始终加载失败;
  • 数据格式已经与前端代码不兼容。

因此重试按钮需要避免无限重试。可以记录每个错误指纹的尝试次数,并在超过阈值后显示刷新页面或联系支持人员的选项。错误指纹可由以下字段组合:

release + route + component + error.name + normalized message

不要直接用完整 stack 作为唯一指纹,因为构建差异、行号变化和动态参数会导致同一问题产生大量事件组。


四、统一日志:记录什么,舍弃什么

1. 错误事件的最小结构

一个生产错误事件至少应包含:

export interface ErrorEvent {
  type: 'error'
  timestamp: string
  release: string
  environment: 'development' | 'staging' | 'production'
  source:
    | 'vue'
    | 'error-boundary'
    | 'window.error'
    | 'unhandledrejection'
    | 'request'
  name: string
  message: string
  stack?: string
  route?: string
  componentName?: string
  vueInfo?: string
  requestId?: string
  userAction?: string
}

这些字段分别回答不同问题:

  • release 判断是否与某次发布相关;
  • source 区分 Vue 错误、请求失败和浏览器错误;
  • routecomponentName 定位功能区域;
  • requestId 将前端事件与服务端日志关联;
  • userAction 记录错误发生前的业务动作,例如提交表单或切换分页。

2. 一个简单的上报实现

// observability.ts
import { normalizeError } from './normalizeError'

const release = import.meta.env.VITE_APP_RELEASE ?? 'local'
const environment =
  import.meta.env.MODE === 'production'
    ? 'production'
    : import.meta.env.MODE === 'staging'
      ? 'staging'
      : 'development'

export function reportError(
  rawError: unknown,
  context: Record<string, unknown> = {},
) {
  const error = normalizeError(rawError)

  const event = {
    type: 'error' as const,
    timestamp: new Date().toISOString(),
    release,
    environment,
    name: error.name,
    message: error.message,
    stack: error.stack,
    ...context,
  }

  // 开发环境先输出完整事件,便于验证字段。
  if (environment !== 'production') {
    console.error('[observability]', event)
    return
  }

  const body = JSON.stringify(event)
  const blob = new Blob([body], { type: 'application/json' })

  const sent = navigator.sendBeacon?.('/api/client-errors', blob)

  if (!sent) {
    void fetch('/api/client-errors', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body,
      keepalive: true,
    }).catch(() => {
      // 上报失败不能再次制造未处理 rejection。
    })
  }
}

前置条件是服务端存在 /api/client-errors 接收接口。验证步骤如下:

  1. 在开发环境中触发一次组件错误;
  2. 检查控制台中的结构化对象;
  3. 在生产模式下通过 vite build 和静态服务器运行;
  4. 在浏览器 Network 面板检查 /api/client-errors
  5. 验证请求失败时不会产生新的 unhandledrejection

sendBeacon 适合页面卸载阶段发送小型日志,但它不保证服务端一定成功接收,也不适合大体积 stack、源码或用户数据。生产实现通常还需要服务端鉴权、大小限制、速率限制和字段清洗。

3. 不要把敏感数据写进日志

以下内容不应直接上报:

  • access token、refresh token、Cookie;
  • 密码和表单中的完整身份证号;
  • 完整 URL 查询参数;
  • 未脱敏的用户输入;
  • 整个 Pinia/Vuex 状态树;
  • 可能包含隐私数据的请求响应。

例如,下面的做法风险很高:

reportError(error, {
  state: JSON.stringify(store),
  url: location.href,
})

更合理的方式是白名单字段:

reportError(error, {
  route: location.pathname,
  accountType: currentUser.accountType,
  feature: 'invoice-list',
})

日志系统本身也是生产数据系统,应按最小化原则采集,并在服务端再次过滤,而不是只依赖前端自觉。


五、重复错误、采样和错误率

一个错误可能在一个页面中连续触发数百次。若每次都上报,会造成噪声、成本和告警风暴。

1. 去重窗口

可以在浏览器内对相同指纹设置时间窗口:

const recent = new Map<string, number>()
const DEDUPE_WINDOW_MS = 60_000

function fingerprint(input: {
  release: string
  route: string
  name: string
  message: string
}) {
  return [
    input.release,
    input.route,
    input.name,
    input.message,
  ].join('|')
}

export function shouldReport(key: string): boolean {
  const now = Date.now()
  const previous = recent.get(key)

  if (previous !== undefined && now - previous < DEDUPE_WINDOW_MS) {
    return false
  }

  recent.set(key, now)
  return true
}

这段逻辑只适合降低客户端重复事件,不代表问题只发生了一次。更完整的系统还应在服务端按指纹聚合,并保留发生次数、受影响用户数和首次/最近发生时间。

2. 采样的含义

设某类错误总发生次数为 NN,每个事件以概率 pp 上报,则期望上报量为:

E(S)=pNE(S) = pN

其中:

  • NN 是真实事件数量;
  • pp 是采样率;
  • SS 是上报事件数量。

如果希望估计真实发生量,可以使用:

N^=Sp\hat{N} = \frac{S}{p}

但这个估计成立的前提是采样对事件独立且无系统性偏差。实际中常用不同策略:

  • 新版本、严重错误:100% 上报;
  • 已知高频低影响错误:按固定比例采样;
  • 页面性能指标:按会话或用户采样;
  • 首次出现的错误指纹:优先完整上报。

不要对“影响面很大的错误”简单降采样,否则可能只剩下看似稀疏的事件,无法及时触发告警。

3. 错误率必须有分母

“今天收到了 10 万条错误”不等于系统一定更差。更有意义的指标包括:

用户错误率=出现该错误的唯一用户数活跃用户数\text{用户错误率} = \frac{\text{出现该错误的唯一用户数}} {\text{活跃用户数}}

会话错误率=出现错误的会话数总会话数\text{会话错误率} = \frac{\text{出现错误的会话数}} {\text{总会话数}}

对于请求错误,还可以计算:

请求失败率=失败请求数总请求数\text{请求失败率} = \frac{\text{失败请求数}} {\text{总请求数}}

分母必须与指标范围一致。例如,订单页请求失败率不能用整个站点的页面访问量作为分母。


六、异步请求中的错误、取消和竞态

组件错误边界不能替代请求状态管理。请求失败通常是业务状态,而不是组件崩溃。

一个请求状态至少应区分:

type RequestStatus =
  | 'idle'
  | 'loading'
  | 'success'
  | 'error'
  | 'cancelled'

取消请求也不应被当作普通错误上报。下面是一个处理请求竞态的 Composable:

import { onBeforeUnmount, ref } from 'vue'

interface RequestState<T> {
  status: RequestStatus
  data: T | null
  error: unknown
}

export function useLatestRequest<T, P>(
  request: (params: P, signal: AbortSignal) => Promise<T>,
) {
  const state = ref<RequestState<T>>({
    status: 'idle',
    data: null,
    error: null,
  })

  let controller: AbortController | null = null
  let sequence = 0

  async function execute(params: P) {
    controller?.abort()

    const currentController = new AbortController()
    controller = currentController
    const currentSequence = ++sequence

    state.value = {
      ...state.value,
      status: 'loading',
      error: null,
    }

    try {
      const data = await request(params, currentController.signal)

      // 即使底层请求没有正确响应 abort,也禁止旧请求覆盖新请求。
      if (
        currentSequence !== sequence ||
        currentController.signal.aborted
      ) {
        return
      }

      state.value = {
        status: 'success',
        data,
        error: null,
      }
    } catch (error) {
      if (currentController.signal.aborted) {
        if (currentSequence === sequence) {
          state.value = {
            ...state.value,
            status: 'cancelled',
          }
        }
        return
      }

      if (currentSequence !== sequence) {
        return
      }

      state.value = {
        ...state.value,
        status: 'error',
        error,
      }
    }
  }

  onBeforeUnmount(() => {
    controller?.abort()
  })

  return {
    state,
    execute,
  }
}

这里有两个独立机制:

  1. AbortController 取消不再需要的网络请求;
  2. sequence 防止旧请求结果覆盖新请求结果。

只使用取消并不充分,因为:

  • 某些请求适配器未完全支持取消;
  • 响应可能已经进入 JavaScript 队列;
  • 缓存层可能返回一个无法取消的 Promise。

请求错误可以在请求层记录一次:

reportError(error, {
  source: 'request',
  requestId,
  endpoint: '/api/users',
  method: 'GET',
})

但如果界面还被 Error Boundary 捕获,必须避免同一请求失败被当成两个完全独立的问题重复告警。可以用 requestId 或错误指纹关联,而不是简单地删除其中一条。

请求恢复的边界

不同错误应采用不同恢复策略:

  • 401:刷新身份或跳转登录;
  • 403:展示无权限状态,不应无限重试;
  • 404:展示资源不存在;
  • 408、网络断开:允许用户重试;
  • 429:遵守服务端的限流信息;
  • 500:有限次数重试,并记录服务端 request ID;
  • JSON 解析失败:通常是接口协议或代理异常,应提高告警等级。

把所有错误都映射成“请重试”,会掩盖权限、协议和发布错误。


七、性能可观测性:从时间点到用户体验

性能日志不是简单记录“页面加载用了多少毫秒”。需要先确定测量对象。

1. 常用性能时间

浏览器 Performance API 能提供导航、资源和用户标记:

performance.mark('dashboard-start')

// 数据和首屏组件准备完成后
performance.mark('dashboard-ready')
performance.measure(
  'dashboard-ready-time',
  'dashboard-start',
  'dashboard-ready',
)

const measure = performance.getEntriesByName(
  'dashboard-ready-time',
).at(-1)

if (measure) {
  console.log({
    name: measure.name,
    duration: measure.duration,
  })
}

这段代码测量的是应用自定义的“开始到准备完成”时间,不等同于浏览器的首屏绘制、最大内容绘制或交互延迟。测量点必须明确,否则不同团队会把不同阶段混成一个数字。

2. Vue 的性能标记

Vue 3 支持:

if (import.meta.env.DEV) {
  app.config.performance = true
}

这是开发期能力,用于配合浏览器 Performance 面板或 Vue Devtools 检查组件相关性能。它不是生产性能监控方案,也不应据此推断生产环境的真实耗时。

生产环境可以使用标准浏览器 Performance API 和 Web Vitals 采集用户体验指标,例如:

  • LCP:主要内容何时完成呈现;
  • INP:交互响应延迟;
  • CLS:布局稳定性。

这些指标反映用户体验,但不能直接定位某个 Vue 组件。需要把指标与路由、版本、设备类型、网络类型等上下文关联起来。

3. 观察长任务

长任务会阻塞主线程,使输入和渲染延迟。现代浏览器可以使用 PerformanceObserver

export function observeLongTasks(
  report: (event: {
    duration: number
    startTime: number
  }) => void,
) {
  if (!('PerformanceObserver' in window)) {
    return () => {}
  }

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      report({
        duration: entry.duration,
        startTime: entry.startTime,
      })
    }
  })

  try {
    observer.observe({ type: 'longtask', buffered: true })
  } catch {
    // 当前浏览器不支持该观察类型。
    return () => {}
  }

  return () => observer.disconnect()
}

风险在于 buffered: true 可能一次读出页面早期积累的记录,且不同浏览器支持程度不同。上报时应限制采样率,避免每个长任务都产生网络请求。

4. 把性能与错误关联

单独看错误率和性能容易漏掉因果关系。一个发布可能同时导致:

  • JavaScript 包体积变大;
  • 首屏交互变慢;
  • 用户重复点击;
  • 请求被重复发送;
  • 随后出现更多状态冲突错误。

因此错误事件和性能事件至少应共享:

release
environment
route
sessionId
device class
network type

如果某个错误只在 INP 较高的会话中显著增加,就应检查主线程阻塞、重复渲染和事件重入,而不是只看异常 stack。


八、源代码映射与生产堆栈诊断

Vite 生产构建后的 JavaScript 通常经过压缩和打包。浏览器上报的堆栈可能只显示:

assets/index-B8k3.js:1:48321

这无法直接对应源码。Source Map 用于建立:

生产 bundle 行列号
        ↓
TypeScript/Vue 源文件、行号和列号

1. 构建配置

Vite 中可以配置生产 source map:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    sourcemap: true,
  },
})

执行:

npm run build
npm run preview

预期结果是:

  • dist/ 中生成构建后的资源;
  • 同时生成对应的 .map 文件;
  • vite preview 启动本地静态预览服务。

build.sourcemap: true 会让 source map 可能被公开访问。source map 通常包含原始源码路径、变量名甚至源码内容,因此公开部署有泄露风险。

更常见的生产策略是:

  • 构建时生成 source map;
  • 上传到受控的错误监控服务;
  • 静态资源服务器不公开 .map
  • 发布完成后验证监控服务能正确反解堆栈。

具体上传命令取决于监控产品,不能假定所有服务都使用相同 CLI 或配置。

2. 发布版本必须稳定

前端错误事件应携带构建版本:

VITE_APP_RELEASE=web-2025.03.08-abc1234

Vite 的 VITE_ 变量会进入客户端代码,因此它只能放公开的版本标识,不能放密钥。

推荐使用不可变的 release 标识,例如:

应用名 + 日期 + Git commit SHA

这样可以将以下对象关联起来:

  • 错误事件;
  • source map;
  • 静态资源;
  • Git 提交;
  • CI 构建记录;
  • 部署批次。

如果每次部署都使用 latest,旧错误可能被错误地解析到新 source map,导致定位结果不可信。


九、发布诊断:从构建到回滚

发布诊断的核心不是“出了问题再看日志”,而是让发布本身可验证。

1. 构建阶段

npm ci
npm run typecheck
npm run test:unit
npm run build

每一步的目的不同:

  • npm ci 使用锁文件安装依赖,减少环境漂移;
  • typecheck 检查 TypeScript 类型错误;
  • test:unit 验证错误状态、边界组件和请求竞态;
  • build 验证生产编译、插件和资源生成。

注意:类型检查通过不代表模板运行时一定安全,单元测试通过也不代表 source map、CDN 缓存或真实浏览器兼容性正确。

2. 构建产物验证

可以检查:

find dist -maxdepth 2 -type f | sort

应重点确认:

  • 入口 JavaScript 和 CSS 存在;
  • 静态资源路径正确;
  • 是否意外暴露 source map;
  • HTML 中引用的资源是否都能通过 HTTP 获取;
  • 版本号是否进入运行时事件。

不要只使用文件是否存在作为验证。还需要用浏览器访问 vite preview 或部署后的 URL,检查:

  • 首屏是否有运行时错误;
  • 路由刷新是否返回正确的 HTML;
  • 动态导入失败时是否有降级;
  • API 地址是否指向正确环境;
  • 错误上报请求是否可达。

3. 动态导入失败

网络缓存不一致或 CDN 发布不完整时,可能出现:

Failed to fetch dynamically imported module

这类错误通常不是组件内部异常,而是“HTML、入口包和异步 chunk 不属于同一版本”。可采用:

  • 静态资源文件名使用内容哈希;
  • 新旧资源在部署窗口内同时可访问;
  • HTML 不长期缓存;
  • 监听动态导入失败并提示刷新;
  • 记录 release、chunk URL 和当前页面 URL。

页面级自动刷新必须谨慎,否则可能造成无限刷新循环。可以在 sessionStorage 中记录一次刷新标记,并在再次失败时停止自动刷新。

4. 灰度与回滚

发布后的诊断应比较相对变化,而不是只看绝对数量:

新版本错误率 - 基线版本错误率
新版本请求失败率 - 基线请求失败率
新版本 INP/LCP 分位数 - 基线分位数

如果新版本错误主要集中在一个 release、一个浏览器或一个特性开关,应先关闭开关或回滚该版本,而不是等待更多用户受影响。

回滚也有资源兼容性风险:旧 HTML 可能引用旧 chunk,旧 chunk 如果已经被清理,回滚后仍会出现动态导入失败。因此静态资源保留策略应覆盖发布回滚窗口。


十、错误边界的测试

错误处理本身必须测试,否则最容易在“真正出错时”失效。

1. 测试 Boundary 显示降级界面

// ErrorBoundary.spec.ts
import { defineComponent, h } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import ErrorBoundary from './ErrorBoundary.vue'

const BrokenChild = defineComponent({
  name: 'BrokenChild',
  setup() {
    throw new Error('child failed')
  },
  render() {
    return h('div')
  },
})

describe('ErrorBoundary', () => {
  it('shows fallback when a descendant throws', () => {
    const wrapper = mount(ErrorBoundary, {
      slots: {
        default: () => h(BrokenChild),
      },
      global: {
        config: {
          // 测试中避免 Vue 将预期异常额外打印成噪声。
          errorHandler: () => {},
        },
      },
    })

    expect(wrapper.text()).toContain('该模块暂时不可用')
    expect(wrapper.text()).not.toContain('child content')
  })
})

这个测试验证的是“后代组件在 setup 阶段抛错时,Boundary 能显示降级内容”。它不代表定时器异常、浏览器资源异常和未处理 rejection 也会被同一组件捕获。

2. 测试请求竞态

请求测试至少应覆盖:

  1. 第一次请求开始;
  2. 第二次请求开始并取消第一次;
  3. 第一次请求即使晚返回,也不能覆盖第二次结果;
  4. 取消不应作为业务错误上报;
  5. 真正失败时进入 error 状态;
  6. 组件卸载后不再更新状态。

对于端到端测试,还应模拟:

  • 接口 500;
  • 网络断开;
  • 动态 chunk 加载失败;
  • 用户点击重试;
  • 发布版本与日志事件是否一致。

十一、常见错误处理反模式

1. 只在 console.error 中记录

try {
  await save()
} catch (error) {
  console.error(error)
}

问题是日志没有版本、路由、请求 ID和用户操作,生产环境也不一定能访问用户控制台。应将错误记录与上下文关联,同时保留面向用户的明确状态。

2. 所有错误都让 Error Boundary 处理

请求失败、表单校验失败和权限不足通常属于可预期业务状态。把它们全部抛出到 Boundary 会导致:

  • 一个局部网络问题替换整块页面;
  • 用户无法保留已填写的表单;
  • 重试行为缺少请求参数和缓存上下文;
  • 正常业务失败被错误告警淹没。

Boundary 更适合处理组件无法继续渲染的异常,而请求状态应由组件或 Composable 显式建模。

3. 捕获后直接返回 false

onErrorCaptured((error) => {
  showFallback(error)
  return false
})

如果 showFallback 没有可靠上报,这段代码会截断全局传播,导致监控系统看不到错误。只有在当前层已经完成等价记录,并且明确不希望父级重复处理时,才应返回 false

4. 自动无限重试

watchEffect(async () => {
  await load()
  if (failed.value) {
    await load()
  }
})

这可能形成请求风暴。重试必须有边界、退避策略和取消机制,并区分可恢复错误与确定性错误。

5. 只看平均性能

平均值会掩盖少数设备和网络环境的严重问题。性能诊断通常应关注分位数,例如 P75 或 P95,并按浏览器、设备、网络、路由和发布版本切分。

6. 在错误日志中上传完整状态树

这既可能泄露隐私,也会让事件过大而被丢弃。可观测性需要的是足以定位问题的上下文,而不是把整个应用复制到日志系统。


十二、一个完整的初始化顺序

实际应用可以按以下顺序初始化:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { reportError } from './observability'

const app = createApp(App)

app.config.errorHandler = (error, instance, info) => {
  reportError(error, {
    source: 'vue',
    vueInfo: info,
    componentName: getComponentName(instance?.type),
    route: location.pathname,
  })
}

if (import.meta.env.DEV) {
  app.config.warnHandler = (message, _instance, trace) => {
    console.warn('[Vue warning]', message, trace)
    }
  app.config.performance = true
}

window.addEventListener('error', (event) => {
  void reportError(event.error ?? new Error(event.message), {
    source: 'window.error',
    route: location.pathname,
    filename: event.filename,
    line: event.lineno,
    column: event.colno,
  })
})

window.addEventListener('unhandledrejection', (event) => {
  void reportError(normalizeReason(event.reason), {
    source: 'unhandledrejection',
    route: location.pathname,
  })
})

app.mount('#app')

function normalizeReason(reason: unknown): Error {
  if (reason instanceof Error) {
    return reason
  }

  return new Error(
    typeof reason === 'string'
      ? reason
      : 'Unhandled Promise rejection',
  )
}

function getComponentName(type: unknown): string | undefined {
  if (typeof type !== 'object' || type === null) {
    return undefined
  }

  const value = type as { name?: string; __name?: string }
  return value.name ?? value.__name
}

初始化后的数据流是:

Vue 后代异常 ─┐
浏览器异常 ───┼→ 规范化 → 添加 release/route/context → 去重/采样 → 上报
Promise rejection ┘

请求失败 → 请求状态机 → 局部错误界面 → 必要时单独上报
性能事件 → 性能指标与 release/route 关联 → 分位数与错误率联合分析

这几条路径不能简单合并成一个“万能错误处理器”。组件崩溃、请求失败、资源加载失败和主线程阻塞的故障性质不同,恢复方式和告警等级也不同。


结语

Vue 3 的错误处理能力可以分为三层:

  1. onErrorCaptured:在组件树局部捕获并展示降级界面;
  2. app.config.errorHandler:统一接收 Vue 运行时错误;
  3. window.errorunhandledrejection:覆盖 Vue 之外的浏览器级异常。

在此基础上,可观测性需要继续解决三个问题:用稳定版本和上下文还原现场,用性能指标解释用户体验,用构建产物和 source map 将生产错误映射回源码。请求取消、竞态防护、错误状态机和测试体系则负责避免把可预期业务失败误判为组件崩溃。

最终可靠的系统不是“发生错误后显示一个按钮”,而是能够完成完整闭环:

故障发生
  → 正确分类
  → 局部隔离或全局兜底
  → 结构化记录
  → 与版本和性能关联
  → 验证修复
  → 必要时灰度、关闭开关或回滚

只有错误处理、日志、性能测量和发布诊断共同工作,前端异常才会从用户报告转变为可定位、可验证、可恢复的工程事件。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。