前端异常处理与错误边界
前端异常处理是保障用户体感和应用稳定性的最后一道防线。合理的错误捕获、优雅的降级兜底、精准的错误上报,构成了前端应用的"免疫系统"。
1. 前端异常分类
1.1 异常类型概览
| 异常类型 | 触发场景 | 能否被 ErrorBoundary 捕获 | 是否影响后续执行 |
|---|---|---|---|
| JS 运行时错误 | TypeError、ReferenceError、SyntaxError |
能(渲染阶段) | 是 |
| 异步错误 | 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.onerror和unhandledrejection全局捕获 - 在应用入口注册框架级错误处理器(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. 注意事项 / 踩坑点
-
window.onerror与window.addEventListener('error')同时使用时注意去重:前者捕获 JS 运行时错误,后者监听资源加载错误。如果在addEventListener中不区分ErrorEvent,会导致同一 JS 错误触发两次上报。 -
跨域脚本错误返回
Script error.无堆栈信息:第三方 CDN 脚本的异常会被浏览器屏蔽。解决方案:在外部脚本上添加crossorigin="anonymous"属性,且 CDN 返回Access-Control-Allow-Origin头。 -
unhandledrejection并不等同于所有未 catch 的 async 异常:async 函数中如果用了try-catch捕获了异常,则不会触发;但如果 async 函数内部抛出了异常而没有 catch,且调用方也没有await或.catch(),则会触发。 -
Vue 3 的
errorHandler无法捕获setup中同步代码错误:setup在组件创建阶段执行,其错误会被errorHandler捕获。但注意区分:onMounted中的异步错误只能通过catch捕获。 -
Error Boundary 无法捕获事件处理中的错误:React 的 ErrorBoundary 只捕获 render 阶段和生命周期函数中的异常。事件处理中的异常需要手动 try-catch。
-
不要在 ErrorBoundary 的 fallback UI 中再次抛出异常:会导致无限循环。ErrorBoundary 自身应尽量简单,仅展示降级 UI 和重试按钮。
-
Source Map 生产环境部署风险:如果
.map文件随 CDN 部署,任何人都可以通过浏览器 DevTools 查看原始源码。务必只将 Source Map 上传到 Sentry 等内部服务,不暴露在公网。 -
message.error和 Toast 的并发控制:连续多个请求失败会导致弹窗堆积,可以考虑防抖合并或队列管理(如只显示最新一条,或汇总显示共 N 个请求失败)。 -
requestIdleCallback用于低优先级上报:错误上报不应阻塞用户交互,可以通过requestIdleCallback或navigator.sendBeacon实现非阻塞上报。 -
网络离线检测需要结合
navigator.onLine和online/offline事件:navigator.onLine在某些浏览器中不准确(如用户关闭 WiFi 但仍有有线连接),需要结合事件和心跳检测。
最后更新:2026/06/29