全局错误处理与消息提示
1. 从"一个接口 500"说起
后端写 Spring 时,异常处理是 @ControllerAdvice 一把梭:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ErrorResponse handle(BusinessException e) { ... }
}
前端稍微复杂,因为错误来源更多:
用户点按钮
↓
[请求发送]
├─ 网络断了 (Network Error)
├─ 后端 500 (HTTP 异常)
├─ 后端 401 (鉴权异常)
├─ 后端 400 + { code: 'INSUFFICIENT_BALANCE' } (业务异常)
↓
[JS 代码执行]
├─ 访问 undefined.xxx (运行时错误)
├─ JSON.parse 解析失败
↓
[Vue 组件渲染]
├─ 模板里 undefined.a.b (组件错误)
一个完整的错误处理体系要照顾到每一层——每层漏一个,用户就会看到"白屏""按钮转圈不停""没反应"之类的惨剧。
2. 四层分工
in4vue 里的错误处理分工:
| 层 | 处理什么 | 表现 |
|---|---|---|
| 1. Axios 拦截器 | HTTP 错误(网络/状态码/业务 code) | ElMessage 弹红色提示 |
| 2. 组件 try-catch | 业务流程关键步骤 | 局部 UI 反馈(按钮 loading 停) |
3. Vue errorHandler |
组件渲染/生命周期抛的错 | 捕获不崩页 + 上报 |
4. window error/unhandledrejection |
全局兜底(第三方 SDK、异步里漏的) | 上报监控 |
分工原则:能在下层捕获的绝不上抛。组件内的 try-catch 能给出精确反馈("该字段错了"),全局兜底只是最后一道保险。
3. 第一层:Axios 拦截器
axios 笔记已经讲过基本封装,这里聚焦错误处理策略。
// src/api/request.ts
import axios, { AxiosError } from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
const service = axios.create({
baseURL: '/api',
timeout: 10000,
})
// 后端统一响应结构
interface ApiResponse<T> {
code: number
message: string
data: T
}
service.interceptors.response.use(
(response) => {
const res = response.data as ApiResponse<unknown>
// 1. HTTP 2xx 但业务码失败
if (res.code !== 0) {
ElMessage.error(res.message || '操作失败')
return Promise.reject(new BizError(res.code, res.message))
}
return res.data
},
(error: AxiosError<ApiResponse<unknown>>) => {
// 2. HTTP 非 2xx
if (!error.response) {
// 网络错误(断网、CORS、超时)
if (error.code === 'ECONNABORTED') {
ElMessage.error('请求超时,请稍后重试')
} else {
ElMessage.error('网络异常,请检查网络连接')
}
return Promise.reject(error)
}
const status = error.response.status
const payload = error.response.data
switch (status) {
case 401: {
const userStore = useUserStore()
userStore.logout()
ElMessage.error('登录已过期,请重新登录')
router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
break
}
case 403:
ElMessage.error('没有访问权限')
break
case 404:
ElMessage.error('资源不存在')
break
case 429:
ElMessage.error('操作过于频繁,请稍后再试')
break
case 500:
case 502:
case 503:
ElMessage.error('服务器异常,请稍后重试')
break
default:
ElMessage.error(payload?.message || `请求失败(${status})`)
}
return Promise.reject(error)
},
)
class BizError extends Error {
constructor(public code: number, message: string) {
super(message)
this.name = 'BizError'
}
}
export { service as request }
几个关键决策:
3.1 业务成功 = code === 0
很多国内后端用 code === 0 表示成功,类似 Linux 的"退出码 0 才成功"。具体值看你公司约定(有的用 200),改一下判断即可。
3.2 业务错误也 reject
业务失败(如"余额不足")是 HTTP 200 + code !== 0——很多前端写成直接吞掉。应该 reject,让调用方能走 catch 分支。否则调用方以为成功了,下一步继续执行就会错。
3.3 消息提示的"节流"
如果一个页面同时发 10 个请求,全 401 会弹 10 个红框。用一个标志位去重:
let showing401 = false
service.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
if (!showing401) {
showing401 = true
ElMessage.error('登录已过期')
setTimeout(() => (showing401 = false), 3000)
}
// 统一登出流程
}
return Promise.reject(error)
})
3.4 "静默"场景
有些接口不该弹错误(比如"检查用户名是否已存在",失败=未占用,业务逻辑本身)。通过自定义 config 让拦截器跳过:
// 声明合并,让 axios 的 config 支持自定义字段
declare module 'axios' {
export interface AxiosRequestConfig {
silent?: boolean
}
}
// 拦截器里
service.interceptors.response.use(
(response) => { /* ... */ },
(error) => {
if (!error.config?.silent) {
ElMessage.error(/* ... */)
}
return Promise.reject(error)
},
)
// 调用时
await request.get('/check-username', { silent: true, params: { name } })
4. 第二层:组件内 try-catch
组件里发请求,即便拦截器弹了提示也要 try-catch——至少为了把 loading 关掉:
const submitting = ref(false)
const onSubmit = async () => {
submitting.value = true
try {
await createNote(form)
ElMessage.success('创建成功')
router.push('/notes')
} catch (err) {
// 拦截器已经 ElMessage 了,这里不用再弹
// 但可以根据业务 code 做特殊处理
if (err instanceof BizError && err.code === 4001) {
// 比如"标题重复",跳到已有的笔记
router.push(`/notes/${err.existingId}`)
}
} finally {
submitting.value = false // 最重要的一行
}
}
记住:finally 是给用户的承诺 —— loading 一定会停,不管成功失败。
4.1 并发请求用 Promise.allSettled
// ❌ 一个失败全挂
const [notes, users, stats] = await Promise.all([
getNotes(),
getUsers(),
getStats(),
])
// ✅ 每个独立处理
const results = await Promise.allSettled([getNotes(), getUsers(), getStats()])
results.forEach((r, i) => {
if (r.status === 'fulfilled') {
// 用 r.value
} else {
console.warn(`第 ${i} 个请求失败`, r.reason)
}
})
Java 对照:类似 CompletableFuture.allOf vs CompletableFuture.anyOf——一个等全部,一个任意一个完成就走。
5. 第三层:Vue 的全局错误钩子
// src/main.ts
import * as Sentry from '@sentry/vue'
app.config.errorHandler = (err, instance, info) => {
// err: 抛出的错误对象
// instance: 出错的组件实例(可能 null)
// info: 错误来源(如 'render function' / 'watcher callback' / 'setup function')
console.error('[Vue Error]', err, info)
// 生产环境上报
if (import.meta.env.PROD) {
Sentry.captureException(err, { extra: { componentInfo: info } })
}
}
// 警告也可以捕获(只开发环境)
app.config.warnHandler = (msg, instance, trace) => {
console.warn('[Vue Warn]', msg, trace)
}
能捕获什么:
- ✅ 模板渲染里的错误(
{{ user.name }}但user是null) - ✅ 生命周期钩子抛的错(
onMounted里同步代码异常) - ✅
watch/computed回调的错 - ❌
<script setup>里的 async 函数默认不进这里,除非你手动 throw 或 await 的 promise reject
Java 对照:类似 Spring 的 HandlerExceptionResolver——统一兜底,避免异常冒泡到容器导致 500 白屏。
6. 第四层:window 全局监听
全局兜底——任何没人 catch 的异常都会冒到这里:
// 同步错误 + 资源加载错误
window.addEventListener('error', (event) => {
console.error('[Global Error]', event.error || event.message)
Sentry.captureException(event.error || new Error(event.message))
})
// Promise 没 catch 的 reject
window.addEventListener('unhandledrejection', (event) => {
console.error('[Unhandled Rejection]', event.reason)
Sentry.captureException(event.reason)
// event.preventDefault() // 阻止控制台的红色报错输出(不建议,开发时要看见)
})
典型场景:
- 第三方 SDK 内部抛的错(你没代码权限去 try-catch)
- 老代码里漏写
.catch的 Promise - JS 语法级错误(
console.log(x.y)但x未定义)
7. ElMessage 的正确姿势
Element Plus 三种提示组件怎么选:
| 组件 | 场景 | 位置 | 是否阻塞 |
|---|---|---|---|
ElMessage |
操作成功/失败的轻量反馈 | 顶部滑入 | ❌ 不阻塞 |
ElNotification |
系统消息、带标题的通知 | 右上角 | ❌ 不阻塞 |
ElMessageBox |
需要用户确认的重要操作 | 居中模态 | ✅ 阻塞 |
7.1 典型用法
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
// 操作反馈
ElMessage.success('保存成功')
ElMessage.error('保存失败')
ElMessage.warning('请选择至少一项')
ElMessage.info('正在处理')
// 系统通知
ElNotification({
title: '新消息',
message: '你有 3 条未读评论',
type: 'success',
duration: 4500,
})
// 危险操作确认
try {
await ElMessageBox.confirm('此操作不可撤销,确定继续?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
// 用户点了"确定"
await deleteAll()
} catch {
// 用户点了"取消"
}
7.2 避免 ElMessage 满屏
同一个错误短时间内多次触发(比如循环里挂了错),会弹出一堆。Element Plus 自带 grouping 选项:
ElMessage({
message: '网络异常',
type: 'error',
grouping: true, // 相同内容的消息合并成一条,显示 x2 / x3
})
或者自己节流(见 3.3 节)。
7.3 loading + 成功提示的模式
const loadingMsg = ElMessage({
message: '处理中...',
type: 'info',
duration: 0, // 不自动关闭
icon: 'Loading',
})
try {
await heavyTask()
loadingMsg.close()
ElMessage.success('完成')
} catch {
loadingMsg.close()
// 拦截器已经报错了
}
8. ElMessageBox 的 confirm / prompt / alert
// 确认
await ElMessageBox.confirm('删除?', '提示', { type: 'warning' })
// 输入
const { value } = await ElMessageBox.prompt('请输入邮箱', '提示', {
inputPattern: /^[\w.-]+@[\w.-]+$/,
inputErrorMessage: '格式不正确',
})
// 仅提示
await ElMessageBox.alert('操作完成', '提示')
Promise 的 resolve/reject 含义:
- 点"确定" → resolve
- 点"取消" / 按 ESC / 点遮罩 → reject
坑点:reject 默认会被控制台记为"未处理的 Promise 拒绝"。用 .catch(() => {}) 吞掉或让 Promise 接着走:
const ok = await ElMessageBox.confirm('删除?').catch(() => false)
if (!ok) return
9. 错误边界:用 onErrorCaptured
想在组件树的某一层兜底(比如只让某一块的崩溃不影响全局):
<!-- src/components/ErrorBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
const error = ref<Error | null>(null)
onErrorCaptured((err) => {
error.value = err
return false // 返回 false 阻止错误继续向上传递
})
const reset = () => (error.value = null)
</script>
<template>
<div v-if="error" class="p-6 border border-red-200 rounded bg-red-50">
<p class="text-red-700 mb-3">此区域加载失败: {{ error.message }}</p>
<el-button size="small" @click="reset">重试</el-button>
</div>
<slot v-else />
</template>
用在可能崩的区域外面:
<ErrorBoundary>
<NoteList /> <!-- 这里崩了只影响这块 -->
</ErrorBoundary>
Java 对照:类似 Spring 的分层 try-catch——外层兜底,内层具体处理。
10. 上报 Sentry:生产环境必备
本地的 console.error 用户看不到,线上 bug 全靠猜。接入 Sentry 几分钟搞定:
pnpm add @sentry/vue
// src/main.ts
import * as Sentry from '@sentry/vue'
if (import.meta.env.PROD) {
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0, // 开启会话回放要付费,个人项目关掉
beforeSend(event) {
// 过滤掉没价值的错误(如浏览器插件注入的)
if (event.exception?.values?.[0]?.value?.includes('ResizeObserver')) {
return null
}
return event
},
})
}
手动上报(业务关键点):
try {
await payOrder(id)
} catch (err) {
Sentry.captureException(err, {
tags: { feature: 'payment' },
extra: { orderId: id, userId: user.id },
})
throw err
}
要点:
tags可以按特性筛选extra附带业务上下文- 生产才开启,开发环境日志在控制台就好
11. 统一错误码 → 文案映射
后端返回 code: 4001,前端每处都写"余额不足"太冗余。集中管理:
// src/utils/error-codes.ts
export const ERROR_MESSAGES: Record<number, string> = {
4001: '余额不足',
4002: '优惠券已使用',
4003: '订单已超时',
// ...
}
export function getErrorMessage(code: number, fallback = '操作失败'): string {
return ERROR_MESSAGES[code] ?? fallback
}
拦截器里:
if (res.code !== 0) {
const msg = getErrorMessage(res.code, res.message)
ElMessage.error(msg)
return Promise.reject(new BizError(res.code, msg))
}
好处:
- 后端改
message不影响前端文案 - 支持 i18n(见下一篇国际化笔记)
- 某些错误码要"特殊处理"(跳转、弹确认)时有统一出口
12. 开发环境友好:不要隐藏错误
生产环境弹个"网络异常"让用户不慌,开发环境得让开发者一眼看到堆栈:
service.interceptors.response.use(undefined, (error) => {
if (import.meta.env.DEV) {
console.group('%c Axios Error', 'color: red; font-weight: bold')
console.log('URL:', error.config?.url)
console.log('Status:', error.response?.status)
console.log('Response:', error.response?.data)
console.log('Error:', error)
console.groupEnd()
}
// 正常处理...
return Promise.reject(error)
})
控制台里分组展示,开发时调试爽。
13. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
| 一个页面同时弹 N 个错误提示 | 并发请求都 401 / 500 | 加防抖标志位 |
catch 里 ElMessage.error('失败') 把拦截器的具体信息吞了 |
重复提示 | 拦截器已弹,catch 里只处理业务,不再弹 |
| 按钮 loading 停不下来 | 忘了 finally |
必须 finally 里 submitting.value = false |
| 组件渲染崩了整页白屏 | 没配 app.config.errorHandler |
配上 + 局部用 ErrorBoundary |
| 线上 bug 无从排查 | 没接 Sentry | 接 Sentry 并带上业务上下文 |
await ElMessageBox.confirm 取消时控制台红字 |
用户点取消 reject 了 | .catch(() => false) 吞掉 |
14. 心智模型
每一次异步操作都要问自己三个问题:
1. 失败了用户看得到吗? → ElMessage / 局部 UI
2. 失败了 loading 会停吗? → finally
3. 失败了有办法查原因吗? → console.error + Sentry
三个问题全答 yes,错误处理就合格了。
小练习
- 给 in4vue 的
request.ts加上完整的错误拦截器(支持 silent 配置) - 加
app.config.errorHandler,制造一个undefined.x的错误看能否捕获 - 写一个
ErrorBoundary组件,在一个可能崩的子组件外面包一层 - 接 Sentry(可以用它的免费档 5k 事件/月)
- 做一个错误码 → 文案映射表,在拦截器里启用