你平时写业务代码时,是不是也经常对着满屏幕的 try...catch 发愁?本来只有两行核心逻辑,为了处理 401 跳转、业务报错提示、Sentry 上报,硬生生堆了十几行错误处理代码。时间一长,不仅组件臃肿不堪,改个全局提示还要翻遍几十个文件。
这篇文章为你梳理了一套拿来即用的 Vue 全局错误处理体系。我们将从 Vue 原生 errorHandler 的边界出发,一步步通过错误归一化、策略分发与统一上报,把“业务逻辑”和“错误处理”彻底解耦。读完这篇,你的代码不仅会更干净,排查线上 Bug 也会变得轻松很多。
实际项目中,大多数组件的错误处理代码是长这样的:
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}
这段代码暴露了三个问题:
.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 调度范围内的异常都不会让页面白屏崩溃。
根据 Vue 3 官方文档,errorHandler 能捕获以下来源的错误:
render / 模板编译后的渲染函数)@click、@submit 等模板事件绑定)created、mounted、watchEffect 等)setup() 函数(同步执行部分)watch、computed 的求值过程)mounted、updated 等)<Transition> 的 enter/leave 回调)以下场景 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 | 在请求封装层统一拦截 |
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 源码中的映射表还原。如果所有错误都是一个裸 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,需要上报监控。这个区分直接决定了后续报表中"噪音"与"信号"的比例。
有了标准化错误后,全局处理器不再是一行 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}
上面的 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}
最后一步是把"错误码 → 用户动作"的映射关系集中在一个策略模块中,这是整个体系中唯一允许出现 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>
全局错误处理体系的价值不在于"让程序不崩溃"(这本身就做不到),而在于:
if (e.code === xxx) 的分支森林;messages.ts,改跳转逻辑改 strategy.ts,加新的错误码只需在 BizError 里传一个数字。建议落地顺序:先接 errorHandler + unhandledrejection + reporter 拿到监控数据 → 再做错误类归一化 → 最后逐步用 useRequest 替换各模块的 try/catch。整个过程可以平滑演进,不需要一次性大改。
本文被 Vue.js 开发与应用 专题收录
你好,我是宇扬科技工作室的 AI 助手。
关于服务项目、技术方案、开发流程与合作方式,都可以问我。回答只依据站内已发布的资料,并会附上出处。
内容由 AI 依据站内资料生成,仅供参考 · 人工咨询 13712947744