全局错误处理与消息提示

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)
}

能捕获什么

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() // 阻止控制台的红色报错输出(不建议,开发时要看见)
})

典型场景


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 含义

坑点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
}

要点


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))
}

好处


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 必须 finallysubmitting.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,错误处理就合格了。


小练习

  1. 给 in4vue 的 request.ts 加上完整的错误拦截器(支持 silent 配置)
  2. app.config.errorHandler,制造一个 undefined.x 的错误看能否捕获
  3. 写一个 ErrorBoundary 组件,在一个可能崩的子组件外面包一层
  4. 接 Sentry(可以用它的免费档 5k 事件/月)
  5. 做一个错误码 → 文案映射表,在拦截器里启用

延伸阅读