构建 Vue 全局错误处理体系,实现业务与错误的清晰解耦

9
发布时间:2026-11-02 12:04:58

你平时写业务代码时,是不是也经常对着满屏幕的 try...catch 发愁?本来只有两行核心逻辑,为了处理 401 跳转、业务报错提示、Sentry 上报,硬生生堆了十几行错误处理代码。时间一长,不仅组件臃肿不堪,改个全局提示还要翻遍几十个文件。

这篇文章为你梳理了一套拿来即用的 Vue 全局错误处理体系。我们将从 Vue 原生 errorHandler 的边界出发,一步步通过错误归一化、策略分发与统一上报,把“业务逻辑”和“错误处理”彻底解耦。读完这篇,你的代码不仅会更干净,排查线上 Bug 也会变得轻松很多。


一、为什么要做全局错误处理?

1、将业务逻辑与错误处理解耦

实际项目中,大多数组件的错误处理代码是长这样的:

1// 某个下单按钮的点击事件
2const submitOrder = async () => {
3  try {
4    await api.createOrder(payload)
5    message.success('下单成功')
6  } catch (e) {
7    if (e.response?.status === 401) {
8      router.push('/login')
9    } else if (e.code === 10001) {
10      ElMessage.warning('余额不足')
11    } else if (e.code === 10002) {
12      ElMessage.warning('库存不足')
13    } else {
14      ElMessage.error('提交失败,请稍后重试')
15    }
16    // 上报 Sentry
17    Sentry.captureException(e)
18  }
19}

这段代码暴露了三个问题:

  • 业务意图被噪音淹没:真正的业务逻辑(调用下单接口 → 成功提示)只占 2 行,错误分支占了 15 行。
  • 错误处理逻辑到处复制:每个组件都要写一遍 401 跳转、错误码映射、Sentry 上报,改一个逻辑就要全局搜索替换。
  • 判断条件硬编码:错误码、提示文案、跳转路径散落在各个 .vue 文件中,维护成本随模块数量指数级增长。

全局错误处理的核心价值,就是把上述"错了怎么办"的决策从业务组件中彻底抽离,让业务代码只表达"做什么"。

2、为监控和埋点提供统一入口

没有全局错误处理的团队,排查线上问题通常依赖用户截图或客服反馈。而一旦建立了统一的错误捕获入口,你可以做到:

  • 错误自动归因:知道是哪个组件、哪个接口、哪个路由下出错的;
  • 错误去重与采样:同一个错误在 60 秒内只报一次,避免监控告警被刷爆;
  • 分级别上报:预期内的业务错误(如余额不足)不上报,预期外的异常(如渲染崩溃)立即告警;
  • 离线兜底:弱网环境下暂存本地,下次请求时补发。

二、Vue 中的基础全局错误处理方式

1、Vue 中全局错误处理写法

Vue 3 通过 app.config.errorHandler 提供了一个全局的未捕获错误处理器:

1import { createApp } from 'vue'
2import App from './App.vue'
3
4const app = createApp(App)
5
6app.config.errorHandler = (err, instance, info) => {
7  console.error('全局捕获到错误:', err)
8  console.error('出错的组件:', instance?.$options?.name)
9  console.error('错误来源信息:', info)
10}
11
12app.mount('#app')

这个处理器需要在 mount 之前注册,这样从应用启动那一刻起,所有 Vue 调度范围内的异常都不会让页面白屏崩溃。

2、它会捕获哪些错误?

根据 Vue 3 官方文档,errorHandler 能捕获以下来源的错误:

  • 组件渲染(render / 模板编译后的渲染函数)
  • 事件处理程序(@click、@submit 等模板事件绑定)
  • 生命周期钩子(created、mounted、watchEffect 等)
  • setup() 函数(同步执行部分)
  • 侦听器(watch、computed 的求值过程)
  • 自定义指令钩子(mounted、updated 等)
  • 过渡钩子(<Transition> 的 enter/leave 回调)

3、它不会捕获哪些错误?

以下场景 errorHandler 完全无能为力,必须额外处理:

   
场景 原因 正确捕获方式
async 函数中未 catch 的 Promise rejection Vue 不拦截原生 Promise 链 window.addEventListener('unhandledrejection')
setTimeout / setInterval 回调中的异常 脱离了 Vue 的调度上下文 window.addEventListener('error')
资源加载失败(<img>、<script> 404) 属于 DOM 事件,非 JS 异常 window.addEventListener('error', ..., true) 捕获阶段
Web Worker 中的错误 运行在独立线程 Worker 内部的 onerror
fetch / 原生 XHR 的网络错误 不抛出异常,只返回 rejected Promise 在请求封装层统一拦截

4、errorHandler 的参数含义

1interface ErrorHandler {
2  (
3    err: unknown,                          // ① 错误对象本身
4    instance: ComponentPublicInstance | null, // ② 触发错误的组件实例
5    info: string                           // ③ 错误来源类型
6  ): void
7}
  • err:原生 Error 对象(或任何被 throw 的值),包含 message 和 stack。
  • instance:出错组件的实例。生产环境中可能为 null(如渲染根级别的错误)。可以通过 instance?.$options?.name 获取组件名。
  • info:Vue 内部给出的错误来源标识。开发环境下是完整描述,如 "render function"、"beforeMount hook";生产环境下会被压缩为短码(如 "r"、"bm"),需要对照 Vue 源码中的映射表还原。

三、全局错误处理的进阶设计

1、定义"可识别的业务错误"

如果所有错误都是一个裸 Error 对象,全局处理器根本没法做分类决策。第一步是建立一套可识别的错误类型体系:

1// src/error/types.ts
2export type ErrorLevel = 'silent' | 'info' | 'warn' | 'error' | 'fatal'
3export type ErrorAction = 'none' | 'toast' | 'modal' | 'redirect' | 'retry' | 'fallback'
4
5export interface ErrorMeta {
6  code: number              // 业务错误码,-1 表示未知
7  level: ErrorLevel         // 决定上报优先级
8  action: ErrorAction       // 决定用户侧反应
9  retryable: boolean        // 是否可重试
10  source: 'biz' | 'http' | 'auth' | 'validate' | 'resource' | 'framework'
11  context?: Record<string, unknown>
12}

然后为每种场景定义具体的错误类:

1// src/error/errors.ts
2export class AppError extends Error {
3  readonly __normalized = true
4  meta: ErrorMeta
5  original?: unknown
6
7  constructor(message: string, meta: Partial<ErrorMeta> = {}, original?: unknown) {
8    super(message)
9    this.name = 'AppError'
10    this.meta = {
11      code: -1, level: 'error', action: 'toast', retryable: false, source: 'framework',
12      ...meta
13    }
14    this.original = original
15    if (Error.captureStackTrace) Error.captureStackTrace(this, AppError)
16  }
17}
18
19// 预期内的业务错误:不上报,只给用户反馈
20export class BizError extends AppError {
21  constructor(message: string, code: number) {
22    super(message, { source: 'biz', level: 'warn', code })
23    this.name = 'BizError'
24  }
25}
26
27// 认证失效:需要跳转登录页
28export class AuthError extends AppError {
29  constructor(message = '登录已失效,请重新登录') {
30    super(message, { code: 401, source: 'auth', level: 'info', action: 'redirect', retryable: true })
31    this.name = 'AuthError'
32  }
33}
34
35// 表单校验失败
36export class ValidationError extends AppError {
37  constructor(message: string) {
38    super(message, { code: 400, source: 'validate', level: 'info', action: 'toast' })
39    this.name = 'ValidationError'
40  }
41}
42
43// 网络异常:需要上报 + 提示
44export class NetworkError extends AppError {
45  constructor(message = '网络异常,请检查连接') {
46    super(message, { code: 0, source: 'http', level: 'error', action: 'toast', retryable: true })
47    this.name = 'NetworkError'
48  }
49}
50
51/** 统一归一化:把任何 unknown 变成标准化错误 */
52export function normalizeError(err: unknown, hint?: Partial<ErrorMeta>): AppError {
53  if (err instanceof AppError) return err
54  if (err instanceof Error) return new AppError(err.message, hint, err)
55  if (typeof err === 'string') return new AppError(err, hint, err)
56  return new AppError('未知错误', hint, err)
57}

关键设计:BizError 的 level 设为 warn,表示这是预期内的业务反馈,不需要触发告警;NetworkError 的 level 是 error,需要上报监控。这个区分直接决定了后续报表中"噪音"与"信号"的比例。

2、在 errorHandler 中做真正的"分类处理"

有了标准化错误后,全局处理器不再是一行 console.error,而是真正的分发中枢:

1// src/error/handler.ts
2import type { App } from 'vue'
3import type { Router } from 'vue-router'
4import { normalizeError } from './errors'
5import { dispatchError } from './strategy'
6
7export function installErrorHandler(app: App, router?: Router) {
8  const handle = (err: unknown, ctx: Record<string, unknown> = {}) => {
9    const e = normalizeError(err, ctx as Partial<ErrorMeta>)
10    dispatchError(e)
11  }
12
13  // ① Vue 运行时错误
14  app.config.errorHandler = (err, instance, info) => {
15    handle(err, {
16      source: 'framework',
17      level: 'error',
18      action: 'fallback',
19      component: instance?.$options?.name,
20      context: { vueInfo: info }
21    })
22  }
23
24  // ② 路由懒加载 chunk 失败
25  if (router) {
26    router.onError((err, to) => {
27      const isChunkFail = /loading chunk|failed to fetch dynamically imported module/i.test(err.message)
28      handle(err, {
29        source: isChunkFail ? 'resource' : 'framework',
30        level: 'fatal',
31        context: { route: to.fullPath }
32      })
33    })
34  }
35
36  // ③ 未处理的 Promise rejection
37  window.addEventListener('unhandledrejection', (e) => {
38    handle(e.reason, { source: 'framework', level: 'error' })
39    e.preventDefault() // 阻止控制台重复打印
40  })
41
42  // ④ 资源加载失败 + 全局 JS 异常(捕获阶段)
43  window.addEventListener('error', (e) => {
44    const target = e.target as HTMLElement | null
45    if (target && target !== window && ['IMG', 'SCRIPT', 'LINK'].includes(target.tagName)) {
46      const url = (target as HTMLScriptElement).src || (target as HTMLLinkElement).href
47      handle(new Error(`资源加载失败: ${url}`), { source: 'resource', level: 'error' })
48      return
49    }
50    handle(e.error ?? e.message, { source: 'framework', level: 'fatal' })
51  }, true) // 注意:第三个参数 true 表示在捕获阶段触发
52}

3、补齐 Promise reject 的捕获能力

上面的 unhandledrejection 已经覆盖了全局的 Promise 未捕获 rejection。但业务中更推荐用一层统一的请求 hook,在源头把 Promise 错误转换为标准化错误:

1// src/api/request.ts
2import axios, { type AxiosError } from 'axios'
3import { BizError, AuthError, NetworkError, ValidationError } from '@/error/errors'
4
5const http = axios.create({ baseURL: '/api', timeout: 15_000 })
6
7http.interceptors.response.use(
8  (res) => {
9    const { code, data, message } = res.data
10    if (code === 0) return data
11    // 后端约定的业务错误码 → 转成对应错误类抛出
12    if (code === 401) throw new AuthError()
13    if (code === 400) throw new ValidationError(message)
14    throw new BizError(message, code)
15  },
16  (err: AxiosError) => {
17    if (err.response) throw new NetworkError(`服务异常(${err.response.status})`)
18    if (err.code === 'ECONNABORTED') throw new NetworkError('请求超时')
19    throw new NetworkError()
20  }
21)
22
23export default http

配合一个统一的请求 hook,把重试、静默等策略也收敛进来:

1// src/composables/useRequest.ts
2import { ref } from 'vue'
3import { normalizeError } from '@/error/errors'
4
5export function useRequest<T>(fn: () => Promise<T>, options: { silent?: boolean; retries?: number } = {}) {
6  const data = ref<T | null>(null)
7  const loading = ref(false)
8  const error = ref<ReturnType<typeof normalizeError> | null>(null)
9
10  const run = async () => {
11    loading.value = true; error.value = null
12    try {
13      data.value = await fn()
14      return data.value
15    } catch (e) {
16      const ne = normalizeError(e)
17      if (options.silent) ne.meta.action = 'none'   // 非关键请求静默失败
18      if (ne.meta.retryable && (options.retries ?? 0) > 0) {
19        await new Promise(r => setTimeout(r, 800))
20        return useRequest(fn, { ...options, retries: (options.retries ?? 1) - 1 }).run()
21      }
22      error.value = ne
23      throw ne   // 交给全局策略层
24    } finally { loading.value = false }
25  }
26  return { data, loading, error, run }
27}

4、错误处理的策略化封装

最后一步是把"错误码 → 用户动作"的映射关系集中在一个策略模块中,这是整个体系中唯一允许出现 UI 副作用的地方:

1// src/error/strategy.ts
2import type { AppError } from './errors'
3import { getUserMessage } from './messages'
4import { reporter } from './reporter'
5import router from '@/router'
6import { useMessage } from '@/ui'
7
8type Handler = (err: AppError) => void
9
10const ACTION_MAP: Record<string, Handler> = {
11  none: () => {},
12  toast: (e) => useMessage().warning(getUserMessage(e)),
13  modal: (e) => useMessage().alert({ title: '提示', content: getUserMessage(e) }),
14  redirect: () => { router.replace({ path: '/login', query: { from: location.pathname } }) },
15  retry: (e) => { useMessage().warning(getUserMessage(e)) },
16  fallback: (e) => { reporter.report(e); useMessage().error(getUserMessage(e)) },
17}
18
19export function dispatchError(err: AppError) {
20  // 级别为 error/fatal 的必须上报
21  if (err.meta.level === 'error' || err.meta.level === 'fatal') {
22    reporter.report(err)
23  }
24  const fn = ACTION_MAP[err.meta.action] ?? ACTION_MAP.toast
25  try { fn(err) } catch (handlerErr) {
26    // 策略层自身崩溃绝不能二次污染主流程
27    console.error('[error strategy crashed]', handlerErr)
28  }
29}

文案与错误码的映射也单独抽离:

1// src/error/messages.ts
2const BIZ_MESSAGES: Record<number, string> = {
3  10001: '余额不足',
4  10002: '库存不足,请减少数量',
5  20001: '您没有该操作权限',
6}
7
8export function getUserMessage(err: AppError): string {
9  if (err.meta.source === 'biz' && err.meta.code > 0) {
10    return BIZ_MESSAGES[err.meta.code] || err.message
11  }
12  const map: Record<string, string> = {
13    http: '网络不给力,请稍后重试',
14    resource: '页面部分内容加载失败,请刷新',
15    auth: '登录已失效,正在跳转',
16    framework: '页面出现异常,已自动恢复',
17  }
18  return map[err.meta.source] ?? '操作失败,请稍后重试'
19}

至此整个数据流闭环:

1业务代码抛 BizError/AuthError/NetworkError
2    ↓
3axios 拦截器 / errorHandler / unhandledrejection 统一归一化
4    ↓
5dispatchError 根据 meta.action 分发
6    ↓
7toast / modal / redirect / fallback(UI 反馈)
8    +
9reporter(监控上报)

业务组件最终变成这样:

1<script setup lang="ts">
2import { useRequest } from '@/composables/useRequest'
3import * as api from '@/api'
4
5const { loading, run } = useRequest(() => api.createOrder(payload))
6
7const submit = () => {
8  run().then(() => {
9    // 只关心成功路径
10    message.success('下单成功')
11  })
12  // 不需要 catch,错误已被全局接管
13}
14</script>

四、结语

全局错误处理体系的价值不在于"让程序不崩溃"(这本身就做不到),而在于:

  1. 让错误变得可观测——你知道线上到底在报什么错、影响多少人、集中在哪个模块;
  2. 让业务代码变得干净——组件里不再有 if (e.code === xxx) 的分支森林;
  3. 让修改成本变得可控——改提示文案改 messages.ts,改跳转逻辑改 strategy.ts,加新的错误码只需在 BizError 里传一个数字。

建议落地顺序:先接 errorHandler + unhandledrejection + reporter 拿到监控数据 → 再做错误类归一化 → 最后逐步用 useRequest 替换各模块的 try/catch。整个过程可以平滑演进,不需要一次性大改。

本文被 Vue.js 开发与应用 专题收录

有疑问?试试 AI 助手,答案来自站内真实资料