CC 咖啡猫的工作空间 Coding Space

前端异常处理与错误边界

前端异常处理是保障用户体感和应用稳定性的最后一道防线。合理的错误捕获、优雅的降级兜底、精准的错误上报,构成了前端应用的"免疫系统"。


1. 前端异常分类

1.1 异常类型概览

异常类型 触发场景 能否被 ErrorBoundary 捕获 是否影响后续执行
JS 运行时错误 TypeErrorReferenceErrorSyntaxError 能(渲染阶段)
异步错误 Promise rejection、async/await 异常 否(不触发 rejection 则静默失败)
资源加载错误 图片/脚本/样式 404、跨域错误 局部
接口请求错误 网络超时、HTTP 4xx/5xx
渲染错误 组件 render 过程中异常
跨域脚本错误 加载第三方 CDN 脚本异常 limited

1.2 JS 运行时错误

// ✅ 常见运行时错误
const obj = null;
obj.name;                // TypeError: Cannot read properties of null

console.log(undeclared); // ReferenceError: undeclared is not defined

const json = '{"bad": }';
JSON.parse(json);        // SyntaxError: Unexpected token

1.3 异步错误

// Promise rejection
fetch('/api/data').catch(err => {
  console.error('请求失败:', err);
});

// async/await 需要 try-catch 包裹
async function fetchData() {
  try {
    const res = await api.get('/users');
    return res.data;
  } catch (err) {
    // 未捕获的 async 异常会变为 unhandledrejection
    throw new Error('请求用户数据失败');
  }
}

关键认知:异步错误不会自动冒泡到 window.onerror,必须通过 unhandledrejection 事件或显式的 .catch() 捕获。

1.4 资源加载错误

// 图片加载失败
const img = new Image();
img.onerror = () => {
  img.src = '/assets/placeholder.png'; // 降级为占位图
};
img.src = '/assets/banner.png';

// 脚本加载异常
const script = document.createElement('script');
script.onerror = () => {
  console.error('脚本加载失败:', script.src);
};
script.src = 'https://cdn.example.com/widget.js';
document.body.appendChild(script);

1.5 接口请求错误

// ✅ 按状态码分类处理
interface ApiError {
  status: number;
  message: string;
  timestamp: number;
  traceId?: string;
}

function handleHttpError(error: ApiError): void {
  switch (error.status) {
    case 401:
      // 未授权 → 跳转登录
      redirectToLogin();
      break;
    case 403:
      // 无权限 → 提示
      message.warning('没有操作权限,请联系管理员');
      break;
    case 404:
      // 资源不存在
      message.error('请求的资源不存在');
      break;
    case 500:
    case 502:
    case 503:
      // 服务器异常
      message.error('服务暂时不可用,请稍后重试');
      break;
    default:
      message.error(`请求异常 (${error.status})`);
  }
}

2. 全局错误捕获

2.1 捕获机制对比

捕获方式 适用场景 能否获取堆栈 能否阻止默认行为
window.onerror JS 运行时错误 能(跨域受限) return true 阻止
window.addEventListener('error') 资源加载错误 + JS 错误 preventDefault()
window.addEventListener('unhandledrejection') 未被 catch 的 Promise 异常 可阻止
app.config.errorHandler (Vue) Vue 组件内异常 通过返回值控制
ErrorBoundary (React) React 渲染阶段异常 通过 fallback UI

2.2 window.onerror vs window.addEventListener('error')

核心区别onerror 能捕获 JS 运行时错误但无法捕获资源加载错误;addEventListener('error') 两者都能捕获,且支持多个监听器。

// ✅ 推荐做法:两者配合使用

// 1. 捕获 JS 运行时错误(包含语法错误、未定义变量等)
window.onerror = function (
  message: string,
  source: string | undefined,
  lineno: number | undefined,
  colno: number | undefined,
  error: Error | undefined
): boolean {
  console.error('[GlobalError]', { message, source, lineno, colno, error });
  // 上报错误
  reportError({
    type: 'js_runtime',
    message,
    stack: error?.stack,
    source,
    lineno,
    colno,
  });
  // return true 表示错误已处理,阻止浏览器默认行为
  return true;
};

// 2. 捕获资源加载错误(图片、脚本、样式表加载失败)
window.addEventListener(
  'error',
  (event: ErrorEvent | Event) => {
    // 区分 JS 错误和资源加载错误
    if (event instanceof ErrorEvent) {
      // JS 运行时错误,onerror 已处理,这里跳过避免重复
      return;
    }

    const target = event.target as HTMLElement;
    console.error('[ResourceError]', {
      tagName: target.tagName,
      src: (target as HTMLImageElement | HTMLScriptElement).src,
      href: (target as HTMLLinkElement).href,
    });

    reportError({
      type: 'resource_load',
      tagName: target.tagName,
      url: (target as HTMLImageElement | HTMLScriptElement).src
        || (target as HTMLLinkElement).href,
    });
  },
  true // 使用捕获阶段,确保能捕获到资源错误
);
// ❌ 错误做法:只使用 onerror,会遗漏资源加载错误
window.onerror = (msg, url, line, col, err) => {
  // 只能捕获 JS 运行时错误
  // 图片 404、脚本加载失败都不会触发
};

2.3 未处理 Promise rejection

// ✅ 必须捕获 unhandledrejection
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
  console.error('[UnhandledRejection]', event.reason);

  // 阻止默认行为(在控制台打印警告)
  event.preventDefault();

  reportError({
    type: 'unhandled_promise_rejection',
    message: event.reason?.message ?? String(event.reason),
    stack: event.reason?.stack,
  });
});

注意事项

  • unhandledrejection 只在 Promise 被 reject 且没有 .catch() 时触发
  • 如果 Promise 在 reject 后才被 catch(通过 .catch()await),会触发 rejectionhandled 事件
  • 某些第三方库(如老的 axios 版本)可能会产生误报,需要过滤白名单

2.4 Vue 全局异常处理

<!-- Vue 3 + TypeScript:main.ts -->
<script setup lang="ts">
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)

// 全局错误处理器
app.config.errorHandler = (err: unknown, instance: ComponentPublicInstance | null, info: string) => {
  console.error('[VueError]', {
    error: err,
    component: instance?.$options?.name ?? 'unknown',
    info, // 如 'render function', 'setup function', 'event handler' 等
    route: instance?.$route?.fullPath,
  })

  reportError({
    type: 'vue_component_error',
    message: (err as Error).message,
    stack: (err as Error).stack,
    componentName: instance?.$options?.name ?? 'unknown',
    hookInfo: info,
  })
}

// 警告信息处理(可选)
app.config.warnHandler = (msg: string, instance: ComponentPublicInstance | null, trace: string) => {
  console.warn('[VueWarn]', { msg, trace })
}

// 注册路由
const router = createRouter({
  history: createWebHistory(),
  routes: [/* ... */],
})

// 注册状态管理
const pinia = createPinia()

app.use(router)
app.use(pinia)
app.use(ElementPlus)
app.mount('#app')
</script>

2.5 React 全局异常处理

// React + TypeScript:src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'

// React 16+ 没有内置的全局 error handler
// 通过 window 级别的捕获作为兜底
window.onerror = (message, source, lineno, colno, error) => {
  console.error('[ReactGlobalError]', { message, source, lineno, colno, error })
  reportError({
    type: 'react_runtime_error',
    message: String(message),
    stack: error?.stack,
  })
  return true
}

window.addEventListener('unhandledrejection', (event) => {
  console.error('[ReactUnhandledRejection]', event.reason)
  event.preventDefault()
  reportError({
    type: 'react_unhandled_rejection',
    message: event.reason?.message ?? String(event.reason),
    stack: event.reason?.stack,
  })
})

const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
)

3. 错误边界(Error Boundary)

3.1 原理说明

错误边界是一种声明式的异常捕获机制,用于捕获组件渲染阶段(render、生命周期函数、构造函数)抛出的异常,防止整个应用崩溃,并展示降级 UI。

框架 实现方式 捕获范围 限制
React Class 组件 + componentDidCatch / getDerivedStateFromError render、生命周期、构造函数 不支持 Hooks 实现
Vue 3 onErrorCaptured 钩子 + app.config.errorHandler 子组件 render、watcher、生命周期 需配合 errorHandler 做全局兜底

3.2 错误边界不能捕获什么

场景 原因 解决方案
事件处理中的错误 事件回调在微任务中执行,不在渲染流程内 事件处理内部用 try-catch
异步代码(setTimeout/Promise) 异步任务脱离组件渲染上下文 全局 unhandledrejection 捕获
服务端渲染(SSR) 错误边界仅在客户端生效 SSR 端使用 try-catch
错误边界自身抛出的错误 递归循环 错误边界本身应尽量简单,或嵌套多层 ErrorBoundary
React.lazy 加载失败 模块加载网络异常 配合 Suspense fallback

3.3 React ErrorBoundary 实现

// React + TypeScript:ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react'

interface ErrorBoundaryProps {
  children: ReactNode
  /** 降级 UI */
  fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
  /** 自定义错误回调 */
  onError?: (error: Error, errorInfo: ErrorInfo) => void
  /** 重置错误边界 key,可用于主动重置 */
  resetKey?: string | number
}

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  // 更新 state 触发降级 UI
  static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
    return { hasError: true, error }
  }

  // 记录错误信息
  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error('[ErrorBoundary]', error, errorInfo.componentStack)

    // 上报错误
    this.props.onError?.(error, errorInfo)
    reportError({
      type: 'react_error_boundary',
      message: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack,
    })
  }

  // 当 resetKey 变化时重置状态
  componentDidUpdate(prevProps: ErrorBoundaryProps): void {
    if (this.props.resetKey !== prevProps.resetKey && this.state.hasError) {
      this.resetErrorBoundary()
    }
  }

  resetErrorBoundary = (): void => {
    this.setState({ hasError: false, error: null })
  }

  render(): ReactNode {
    if (this.state.hasError) {
      if (typeof this.props.fallback === 'function') {
        return this.props.fallback(this.state.error!, this.resetErrorBoundary)
      }
      return this.props.fallback ?? (
        <div className="error-boundary-fallback">
          <h2>页面出现异常</h2>
          <p>请刷新页面或稍后重试</p>
          <button onClick={this.resetErrorBoundary}>重试</button>
        </div>
      )
    }

    return this.props.children
  }
}

export default ErrorBoundary

使用示例

// ✅ 使用 ErrorBoundary 包裹可能存在异常的组件

// 页面级别:一个错误页面不影响整体导航
<ErrorBoundary
  fallback={<FullPageError />}
  onError={(err, info) => reportError(err, info)}
>
  <UserProfile userId={id} />
</ErrorBoundary>

// 模块级别:某个模块崩溃不影响其他模块
<ErrorBoundary fallback={<ModuleError />}>
  <CommentsSection />
</ErrorBoundary>

// 静态 fallback(最简形式)
<ErrorBoundary fallback={<div>组件加载异常</div>}>
  <LazyLoadedComponent />
</ErrorBoundary>

// React.lazy 配合 Suspense
<ErrorBoundary fallback={<div>模块加载失败</div>}>
  <Suspense fallback={<Spin />}>
    <LazyComponent />
  </Suspense>
</ErrorBoundary>

3.4 Vue 3 错误边界实现

Vue 3 没有内置的 ErrorBoundary 组件,需要通过 onErrorCaptured 和动态组件实现。

<!-- Vue 3 + TypeScript:ErrorBoundary.vue -->
<script setup lang="ts">
import {
  ref,
  provide,
  onErrorCaptured,
  type ComponentPublicInstance,
  type Slot,
} from 'vue'

interface Props {
  /** 降级 UI 插槽名称,默认 'fallback' */
  fallback?: 'fallback'
  /** 自定义错误处理 */
  onError?: (error: Error, instance: ComponentPublicInstance | null, info: string) => void
}

const props = withDefaults(defineProps<Props>(), {
  onError: undefined,
})

const hasError = ref(false)
const error = ref<Error | null>(null)

// 捕获子组件所有错误
onErrorCaptured((err: Error, instance: ComponentPublicInstance | null, info: string) => {
  console.error('[VueErrorBoundary]', {
    error: err,
    componentName: instance?.$options?.name,
    info,
  })

  error.value = err
  hasError.value = true

  // 调用外部错误处理回调
  props.onError?.(err, instance, info)

  // 上报错误
  reportError({
    type: 'vue_error_boundary',
    message: err.message,
    stack: err.stack,
    componentName: instance?.$options?.name ?? 'unknown',
    info,
  })

  // 返回 false → 阻止错误继续冒泡到父组件或全局 errorHandler
  // 返回 true → 允许继续传播
  return false
})

function resetError(): void {
  hasError.value = false
  error.value = null
}

defineExpose({ resetError })
</script>

<template>
  <!-- 有错误时渲染 fallback 插槽 -->
  <div v-if="hasError" class="error-boundary-fallback">
    <slot
      name="fallback"
      :error="error"
      :reset="resetError"
    >
      <div class="default-fallback">
        <el-result
          status="error"
          title="页面出现异常"
          sub-title="请刷新页面或稍后重试"
        >
          <template #extra>
            <el-button type="primary" @click="resetError">
              重试
            </el-button>
          </template>
        </el-result>
      </div>
    </slot>
  </div>

  <!-- 正常渲染子组件 -->
  <slot v-else />
</template>

<style scoped>
.error-boundary-fallback {
  padding: 24px;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 200px;
}
</style>

使用示例

<!-- Vue 3 + Element Plus:使用 ErrorBoundary -->
<script setup lang="ts">
import { ref } from 'vue'
import ErrorBoundary from '@/components/ErrorBoundary.vue'
import UserProfile from '@/views/UserProfile.vue'
import CommentsSection from '@/views/CommentsSection.vue'

const errorBoundaryKey = ref(0)

function handleBoundaryError(err: Error) {
  console.error('边界捕获到异常:', err.message)
  reportError(err)
}

function refreshAll() {
  errorBoundaryKey.value++
}
</script>

<template>
  <!-- 模块级别错误边界 -->
  <ErrorBoundary
    :on-error="handleBoundaryError"
  >
    <UserProfile :user-id="123" />
  </ErrorBoundary>

  <!-- 自定义降级 UI -->
  <ErrorBoundary :on-error="handleBoundaryError">
    <CommentsSection />

    <template #fallback="{ error, reset }">
      <el-alert
        title="评论区加载异常"
        :description="error?.message"
        type="error"
        show-icon
        closable
      >
        <template #footer>
          <el-button size="small" @click="reset">
            重试
          </el-button>
        </template>
      </el-alert>
    </template>
  </ErrorBoundary>

  <!-- 通过 key 重置所有错误边界 -->
  <ErrorBoundary :key="errorBoundaryKey">
    <DashboardView />
  </ErrorBoundary>
</template>

3.5 错误边界的嵌套与分层策略

应用根节点
  └─ 全局 ErrorBoundary(兜底,show 整个页面崩溃页)
       ├─ Layout 组件
       │    ├─ 顶部导航 ErrorBoundary(导航失败不影响内容)
       │    └─ 侧边栏 ErrorBoundary
       ├─ 路由出口 ErrorBoundary(路由切换异常)
       │    ├─ 页面级 ErrorBoundary
       │    │    ├─ 数据展示模块 ErrorBoundary
       │    │    └─ 表单模块 ErrorBoundary(表单异常不影响展示)
       │    └─ 弹窗/抽屉 ErrorBoundary
       └─ 全局浮层 ErrorBoundary
// ✅ 推荐:分层 ErrorBoundary
function App() {
  return (
    <ErrorBoundary fallback={<FullPageCrash />}>
      <Layout>
        <Header>
          <ErrorBoundary fallback={<HeaderFallback />}>
            <Navigation />
          </ErrorBoundary>
        </Header>
        <Main>
          <ErrorBoundary
            fallback={<PageError />}
            onError={(err) => reportError(err)}
          >
            <Outlet /> {/* React Router 路由出口 */}
          </ErrorBoundary>
        </Main>
      </Layout>
    </ErrorBoundary>
  )
}

4. 接口错误处理

4.1 Axios 拦截器统一处理

// src/utils/http.ts
import axios, {
  AxiosInstance,
  AxiosError,
  InternalAxiosRequestConfig,
  AxiosResponse,
} from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'

// 错误码枚举
enum HttpStatus {
  UNAUTHORIZED = 401,
  FORBIDDEN = 403,
  NOT_FOUND = 404,
  TIMEOUT = 408,
  TOO_MANY_REQUESTS = 429,
  SERVER_ERROR = 500,
  BAD_GATEWAY = 502,
  SERVICE_UNAVAILABLE = 503,
}

// 业务错误码映射
const BUSINESS_ERROR_MAP: Record<string | number, string> = {
  1001: '参数错误',
  1002: '数据不存在',
  2001: '用户不存在',
  2002: '账号已被禁用',
  9001: '系统繁忙,请稍后重试',
}

// 创建实例
const http: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL as string,
  timeout: 15000, // 15 秒超时
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json',
  },
})

// 请求拦截器
http.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // 1. 添加 Token
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }

    // 2. 添加请求时间戳(防缓存)
    if (config.method === 'get') {
      config.params = { ...config.params, _t: Date.now() }
    }

    // 3. 请求重试标识
    config.headers['X-Retry-Count'] = '0'

    return config
  },
  (error: AxiosError) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
http.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, message, data } = response.data

    // 业务状态码判断
    if (code === 0 || code === 200) {
      return data // 直接返回业务数据
    }

    // 业务异常
    const errorMsg = BUSINESS_ERROR_MAP[code] || message || '未知业务错误'
    ElMessage.error(errorMsg)

    return Promise.reject(new Error(errorMsg))
  },
  (error: AxiosError) => {
    if (!error.response) {
      // 网络错误(无响应)
      handleNetworkError(error)
      return Promise.reject(error)
    }

    handleHttpStatusError(error.response.status, error.config)
    return Promise.reject(error)
  }
)

// 网络错误处理
function handleNetworkError(error: AxiosError): void {
  if (error.code === 'ECONNABORTED') {
    // 超时
    ElMessage.error('请求超时,请检查网络后重试')
  } else if (!navigator.onLine) {
    // 离线
    ElMessage.warning('网络已断开,请检查网络连接')
  } else {
    ElMessage.error('网络异常,无法连接服务器')
  }
}

// HTTP 状态码处理
function handleHttpStatusError(status: number, config?: InternalAxiosRequestConfig): void {
  const userStore = useUserStore()

  switch (status) {
    case HttpStatus.UNAUTHORIZED: // 401 → 登录过期
      ElMessage.error('登录已过期,请重新登录')
      userStore.clearToken()
      router.push('/login')
      break

    case HttpStatus.FORBIDDEN: // 403 → 无权限
      ElMessage.warning('没有操作权限,请联系管理员')
      break

    case HttpStatus.NOT_FOUND: // 404
      ElMessage.error('请求的资源不存在')
      break

    case HttpStatus.TOO_MANY_REQUESTS: // 429 → 限流
      ElMessage.warning('请求过于频繁,请稍后重试')
      break

    case HttpStatus.SERVER_ERROR: // 500
    case HttpStatus.BAD_GATEWAY: // 502
    case HttpStatus.SERVICE_UNAVAILABLE: // 503
      ElMessage.error('服务暂时不可用,请稍后重试')
      break

    default:
      ElMessage.error(`请求异常 (${status})`)
  }
}

export default http

4.2 React + Ant Design 版本

// src/utils/http.ts
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import { message } from 'antd'
import { useUserStore } from '@/stores/user'
import { history } from '@/utils/history' // 路由 history 对象

const http: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL as string,
  timeout: 15000,
  withCredentials: true,
})

// 请求拦截器
http.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器
http.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, message: msg, data } = response.data
    if (code === 0 || code === 200) return data

    message.error(msg || '请求失败')
    return Promise.reject(new Error(msg))
  },
  (error: AxiosError) => {
    if (!error.response) {
      if (error.code === 'ECONNABORTED') {
        message.error('请求超时')
      } else {
        message.error('网络异常')
      }
      return Promise.reject(error)
    }

    const { status } = error.response
    switch (status) {
      case 401:
        message.error('登录已过期')
        useUserStore.getState().clearToken()
        history.push('/login')
        break
      case 403:
        message.warning('无操作权限')
        break
      case 500:
      case 502:
      case 503:
        message.error('服务暂时不可用')
        break
      default:
        message.error(`请求异常 (${status})`)
    }

    return Promise.reject(error)
  }
)

export default http

4.3 错误重试策略(指数退避)

// ✅ 指数退避重试
interface RetryConfig {
  maxRetries: number    // 最大重试次数
  baseDelay: number     // 初始延迟(ms)
  maxDelay: number      // 最大延迟(ms)
  retryableStatuses: number[] // 可重试的 HTTP 状态码
}

const DEFAULT_RETRY_CONFIG: RetryConfig = {
  maxRetries: 3,
  baseDelay: 1000,
  maxDelay: 10000,
  retryableStatuses: [408, 429, 500, 502, 503, 504],
}

async function fetchWithRetry<T>(
  requestFn: () => Promise<T>,
  config: Partial<RetryConfig> = {}
): Promise<T> {
  const { maxRetries, baseDelay, maxDelay, retryableStatuses } = {
    ...DEFAULT_RETRY_CONFIG,
    ...config,
  }

  let lastError: Error | null = null

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await requestFn()
    } catch (err) {
      lastError = err as Error

      // 非可重试错误,直接抛出
      const axiosError = err as AxiosError
      if (axiosError.response && !retryableStatuses.includes(axiosError.response.status)) {
        throw err
      }

      // 最后一次尝试失败,不再重试
      if (attempt >= maxRetries) {
        break
      }

      // 计算退避延迟:baseDelay * 2^attempt + 随机抖动
      const delay = Math.min(
        baseDelay * Math.pow(2, attempt) + Math.random() * 500,
        maxDelay
      )

      console.warn(
        `[Retry] 第 ${attempt + 1} 次重试,延迟 ${delay}ms`,
        axiosError.message
      )

      await new Promise((resolve) => setTimeout(resolve, delay))
    }
  }

  throw lastError
}

// 使用示例
async function fetchUserProfile(userId: number) {
  return fetchWithRetry(
    () => http.get(`/users/${userId}`),
    { maxRetries: 2, retryableStatuses: [500, 502, 503] }
  )
}

4.4 Toast 提示 vs 页面级错误展示

场景 推荐方式 理由
表单提交失败 Toast(ElMessage / message) 不需要跳转,提示即可
列表数据加载失败 页面内嵌错误状态 保留导航状态,用户可手动重试
全页面数据加载失败 页面级错误页 主要功能不可用,展示富错误信息
静默刷新(定时拉取) 不提示(日志上报) 用户无感知,后台自动重试
操作按钮触发的接口 Toast 操作反馈,位置明确
// ✅ 页面级错误展示(Vue 3)
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import http from '@/utils/http'

interface User {
  id: number
  name: string
  email: string
}

const loading = ref(true)
const error = ref<{ message: string; retry: () => void } | null>(null)
const users = ref<User[]>([])

async function fetchUsers(): Promise<void> {
  loading.value = true
  error.value = null

  try {
    users.value = await http.get('/users')
  } catch (err) {
    const message = (err as Error).message || '加载用户列表失败'

    // 页面级错误展示(允许重试)
    error.value = {
      message,
      retry: fetchUsers,
    }
  } finally {
    loading.value = false
  }
}

onMounted(fetchUsers)
</script>

<template>
  <!-- 加载状态 -->
  <div v-if="loading" class="loading-container">
    <el-skeleton :rows="5" animated />
  </div>

  <!-- 错误状态 -->
  <div v-else-if="error" class="error-container">
    <el-result
      status="error"
      title="加载失败"
      :sub-title="error.message"
    >
      <template #extra>
        <el-button type="primary" @click="error!.retry()">
          重新加载
        </el-button>
      </template>
    </el-result>
  </div>

  <!-- 数据展示 -->
  <div v-else class="user-list">
    <el-table :data="users" border>
      <el-table-column prop="id" label="ID" width="80" />
      <el-table-column prop="name" label="名称" />
      <el-table-column prop="email" label="邮箱" />
    </el-table>
  </div>
</template>

5. 防御式编程在前端

5.1 永远不要信任后端返回的数据

// ❌ 错误:假设后端一定返回期望的数据结构
interface ApiResponse {
  data: {
    user: {
      name: string
      settings: {
        theme: string
        notifications: boolean
      }
    }
  }
}

function renderUserName(response: ApiResponse): string {
  // 如果后端返回 { code: 500, message: 'error' },这里会直接崩溃
  return response.data.user.name.toUpperCase()
}
// ✅ 正确:逐层空值检查 + 默认值兜底
function renderUserNameSafe(response: unknown): string {
  const data = (response as ApiResponse)?.data
  const userName = data?.user?.name
  return userName?.toUpperCase() ?? '匿名用户'
}

// 或者使用可选链(Optional Chaining)
function renderUserNameSafely(response: ApiResponse | null): string {
  return response?.data?.user?.name?.toUpperCase() ?? '匿名用户'
}

5.2 类型守卫(Type Guards)

// ✅ 使用类型守卫确保数据结构完整
interface UserProfile {
  id: number
  name: string
  email: string
  avatar?: string
  role: 'admin' | 'user' | 'guest'
}

// 类型守卫函数
function isUserProfile(data: unknown): data is UserProfile {
  if (!data || typeof data !== 'object') return false

  const obj = data as Record<string, unknown>

  return (
    typeof obj.id === 'number' &&
    typeof obj.name === 'string' &&
    typeof obj.email === 'string' &&
    (obj.avatar === undefined || typeof obj.avatar === 'string') &&
    ['admin', 'user', 'guest'].includes(obj.role as string)
  )
}

// 使用类型守卫
function processUserData(data: unknown): UserProfile | null {
  if (!isUserProfile(data)) {
    console.warn('[DataGuard] 用户数据格式异常:', data)
    return null
  }

  // 此处 data 已被推断为 UserProfile 类型
  return data
}

5.3 空值合并与默认值兜底

// ✅ 使用空值合并运算符(??)兜底
interface PageConfig {
  pageSize: number
  currentPage: number
  total: number
  filters?: Record<string, string>
  sortField?: string
  sortOrder?: 'asc' | 'desc'
}

// ❌ 危险:|| 会过滤 falsy 值(0、''、false 等)
const pageSize1 = config.pageSize || 20 // 如果 pageSize 是 0,也会变成 20

// ✅ 正确:?? 只过滤 null/undefined
const pageSize2 = config.pageSize ?? 20

// ✅ 深层空值合并
const sortOrder = config?.sortOrder ?? 'desc'
const firstFilter = config?.filters?.keyword ?? ''

// ✅ 配合解构赋值
const {
  pageSize = 20,
  currentPage: page = 1,
  filters = {},
  sortOrder = 'desc',
} = config

5.4 防御式网络请求

// ✅ 防御式请求封装
async function safeRequest<T>(
  url: string,
  options?: {
    fallback?: T
    timeout?: number
    validate?: (data: unknown) => data is T
  }
): Promise<T | null> {
  const { fallback = null, timeout = 10000, validate } = options ?? {}

  try {
    const response = await http.get(url, { timeout })

    // 校验返回数据结构
    if (validate && !validate(response)) {
      console.warn(`[SafeRequest] ${url} 返回数据格式异常`)
      return fallback
    }

    return response as T
  } catch (err) {
    console.error(`[SafeRequest] ${url} 请求失败:`, err)
    return fallback
  }
}

// 使用示例
interface Product {
  id: number
  title: string
  price: number
}

const product = await safeRequest<Product[]>(
  '/api/products',
  {
    fallback: [],
    timeout: 5000,
    validate: (data): data is Product[] =>
      Array.isArray(data) && data.every(
        (item) => typeof item.id === 'number' && typeof item.title === 'string'
      ),
  }
)
// product 一定是 Product[](失败则返回空数组)

5.5 深度合并默认配置

// ✅ 深度合并后端配置与本地默认值
interface AppConfig {
  theme: {
    primary: string
    background: string
    font: string
  }
  features: {
    export: boolean
    import: boolean
    batch: boolean
  }
  pagination: {
    pageSize: number
    maxPageSize: number
  }
}

const DEFAULT_CONFIG: AppConfig = {
  theme: {
    primary: '#1890ff',
    background: '#ffffff',
    font: '#333333',
  },
  features: {
    export: true,
    import: false,
    batch: false,
  },
  pagination: {
    pageSize: 20,
    maxPageSize: 100,
  },
}

function mergeConfig(remote?: Partial<AppConfig>): AppConfig {
  // 深度合并:remote 配置覆盖 DEFAULT_CONFIG 的同级字段
  return {
    theme: { ...DEFAULT_CONFIG.theme, ...remote?.theme },
    features: { ...DEFAULT_CONFIG.features, ...remote?.features },
    pagination: { ...DEFAULT_CONFIG.pagination, ...remote?.pagination },
  }
}

6. 错误上报

6.1 上报数据结构

// 统一的错误上报格式
interface ErrorReport {
  /** 错误类型 */
  type:
    | 'js_runtime'
    | 'unhandled_promise_rejection'
    | 'resource_load'
    | 'vue_component_error'
    | 'react_error_boundary'
    | 'api_error'
    | 'business_error'
    | 'custom_error'

  /** 错误消息 */
  message: string

  /** 错误堆栈 */
  stack?: string

  /** 组件堆栈(Vue/React) */
  componentStack?: string

  /** 发生时间 */
  timestamp: number

  /** 用户标识 */
  userId?: string

  /** 页面路径 */
  url: string

  /** 浏览器信息 */
  browser: {
    userAgent: string
    language: string
    platform: string
  }

  /** 附加信息 */
  extra?: Record<string, unknown>
}

// ✅ 上报函数抽象
function reportError(report: Partial<ErrorReport>): void {
  const payload: ErrorReport = {
    type: report.type ?? 'custom_error',
    message: report.message ?? '未知错误',
    timestamp: Date.now(),
    url: window.location.href,
    browser: {
      userAgent: navigator.userAgent,
      language: navigator.language,
      platform: navigator.platform,
    },
    stack: report.stack,
    componentStack: report.componentStack,
    userId: getUserIdFromStore(),
    extra: report.extra,
  }

  // 生产环境才上报
  if (import.meta.env.PROD) {
    // 使用 Beacon API 确保上报不丢失
    const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' })
    navigator.sendBeacon('/api/log/error', blob)

    // 或批量缓存后上报
    errorQueue.push(payload)
    if (errorQueue.length >= ERROR_BATCH_SIZE) {
      flushErrorQueue()
    }
  } else {
    // 开发环境打印到控制台
    console.group('[ErrorReport]')
    console.error(payload)
    console.groupEnd()
  }
}

6.2 用户行为回放数据采集

// ✅ 采集用户操作轨迹(用于复现问题)
interface UserAction {
  type: 'click' | 'input' | 'navigation' | 'api_request'
  timestamp: number
  detail: string
  url: string
}

class UserActionCollector {
  private actions: UserAction[] = []
  private readonly MAX_ACTIONS = 200 // 最多保留最近 200 条

  start(): void {
    // 点击事件采集
    document.addEventListener('click', (event: MouseEvent) => {
      const target = event.target as HTMLElement
      this.push({
        type: 'click',
        timestamp: Date.now(),
        detail: target.tagName + (target.textContent ? `: ${target.textContent.slice(0, 50)}` : ''),
        url: window.location.href,
      })
    })

    // 路由变化采集
    window.addEventListener('popstate', () => {
      this.push({
        type: 'navigation',
        timestamp: Date.now(),
        detail: window.location.href,
        url: window.location.href,
      })
    })
  }

  push(action: UserAction): void {
    this.actions.push(action)
    if (this.actions.length > this.MAX_ACTIONS) {
      this.actions.shift()
    }
  }

  getActions(): UserAction[] {
    return [...this.actions]
  }

  clear(): void {
    this.actions = []
  }
}

export const actionCollector = new UserActionCollector()

6.3 Source Map 安全处理

// 错误堆栈中的源码映射
// 生产环境需要满足:可定位问题 + 不暴露源码

// ✅ 推荐做法:
// 1. 上传 Source Map 到 Sentry / 自研服务(不部署到 CDN)
// 2. 生产环境 .map 文件仅在白名单 IP 或内部网络可访问
// 3. 前端只收集行列号,服务端映射

// 构建配置示例(Vite)
// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    // 生产环境生成 Source Map,但不上传
    sourcemap: true, // 或 'hidden':生成但不添加 //# sourceMappingURL 注释

    // 或使用插件安全上传
    // rollupOptions: { ... }
  },
})
方案 Source Map 暴露风险 可排查性 推荐度
不上传 Source Map 无风险 差(只有混淆后的堆栈)
上传到 Sentry / 自研 低(仅服务端可访问) 好(自动还原) ✅ 推荐
部署到 CDN 公开访问 高(源码完全暴露)
sourcemap: 'hidden' 中(无法通过浏览器 DevTools 下载) 中等 可选
私有 npm + 自研还原工具 低(内部工具解密) 好(可控) 大厂方案

6.4 Sentry 集成示例

// src/sentry.ts
import * as Sentry from '@sentry/vue' // 或 @sentry/react
import { createApp } from 'vue'

function initSentry(app: ReturnType<typeof createApp>): void {
  if (import.meta.env.PROD) {
    Sentry.init({
      app,
      dsn: import.meta.env.VITE_SENTRY_DSN as string,
      environment: import.meta.env.MODE,
      release: `my-app@${__APP_VERSION__}`,

      // 采样率:错误 100%,性能 20%
      tracesSampleRate: 0.2,
      replaysSessionSampleRate: 0.1,
      replaysOnErrorSampleRate: 1.0,

      // 集成用户行为回放
      integrations: [
        Sentry.replayIntegration(),
        Sentry.browserTracingIntegration(),
      ],

      // 数据脱敏
      beforeSend(event) {
        // 移除敏感信息
        if (event.request?.headers) {
          delete event.request.headers['Authorization']
          delete event.request.headers['Cookie']
        }
        // 过滤已知的第三方脚本错误
        if (event.exception?.values?.[0]?.type === 'Script error') {
          return null // 丢弃跨域脚本错误
        }
        return event
      },
    })
  }
}

7. 优雅降级与兜底

7.1 组件加载失败降级

// React:动态导入加载失败处理
import React, { Suspense, lazy, ComponentType } from 'react'
import { Spin } from 'antd'

// 安全动态导入包装器
function safeLazy<T extends ComponentType<any>>(
  importFn: () => Promise<{ default: T }>,
  fallback?: ComponentType
): React.LazyExoticComponent<T> {
  return lazy(async () => {
    try {
      return await importFn()
    } catch (err) {
      console.error('[SafeLazy] 模块加载失败:', err)
      reportError({
        type: 'resource_load',
        message: `模块加载失败: ${importFn.toString().slice(0, 100)}`,
      })

      if (fallback) {
        return { default: fallback as unknown as T }
      }

      // 返回一个简单的降级组件
      const FallbackComponent: ComponentType = () => (
        <div className="lazy-fallback">
          <h3>模块加载失败</h3>
          <button onClick={() => window.location.reload()}>刷新页面</button>
        </div>
      )
      return { default: FallbackComponent as unknown as T }
    }
  })
}

// 使用
const UserDashboard = safeLazy(() => import('@/views/UserDashboard.vue'))

function App() {
  return (
    <ErrorBoundary fallback={<div>页面异常</div>}>
      <Suspense fallback={<Spin size="large" />}>
        <UserDashboard />
      </Suspense>
    </ErrorBoundary>
  )
}

7.2 图片加载失败降级

<!-- Vue 3 + Element Plus:图片降级组件 -->
<script setup lang="ts">
import { ref, computed } from 'vue'

interface Props {
  src: string
  alt?: string
  fallbackSrc?: string
  fallbackText?: string
  width?: string
  height?: string
}

const props = withDefaults(defineProps<Props>(), {
  alt: '',
  fallbackSrc: '/assets/img-placeholder.svg',
  fallbackText: '图片加载失败',
  width: '100%',
  height: '200px',
})

const loadStatus = ref<'loading' | 'success' | 'error'>('loading')
const showFallback = computed(() => loadStatus.value === 'error')

function onLoadSuccess(): void {
  loadStatus.value = 'success'
}

function onLoadError(): void {
  loadStatus.value = 'error'
  reportError({
    type: 'resource_load',
    message: `图片加载失败: ${props.src}`,
    extra: { alt: props.alt },
  })
}
</script>

<template>
  <div class="safe-image" :style="{ width, height }">
    <!-- 加载中骨架 -->
    <div v-if="loadStatus === 'loading'" class="image-placeholder">
      <el-skeleton animated>
        <template #template>
          <el-skeleton-item variant="image" :style="{ width, height }" />
        </template>
      </el-skeleton>
    </div>

    <!-- 图片 -->
    <img
      v-show="loadStatus === 'success'"
      :src="src"
      :alt="alt"
      class="image-content"
      @load="onLoadSuccess"
      @error="onLoadError"
    />

    <!-- 降级展示 -->
    <div v-if="showFallback" class="image-fallback">
      <img :src="fallbackSrc" :alt="fallbackText" class="fallback-image" />
      <span class="fallback-text">{{ fallbackText }}</span>
    </div>
  </div>
</template>

<style scoped>
.safe-image {
  position: relative;
  overflow: hidden;
  border-radius: 4px;
}
.image-content {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.image-placeholder,
.image-fallback {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: #f5f5f5;
}
.fallback-image {
  width: 48px;
  height: 48px;
  opacity: 0.4;
}
.fallback-text {
  margin-top: 8px;
  font-size: 12px;
  color: #999;
}
</style>

7.3 接口超时兜底(缓存策略)

// ✅ 缓存兜底策略
interface CacheEntry<T> {
  data: T
  timestamp: number
  ttl: number // 缓存有效期(ms)
}

class ApiCacheFallback {
  private cache = new Map<string, CacheEntry<unknown>>()
  private readonly DEFAULT_TTL = 5 * 60 * 1000 // 5 分钟

  async fetch<T>(
    key: string,
    fetcher: () => Promise<T>,
    options?: { ttl?: number; staleWhileRevalidate?: boolean }
  ): Promise<T> {
    const cached = this.cache.get(key)

    // 1. 有缓存且在有效期内 → 直接返回
    if (cached && Date.now() - cached.timestamp < cached.ttl) {
      return cached.data as T
    }

    try {
      // 2. 请求最新数据
      const freshData = await fetcher()

      // 3. 更新缓存
      this.cache.set(key, {
        data: freshData,
        timestamp: Date.now(),
        ttl: options?.ttl ?? this.DEFAULT_TTL,
      })

      return freshData
    } catch (err) {
      // 4. 请求失败 + 有缓存(即使过期)→ 返回缓存(stale)
      if (cached) {
        console.warn(`[CacheFallback] ${key} 请求失败,使用缓存兜底`)
        return cached.data as T
      }

      // 5. 请求失败 + 无缓存 → 向上抛出
      throw err
    }
  }

  invalidate(key: string): void {
    this.cache.delete(key)
  }

  clear(): void {
    this.cache.clear()
  }
}

export const apiCache = new ApiCacheFallback()

// 使用示例
async function getConfig() {
  return apiCache.fetch(
    'app_config',
    () => http.get('/config'),
    { ttl: 10 * 60 * 1000 } // 10 分钟
  )
}

7.4 兜底 UI 汇总

降级场景 兜底策略 示例
接口超时 缓存兜底 + Toast 提示 展示旧数据,后台静默刷新
组件加载失败 降级组件(fallback UI) 块级错误展示 + 重试按钮
图片加载失败 占位图 + 文字提示 默认 SVG 占位
脚本加载失败 兜底组件代替动态模块 不阻塞整页渲染
数据格式异常 默认值 + 日志上报 不显示错误字段,用 "--" 替代
网络离线 离线提示条(非弹窗) 顶部黄色提示 + 自动恢复

8. 异常处理最佳实践清单

8.1 必须做的

  • 注册 window.onerrorunhandledrejection 全局捕获
  • 在应用入口注册框架级错误处理器(Vue errorHandler / React ErrorBoundary)
  • 为每个路由页面或模块包裹 ErrorBoundary
  • Axios 拦截器统一处理 HTTP 状态码
  • 所有异步操作添加 .catch()try-catch
  • 后端数据必须做空值检查和类型守卫
  • 错误信息脱敏后再上报

8.2 不应该做的

// ❌ 全局吞掉所有错误
window.onerror = () => true // 错误被吞掉,无法排查

// ❌ 暴露堆栈给用户
ElMessage.error(`错误堆栈: ${error.stack}`) // 安全漏洞

// ❌ 使用空 catch 块
try {
  await riskyOperation()
} catch {
  // 什么也不做,错误静默消失
}

// ❌ 过度使用 try-catch 包裹正常逻辑
try {
  const name = user.name // 用可选链 ?. 代替
} catch {
  // ...
}

// ❌ 每个组件都包裹 ErrorBoundary,粒度过细
// 合理粒度:路由页面级 + 关键模块

8.3 推荐架构

全局入口
  ├─ window.onerror              ← JS 运行时错误(兜底)
  ├─ unhandledrejection           ← 未捕获 Promise 异常
  ├─ Vue: errorHandler            ← Vue 组件异常
  ├─ React: ErrorBoundary(根)     ← React 渲染异常
  │
  ├─ Axios 拦截器                  ← 接口错误(HTTP + 业务)
  │    ├─ 401 → 跳转登录
  │    ├─ 403 → 无权限提示
  │    ├─ 5xx → 重试 / 兜底
  │    └─ 网络异常 → 离线提示
  │
  ├─ 防御式数据处理                 ← 后端数据校验
  │    ├─ 类型守卫 (isUserProfile)
  │    ├─ 可选链 (?.) + 空值合并 (??)
  │    └─ 默认值兜底
  │
  ├─ 降级策略                      ← 用户体感兜底
  │    ├─ 图片降级 → 占位图
  │    ├─ 组件降级 → fallback UI
  │    └─ 接口降级 → 缓存兜底
  │
  └─ 错误上报                      ← 问题追溯
       ├─ Sentry / 自研服务
       ├─ Source Map 还原
       └─ 用户行为回放

9. 注意事项 / 踩坑点

  1. window.onerrorwindow.addEventListener('error') 同时使用时注意去重:前者捕获 JS 运行时错误,后者监听资源加载错误。如果在 addEventListener 中不区分 ErrorEvent,会导致同一 JS 错误触发两次上报。

  2. 跨域脚本错误返回 Script error. 无堆栈信息:第三方 CDN 脚本的异常会被浏览器屏蔽。解决方案:在外部脚本上添加 crossorigin="anonymous" 属性,且 CDN 返回 Access-Control-Allow-Origin 头。

  3. unhandledrejection 并不等同于所有未 catch 的 async 异常:async 函数中如果用了 try-catch 捕获了异常,则不会触发;但如果 async 函数内部抛出了异常而没有 catch,且调用方也没有 await.catch(),则会触发。

  4. Vue 3 的 errorHandler 无法捕获 setup 中同步代码错误setup 在组件创建阶段执行,其错误会被 errorHandler 捕获。但注意区分:onMounted 中的异步错误只能通过 catch 捕获。

  5. Error Boundary 无法捕获事件处理中的错误:React 的 ErrorBoundary 只捕获 render 阶段和生命周期函数中的异常。事件处理中的异常需要手动 try-catch。

  6. 不要在 ErrorBoundary 的 fallback UI 中再次抛出异常:会导致无限循环。ErrorBoundary 自身应尽量简单,仅展示降级 UI 和重试按钮。

  7. Source Map 生产环境部署风险:如果 .map 文件随 CDN 部署,任何人都可以通过浏览器 DevTools 查看原始源码。务必只将 Source Map 上传到 Sentry 等内部服务,不暴露在公网。

  8. message.error 和 Toast 的并发控制:连续多个请求失败会导致弹窗堆积,可以考虑防抖合并或队列管理(如只显示最新一条,或汇总显示 共 N 个请求失败)。

  9. requestIdleCallback 用于低优先级上报:错误上报不应阻塞用户交互,可以通过 requestIdleCallbacknavigator.sendBeacon 实现非阻塞上报。

  10. 网络离线检测需要结合 navigator.onLineonline/offline 事件navigator.onLine 在某些浏览器中不准确(如用户关闭 WiFi 但仍有有线连接),需要结合事件和心跳检测。


最后更新:2026/06/29