← 返回首页
文章
2026-03-31
Vue3 + TypeScript Axios 完全封装指南
Vue3 + TypeScript Axios 完全封装指南 从基础封装到高级特性,错误重试、全局Loading、鉴权拦截、文件下载,覆盖 Vue3 项目中所有 Axios 使用场景 目录 1. 核心概念 2. 基础封装...
Vue3 + TypeScript Axios 完全封装指南 #
从基础封装到高级特性,错误重试、全局Loading、鉴权拦截、文件下载,覆盖 Vue3 项目中所有 Axios 使用场景
目录 #
- 核心概念
- 基础封装
- 请求拦截器
- 响应拦截器
- 错误处理与重试
- 全局 Loading
- 鉴权处理
- 文件上传下载
- 请求取消
- API 模块化管理
- Vue3 Composable 封装
- 实战场景
- 常见问题
- 总结速记
一、核心概念 #
1.1 为什么需要封装 Axios? #
Axios 很强大,但直接用有几个问题:
| 问题 | 说明 | 封装后解决 |
|---|---|---|
| 重复代码 | 每次都要写 baseURL、headers | 统一配置 |
| 错误处理分散 | 每个请求都要 try-catch | 统一拦截处理 |
| 无 Token 自动刷新 | Token 过期需要手动处理 | 自动刷新 |
| 无请求重试 | 网络波动失败就挂了 | 自动重试 |
| 无 Loading 状态 | 需要手动管理加载状态 | 全局自动管理 |
| API 散落各处 | 接口定义在各个组件中 | 统一模块管理 |
1.2 封装的核心功能 #
Axios 封装架构
├── 基础配置(baseURL、timeout、headers)
├── 请求拦截器
│ ├── 自动添加 Token
│ ├── 请求 ID 追踪
│ └── Loading 状态管理
├── 响应拦截器
│ ├── 统一数据格式
│ ├── Token 过期自动刷新
│ └── 全局错误提示
├── 错误处理
│ ├── 网络错误重试
│ ├── 业务错误处理
│ └── 超时处理
├── 特殊功能
│ ├── 文件上传/下载
│ ├── 请求取消
│ └── 并发请求
└── API 模块化
├── 按业务分模块
└── TypeScript 类型定义1.3 核心名词解释 #
| 名词 | 解释 |
|---|---|
| Interceptor | 拦截器,请求/响应发出前的预处理 |
| Adapter | 适配器,自定义请求发送方式 |
| CancelToken | 取消令牌,用于取消请求 |
| Transformer | 转换器,转换请求/响应数据 |
| Instance | Axios 实例,独立配置的客户端 |
二、基础封装 #
2.1 安装依赖 #
# 安装 axios
npm install axios
# 安装类型定义(TypeScript)
npm install -D @types/axios
# 安装 element-plus(用于全局提示,可选)
npm install element-plus2.2 TypeScript 类型定义 #
// types/api.ts
// 通用响应结构
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
// 分页响应
export interface PageResponse<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
// 请求配置扩展
export interface RequestOptions {
showLoading?: boolean; // 是否显示 loading
showError?: boolean; // 是否显示错误提示
showSuccess?: boolean; // 是否显示成功提示
successMessage?: string; // 成功提示文案
errorMessage?: string; // 错误提示文案
retryTimes?: number; // 重试次数
retryDelay?: number; // 重试间隔(毫秒)
timeout?: number; // 超时时间
headers?: Record<string, string>; // 自定义请求头
}
// 错误类型
export interface ApiError {
code: number;
message: string;
details?: any;
}
// 用户信息(用于鉴权)
export interface UserInfo {
id: string;
name: string;
token: string;
refreshToken: string;
}2.3 创建 Axios 实例 #
// utils/request.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import type { ApiResponse, RequestOptions, ApiError } from '@/types/api';
// 默认配置
const DEFAULT_CONFIG: AxiosRequestConfig = {
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
};
// 默认请求选项
const DEFAULT_OPTIONS: RequestOptions = {
showLoading: true,
showError: true,
showSuccess: false,
retryTimes: 0,
retryDelay: 1000,
};
class Request {
private instance: AxiosInstance;
private options: RequestOptions;
private loadingCount = 0; // loading 计数器(防止多个请求闪烁)
constructor(config: AxiosRequestConfig = {}) {
// 创建 Axios 实例
this.instance = axios.create({
...DEFAULT_CONFIG,
...config,
});
this.options = DEFAULT_OPTIONS;
// 设置拦截器
this.setupInterceptors();
}
// 设置拦截器
private setupInterceptors() {
// 请求拦截器
this.instance.interceptors.request.use(
this.handleRequest.bind(this),
this.handleRequestError.bind(this)
);
// 响应拦截器
this.instance.interceptors.response.use(
this.handleResponse.bind(this),
this.handleResponseError.bind(this)
);
}
// ... 后续实现各个方法
}
// 导出单例
export const request = new Request();
// 导出类,方便创建多个实例
export default Request;2.4 基础请求方法 #
// utils/request.ts(续)
class Request {
// ... 前面的代码
// GET 请求
async get<T = any>(
url: string,
params?: Record<string, any>,
options?: RequestOptions
): Promise<T> {
return this.request<T>('GET', url, { params, ...options });
}
// POST 请求
async post<T = any>(
url: string,
data?: Record<string, any>,
options?: RequestOptions
): Promise<T> {
return this.request<T>('POST', url, { data, ...options });
}
// PUT 请求
async put<T = any>(
url: string,
data?: Record<string, any>,
options?: RequestOptions
): Promise<T> {
return this.request<T>('PUT', url, { data, ...options });
}
// DELETE 请求
async delete<T = any>(
url: string,
params?: Record<string, any>,
options?: RequestOptions
): Promise<T> {
return this.request<T>('DELETE', url, { params, ...options });
}
// PATCH 请求
async patch<T = any>(
url: string,
data?: Record<string, any>,
options?: RequestOptions
): Promise<T> {
return this.request<T>('PATCH', url, { data, ...options });
}
// 通用请求方法
private async request<T>(
method: string,
url: string,
config: AxiosRequestConfig & RequestOptions = {}
): Promise<T> {
const options = { ...this.options, ...config };
try {
const response = await this.instance.request<ApiResponse<T>>({
method,
url,
...config,
headers: {
...options.headers,
},
});
return response.data.data;
} catch (error) {
throw this.transformError(error as AxiosError);
}
}
// 转换错误
private transformError(error: AxiosError): ApiError {
if (error.response) {
// 服务器返回错误
const data = error.response.data as any;
return {
code: data?.code || error.response.status,
message: data?.message || this.getDefaultErrorMessage(error.response.status),
details: data?.details,
};
} else if (error.request) {
// 请求发出但无响应
return {
code: -1,
message: '网络错误,请检查网络连接',
};
} else {
// 请求配置错误
return {
code: -2,
message: error.message || '请求配置错误',
};
}
}
// 根据状态码获取默认错误信息
private getDefaultErrorMessage(status: number): string {
const messages: Record<number, string> = {
400: '请求参数错误',
401: '未授权,请重新登录',
403: '拒绝访问',
404: '请求资源不存在',
405: '请求方法不允许',
408: '请求超时',
500: '服务器内部错误',
501: '服务未实现',
502: '网关错误',
503: '服务不可用',
504: '网关超时',
505: 'HTTP版本不受支持',
};
return messages[status] || `请求失败(${status})`;
}
}三、请求拦截器 #
3.1 基础请求拦截 #
// utils/request.ts(续)
class Request {
// 请求拦截处理
private handleRequest(config: any) {
const options = config as RequestOptions;
// 1. 显示 Loading
if (options.showLoading !== false) {
this.showLoading();
}
// 2. 添加 Token
const token = this.getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 3. 添加请求 ID(用于追踪)
config.headers['X-Request-Id'] = this.generateRequestId();
// 4. 添加时间戳(防止缓存)
if (config.method?.toLowerCase() === 'get') {
config.params = {
...config.params,
_t: Date.now(),
};
}
console.log(`[Request] ${config.method?.toUpperCase()} ${config.url}`, config);
return config;
}
// 请求错误处理
private handleRequestError(error: any) {
this.hideLoading();
console.error('[Request Error]', error);
return Promise.reject(error);
}
// 获取 Token
private getToken(): string | null {
return localStorage.getItem('token');
}
// 生成请求 ID
private generateRequestId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}3.2 Token 自动刷新 #
// utils/request.ts(续)
class Request {
private isRefreshing = false; // 是否正在刷新 Token
private refreshSubscribers: Array<(token: string) => void> = []; // 等待队列
// 请求拦截处理(增强版)
private async handleRequest(config: any) {
const options = config as RequestOptions;
// 显示 Loading
if (options.showLoading !== false) {
this.showLoading();
}
// 获取 Token
const token = this.getToken();
const refreshToken = this.getRefreshToken();
if (token) {
// 检查 Token 是否即将过期(比如 5 分钟内)
if (this.isTokenExpiring(token) && refreshToken) {
// 如果正在刷新,加入等待队列
if (this.isRefreshing) {
return new Promise((resolve) => {
this.refreshSubscribers.push((newToken) => {
config.headers.Authorization = `Bearer ${newToken}`;
resolve(config);
});
});
}
// 开始刷新 Token
this.isRefreshing = true;
try {
const newToken = await this.refreshToken(refreshToken);
// 更新 Token
this.setToken(newToken);
// 通知等待队列
this.refreshSubscribers.forEach(callback => callback(newToken));
this.refreshSubscribers = [];
config.headers.Authorization = `Bearer ${newToken}`;
} catch (error) {
// 刷新失败,清除 Token,跳转登录
this.clearToken();
window.location.href = '/login';
return Promise.reject(error);
} finally {
this.isRefreshing = false;
}
} else {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
}
// 检查 Token 是否即将过期
private isTokenExpiring(token: string): boolean {
try {
// 解析 JWT Token
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp * 1000; // 转换为毫秒
const now = Date.now();
// 5 分钟内过期
return exp - now < 5 * 60 * 1000;
} catch {
return false;
}
}
// 刷新 Token
private async refreshAuthToken(refreshToken: string): Promise<string> {
const response = await axios.post('/api/auth/refresh', {
refreshToken,
});
return response.data.data.token;
}
// Token 存取方法
private getRefreshToken(): string | null {
return localStorage.getItem('refreshToken');
}
private setToken(token: string) {
localStorage.setItem('token', token);
}
private clearToken() {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
}
}四、响应拦截器 #
4.1 基础响应拦截 #
// utils/request.ts(续)
class Request {
// 响应拦截处理
private handleResponse(response: AxiosResponse<ApiResponse>) {
// 隐藏 Loading
this.hideLoading();
const { data, config } = response;
const options = config as any as RequestOptions;
console.log(`[Response] ${config.url}`, data);
// 业务状态码判断
if (data.code === 0 || data.code === 200) {
// 成功
if (options.showSuccess && options.successMessage) {
this.showSuccessMessage(options.successMessage);
}
return data;
}
// 业务错误
const error: ApiError = {
code: data.code,
message: data.message || '请求失败',
details: data.details,
};
// 显示错误提示
if (options.showError !== false) {
this.showErrorMessage(error.message);
}
return Promise.reject(error);
}
// 响应错误处理
private handleResponseError(error: AxiosError) {
this.hideLoading();
const options = (error.config as any) as RequestOptions;
const response = error.response;
console.error('[Response Error]', error);
// 处理特定状态码
if (response) {
switch (response.status) {
case 401:
// 未授权,跳转登录
this.handleUnauthorized();
break;
case 403:
// 无权限
this.showErrorMessage('无权限访问');
break;
case 404:
// 资源不存在
this.showErrorMessage('请求资源不存在');
break;
case 500:
// 服务器错误
this.showErrorMessage('服务器错误,请稍后重试');
break;
}
}
// 显示错误提示
if (options?.showError !== false) {
const message = this.getErrorMessage(error);
this.showErrorMessage(message);
}
return Promise.reject(this.transformError(error));
}
// 处理未授权
private handleUnauthorized() {
this.clearToken();
// 保存当前路由,登录后跳回
const currentPath = window.location.pathname;
localStorage.setItem('redirectPath', currentPath);
// 跳转登录
window.location.href = '/login';
}
// 获取错误信息
private getErrorMessage(error: AxiosError): string {
if (error.code === 'ECONNABORTED') {
return '请求超时,请重试';
}
if (!window.navigator.onLine) {
return '网络断开,请检查网络连接';
}
if (error.message === 'Network Error') {
return '网络错误,请检查网络连接';
}
return error.message || '请求失败';
}
}4.2 统一数据格式处理 #
// utils/request.ts(续)
class Request {
// 响应拦截处理(增强版)
private handleResponse(response: AxiosResponse) {
this.hideLoading();
const { data, config } = response;
// 统一处理响应数据
const result = this.normalizeResponse(data);
// 根据业务状态码处理
if (result.success) {
return result;
}
// 特定业务错误码处理
this.handleBusinessError(result.code, result.message);
return Promise.reject({
code: result.code,
message: result.message,
details: result.details,
});
}
// 标准化响应格式
private normalizeResponse(data: any): {
success: boolean;
code: number;
message: string;
data: any;
details?: any;
} {
// 情况 1:标准格式 { code, message, data }
if ('code' in data && 'data' in data) {
return {
success: data.code === 0 || data.code === 200,
code: data.code,
message: data.message || '',
data: data.data,
details: data.details,
};
}
// 情况 2:直接返回数据(无包装)
return {
success: true,
code: 200,
message: '',
data: data,
};
}
// 处理业务错误码
private handleBusinessError(code: number, message: string) {
switch (code) {
case 10001: // 参数错误
this.showErrorMessage(message || '参数错误');
break;
case 10002: // Token 无效
this.handleUnauthorized();
break;
case 10003: // 权限不足
this.showErrorMessage(message || '权限不足');
break;
case 10004: // 资源不存在
this.showErrorMessage(message || '资源不存在');
break;
case 10005: // 操作频繁
this.showErrorMessage(message || '操作过于频繁,请稍后再试');
break;
default:
this.showErrorMessage(message || '请求失败');
}
}
}五、错误处理与重试 #
5.1 自动重试机制 #
// utils/request.ts(续)
class Request {
// 通用请求方法(带重试)
private async request<T>(
method: string,
url: string,
config: AxiosRequestConfig & RequestOptions = {}
): Promise<T> {
const options = { ...this.options, ...config };
const retryTimes = options.retryTimes || 0;
const retryDelay = options.retryDelay || 1000;
let lastError: any;
for (let attempt = 0; attempt <= retryTimes; attempt++) {
try {
const response = await this.instance.request<ApiResponse<T>>({
method,
url,
...config,
timeout: options.timeout || DEFAULT_CONFIG.timeout,
});
return response.data.data;
} catch (error) {
lastError = error;
// 判断是否应该重试
if (!this.shouldRetry(error as AxiosError, attempt, retryTimes)) {
break;
}
// 等待后重试
console.log(`[Retry] ${url} 第 ${attempt + 1} 次重试...`);
await this.delay(retryDelay * (attempt + 1)); // 指数退避
}
}
throw this.transformError(lastError);
}
// 判断是否应该重试
private shouldRetry(error: AxiosError, attempt: number, maxRetry: number): boolean {
// 已达到最大重试次数
if (attempt >= maxRetry) {
return false;
}
// 网络错误或超时,可以重试
if (!error.response) {
return true;
}
// 特定状态码可以重试
const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
return retryableStatusCodes.includes(error.response.status);
}
// 延迟函数
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}5.2 手动重试封装 #
// utils/retry.ts
interface RetryOptions {
maxAttempts?: number; // 最大尝试次数
delay?: number; // 重试间隔
backoff?: 'fixed' | 'exponential'; // 退避策略
shouldRetry?: (error: any, attempt: number) => boolean; // 自定义重试条件
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const {
maxAttempts = 3,
delay = 1000,
backoff = 'exponential',
shouldRetry = () => true,
} = options;
let lastError: any;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (!shouldRetry(error, attempt) || attempt >= maxAttempts) {
throw error;
}
const waitTime = backoff === 'exponential'
? delay * Math.pow(2, attempt - 1)
: delay;
console.log(`[Retry] 第 ${attempt} 次重试,等待 ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
throw lastError;
}
// 使用示例
async function fetchData() {
return withRetry(
() => request.get('/api/data'),
{
maxAttempts: 3,
delay: 1000,
backoff: 'exponential',
shouldRetry: (error, attempt) => {
// 401 不重试
if (error.code === 401) return false;
return true;
},
}
);
}5.3 全局错误处理 #
// utils/errorHandler.ts
import { ElMessage, ElNotification } from 'element-plus';
import type { ApiError } from '@/types/api';
// 错误处理器
export class ErrorHandler {
private static instance: ErrorHandler;
private constructor() {}
static getInstance(): ErrorHandler {
if (!ErrorHandler.instance) {
ErrorHandler.instance = new ErrorHandler();
}
return ErrorHandler.instance;
}
// 处理 API 错误
handle(error: ApiError, options: { silent?: boolean } = {}) {
if (options.silent) return;
// 根据错误类型处理
if (error.code === -1) {
// 网络错误
this.showNetworkError();
} else if (error.code === 401) {
// 未授权
this.handleUnauthorized();
} else if (error.code >= 500) {
// 服务器错误
this.showServerError(error);
} else {
// 业务错误
this.showBusinessError(error);
}
// 记录错误日志
this.logError(error);
}
// 显示网络错误
private showNetworkError() {
ElMessage.error({
message: '网络连接失败,请检查网络设置',
duration: 5000,
});
}
// 处理未授权
private handleUnauthorized() {
ElNotification.warning({
title: '登录已过期',
message: '请重新登录',
duration: 3000,
});
// 清除登录状态
localStorage.clear();
// 跳转登录
setTimeout(() => {
window.location.href = '/login';
}, 1500);
}
// 显示服务器错误
private showServerError(error: ApiError) {
ElNotification.error({
title: '服务器错误',
message: error.message || '服务器繁忙,请稍后重试',
duration: 5000,
});
}
// 显示业务错误
private showBusinessError(error: ApiError) {
ElMessage.error(error.message || '操作失败');
}
// 记录错误日志
private logError(error: ApiError) {
console.error('[API Error]', {
code: error.code,
message: error.message,
details: error.details,
timestamp: new Date().toISOString(),
url: window.location.href,
});
// 可以发送到日志服务器
// this.sendToLogServer(error);
}
}
// 导出单例
export const errorHandler = ErrorHandler.getInstance();六、全局 Loading #
6.1 Loading 状态管理 #
// utils/request.ts(续)
import { ElLoading } from 'element-plus';
class Request {
private loadingInstance: ReturnType<typeof ElLoading.service> | null = null;
private loadingCount = 0;
// 显示 Loading
private showLoading() {
this.loadingCount++;
if (this.loadingCount === 1) {
this.loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(0, 0, 0, 0.7)',
});
}
}
// 隐藏 Loading
private hideLoading() {
this.loadingCount--;
if (this.loadingCount <= 0) {
this.loadingCount = 0;
if (this.loadingInstance) {
this.loadingInstance.close();
this.loadingInstance = null;
}
}
}
// 强制关闭 Loading
forceHideLoading() {
this.loadingCount = 0;
if (this.loadingInstance) {
this.loadingInstance.close();
this.loadingInstance = null;
}
}
}6.2 Vue3 Loading Composable #
// composables/useLoading.ts
import { ref, computed } from 'vue';
interface LoadingState {
[key: string]: boolean;
}
const loadingState = ref<LoadingState>({});
export function useLoading() {
// 设置加载状态
const setLoading = (key: string, status: boolean) => {
loadingState.value[key] = status;
};
// 开始加载
const startLoading = (key: string) => {
setLoading(key, true);
};
// 停止加载
const stopLoading = (key: string) => {
setLoading(key, false);
};
// 检查是否加载中
const isLoading = (key: string) => {
return computed(() => loadingState.value[key] || false);
};
// 全局加载状态
const isGlobalLoading = computed(() => {
return Object.values(loadingState.value).some(status => status);
});
// 执行异步操作并自动管理 loading
const withLoading = async <T>(
key: string,
fn: () => Promise<T>
): Promise<T> => {
try {
startLoading(key);
return await fn();
} finally {
stopLoading(key);
}
};
return {
loadingState,
setLoading,
startLoading,
stopLoading,
isLoading,
isGlobalLoading,
withLoading,
};
}6.3 Loading 组件 #
<!-- components/LoadingOverlay.vue -->
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="visible" class="loading-overlay">
<div class="loading-spinner">
<div class="spinner"></div>
<p v-if="text" class="loading-text">{{ text }}</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useLoading } from '@/composables/useLoading';
const props = defineProps<{
text?: string;
global?: boolean;
loadingKey?: string;
}>();
const { loadingState, isGlobalLoading } = useLoading();
const visible = computed(() => {
if (props.global) {
return isGlobalLoading.value;
}
if (props.loadingKey) {
return loadingState.value[props.loadingKey] || false;
}
return false;
});
</script>
<style scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: #fff;
font-size: 14px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>七、鉴权处理 #
7.1 Token 存储与管理 #
// utils/auth.ts
import type { UserInfo } from '@/types/api';
const TOKEN_KEY = 'token';
const REFRESH_TOKEN_KEY = 'refreshToken';
const USER_INFO_KEY = 'userInfo';
// Token 管理
export const auth = {
// 获取 Token
getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
},
// 设置 Token
setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
},
// 获取 Refresh Token
getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY);
},
// 设置 Refresh Token
setRefreshToken(refreshToken: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
},
// 获取用户信息
getUserInfo(): UserInfo | null {
const info = localStorage.getItem(USER_INFO_KEY);
return info ? JSON.parse(info) : null;
},
// 设置用户信息
setUserInfo(userInfo: UserInfo): void {
localStorage.setItem(USER_INFO_KEY, JSON.stringify(userInfo));
},
// 检查是否已登录
isAuthenticated(): boolean {
return !!this.getToken();
},
// 登录
login(token: string, refreshToken: string, userInfo: UserInfo): void {
this.setToken(token);
this.setRefreshToken(refreshToken);
this.setUserInfo(userInfo);
},
// 登出
logout(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_INFO_KEY);
},
// 清除所有认证信息
clearAuth(): void {
this.logout();
},
};
// Token 过期检查
export function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp * 1000;
return Date.now() >= exp;
} catch {
return true;
}
}
// Token 即将过期检查
export function isTokenExpiringSoon(token: string, minutes: number = 5): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp * 1000;
return exp - Date.now() < minutes * 60 * 1000;
} catch {
return true;
}
}7.2 路由守卫 #
// router/guards.ts
import type { Router } from 'vue-router';
import { auth } from '@/utils/auth';
// 白名单路由
const WHITE_LIST = ['/login', '/register', '/forgot-password', '/404'];
export function setupRouterGuards(router: Router) {
// 前置守卫
router.beforeEach(async (to, from, next) => {
// 显示页面加载进度
// NProgress.start();
const isAuthenticated = auth.isAuthenticated();
// 已登录
if (isAuthenticated) {
if (to.path === '/login') {
// 已登录访问登录页,跳转首页
next({ path: '/' });
} else {
// 检查权限
const hasPermission = await checkPermission(to);
if (hasPermission) {
next();
} else {
next({ path: '/403' });
}
}
} else {
// 未登录
if (WHITE_LIST.includes(to.path)) {
// 白名单路由,直接访问
next();
} else {
// 需要登录,跳转登录页
next({
path: '/login',
query: { redirect: to.fullPath },
});
}
}
});
// 后置守卫
router.afterEach((to) => {
// 隐藏页面加载进度
// NProgress.done();
// 设置页面标题
const title = to.meta.title as string;
document.title = title ? `${title} - MyApp` : 'MyApp';
});
}
// 检查权限
async function checkPermission(route: any): Promise<boolean> {
const requiredRoles = route.meta.roles as string[] | undefined;
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const userInfo = auth.getUserInfo();
if (!userInfo) {
return false;
}
// 检查用户角色是否匹配
// return requiredRoles.includes(userInfo.role);
return true;
}7.3 权限指令 #
// directives/permission.ts
import type { Directive, DirectiveBinding } from 'vue';
import { auth } from '@/utils/auth';
/**
* 权限指令
* v-permission="['admin']" // 需要管理员权限
* v-permission="['user', 'admin']" // 需要用户或管理员权限
*/
export const permission: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string[]>) {
const requiredRoles = binding.value;
if (!requiredRoles || requiredRoles.length === 0) {
return;
}
const userInfo = auth.getUserInfo();
if (!userInfo) {
el.parentNode?.removeChild(el);
return;
}
// 检查权限
// const hasPermission = requiredRoles.includes(userInfo.role);
const hasPermission = true; // 示例
if (!hasPermission) {
el.parentNode?.removeChild(el);
}
},
};
/**
* 角色指令
* v-role="'admin'" // 仅管理员可见
*/
export const role: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
const requiredRole = binding.value;
if (!requiredRole) {
return;
}
const userInfo = auth.getUserInfo();
if (!userInfo || userInfo.name !== requiredRole) { // 示例判断
el.parentNode?.removeChild(el);
}
},
};
// 注册指令
export function setupPermissionDirectives(app: any) {
app.directive('permission', permission);
app.directive('role', role);
}八、文件上传下载 #
8.1 文件上传 #
// utils/upload.ts
import { request } from './request';
import type { RequestOptions } from '@/types/api';
export interface UploadOptions extends RequestOptions {
onProgress?: (percent: number) => void; // 上传进度回调
accept?: string; // 接受的文件类型
maxSize?: number; // 最大文件大小(字节)
}
// 上传单个文件
export async function uploadFile(
url: string,
file: File,
options: UploadOptions = {}
): Promise<string> {
const { onProgress, maxSize = 10 * 1024 * 1024 } = options;
// 验证文件大小
if (file.size > maxSize) {
throw new Error(`文件大小不能超过 ${formatFileSize(maxSize)}`);
}
// 创建 FormData
const formData = new FormData();
formData.append('file', file);
// 发送请求
const response = await request.post(url, formData, {
...options,
showLoading: false, // 不显示全局 loading,用进度条
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percent);
}
},
});
return response.url;
}
// 上传多个文件
export async function uploadFiles(
url: string,
files: File[],
options: UploadOptions = {}
): Promise<string[]> {
const urls: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
const url = await uploadFile(url, file, {
...options,
onProgress: (percent) => {
const totalPercent = Math.round(((i + percent / 100) / files.length) * 100);
options.onProgress?.(totalPercent);
},
});
urls.push(url);
} catch (error) {
console.error(`上传文件 ${file.name} 失败:`, error);
throw error;
}
}
return urls;
}
// Base64 上传
export async function uploadBase64(
url: string,
base64: string,
filename: string,
options: UploadOptions = {}
): Promise<string> {
// Base64 转 Blob
const blob = base64ToBlob(base64);
const file = new File([blob], filename, { type: blob.type });
return uploadFile(url, file, options);
}
// 辅助函数
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function base64ToBlob(base64: string): Blob {
const arr = base64.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png';
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}8.2 文件下载 #
// utils/download.ts
import axios from 'axios';
import { auth } from './auth';
export interface DownloadOptions {
filename?: string; // 文件名
onProgress?: (percent: number) => void; // 下载进度
}
// 下载文件(Blob 方式)
export async function downloadFile(
url: string,
options: DownloadOptions = {}
): Promise<void> {
const { filename, onProgress } = options;
try {
const response = await axios.get(url, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${auth.getToken()}`,
},
onDownloadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percent);
}
},
});
// 获取文件名
const downloadFilename = filename || getFilenameFromResponse(response) || 'download';
// 创建下载链接
const blob = response.data;
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = downloadFilename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
} catch (error) {
console.error('下载文件失败:', error);
throw error;
}
}
// 通过 URL 下载(直接打开)
export function downloadByUrl(url: string, filename?: string): void {
const link = document.createElement('a');
link.href = url;
link.download = filename || '';
link.target = '_blank';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// 从响应头获取文件名
function getFilenameFromResponse(response: any): string | null {
const disposition = response.headers['content-disposition'];
if (!disposition) {
return null;
}
// 尝试从 Content-Disposition 解析文件名
const filenameMatch = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (filenameMatch && filenameMatch[1]) {
let filename = filenameMatch[1].replace(/['"]/g, '');
// 解码 URL 编码的文件名
try {
filename = decodeURIComponent(filename);
} catch {
// 忽略解码错误
}
return filename;
}
return null;
}
// 导出 Excel(Blob 数据)
export async function exportExcel(
url: string,
params: Record<string, any> = {},
options: DownloadOptions = {}
): Promise<void> {
const response = await axios.get(url, {
params,
responseType: 'blob',
headers: {
Authorization: `Bearer ${auth.getToken()}`,
},
});
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const downloadUrl = URL.createObjectURL(blob);
const filename = options.filename || `导出数据_${formatDate(new Date())}.xlsx`;
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
}
// 辅助函数
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}8.3 Vue3 上传组件 #
<!-- components/FileUpload.vue -->
<template>
<div class="file-upload">
<!-- 上传区域 -->
<div
class="upload-area"
:class="{ 'is-dragover': isDragover }"
@click="triggerUpload"
@dragover.prevent="isDragover = true"
@dragleave.prevent="isDragover = false"
@drop.prevent="handleDrop"
>
<input
ref="inputRef"
type="file"
:accept="accept"
:multiple="multiple"
hidden
@change="handleFileChange"
/>
<div v-if="!fileList.length" class="upload-placeholder">
<el-icon><Upload /></el-icon>
<p>点击或拖拽文件到此处上传</p>
<p class="tip">支持 {{ accept || '所有格式' }},最大 {{ formatSize(maxSize) }}</p>
</div>
<!-- 文件列表 -->
<div v-else class="file-list">
<div
v-for="(file, index) in fileList"
:key="index"
class="file-item"
>
<div class="file-info">
<el-icon><Document /></el-icon>
<span class="filename">{{ file.name }}</span>
<span class="filesize">{{ formatSize(file.size) }}</span>
</div>
<div class="file-actions">
<el-progress
v-if="file.uploading"
:percentage="file.progress"
:stroke-width="6"
/>
<template v-else>
<el-icon v-if="file.uploaded" class="success"><CircleCheck /></el-icon>
<el-icon v-else-if="file.error" class="error"><CircleClose /></el-icon>
<el-icon v-else class="remove" @click.stop="removeFile(index)"><Delete /></el-icon>
</template>
</div>
</div>
</div>
</div>
<!-- 上传按钮 -->
<div v-if="fileList.length && !allUploaded" class="upload-actions">
<el-button type="primary" :loading="uploading" @click="uploadFiles">
{{ uploading ? '上传中...' : '开始上传' }}
</el-button>
<el-button @click="clearFiles">清空</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ElMessage } from 'element-plus';
import { Upload, Document, CircleCheck, CircleClose, Delete } from '@element-plus/icons-vue';
import { uploadFile as uploadFileApi } from '@/utils/upload';
interface FileItem {
file: File;
name: string;
size: number;
uploading: boolean;
uploaded: boolean;
error: boolean;
progress: number;
url?: string;
}
const props = withDefaults(
defineProps<{
url: string;
accept?: string;
maxSize?: number;
multiple?: boolean;
limit?: number;
}>(),
{
accept: '',
maxSize: 10 * 1024 * 1024, // 10MB
multiple: false,
limit: 5,
}
);
const emit = defineEmits<{
success: [urls: string[]];
error: [error: Error];
}>();
const inputRef = ref<HTMLInputElement>();
const isDragover = ref(false);
const fileList = ref<FileItem[]>([]);
const uploading = ref(false);
const allUploaded = computed(() => {
return fileList.value.every(f => f.uploaded);
});
function triggerUpload() {
inputRef.value?.click();
}
function handleFileChange(e: Event) {
const files = (e.target as HTMLInputElement).files;
if (files) {
addFiles(Array.from(files));
}
}
function handleDrop(e: DragEvent) {
isDragover.value = false;
const files = e.dataTransfer?.files;
if (files) {
addFiles(Array.from(files));
}
}
function addFiles(files: File[]) {
// 检查数量限制
if (fileList.value.length + files.length > props.limit) {
ElMessage.warning(`最多只能上传 ${props.limit} 个文件`);
return;
}
// 检查文件大小
for (const file of files) {
if (file.size > props.maxSize) {
ElMessage.error(`文件 ${file.name} 超过最大限制 ${formatSize(props.maxSize)}`);
continue;
}
fileList.value.push({
file,
name: file.name,
size: file.size,
uploading: false,
uploaded: false,
error: false,
progress: 0,
});
}
}
function removeFile(index: number) {
fileList.value.splice(index, 1);
}
function clearFiles() {
fileList.value = [];
}
async function uploadFiles() {
uploading.value = true;
const urls: string[] = [];
for (const fileItem of fileList.value) {
if (fileItem.uploaded) continue;
fileItem.uploading = true;
fileItem.error = false;
try {
const url = await uploadFileApi(props.url, fileItem.file, {
onProgress: (percent) => {
fileItem.progress = percent;
},
});
fileItem.uploaded = true;
fileItem.url = url;
urls.push(url);
} catch (error) {
fileItem.error = true;
console.error('上传失败:', error);
} finally {
fileItem.uploading = false;
}
}
uploading.value = false;
if (urls.length > 0) {
emit('success', urls);
ElMessage.success(`成功上传 ${urls.length} 个文件`);
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
</script>
<style scoped>
.upload-area {
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 20px;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover,
.upload-area.is-dragover {
border-color: #409eff;
background: #ecf5ff;
}
.upload-placeholder {
text-align: center;
color: #909399;
}
.upload-placeholder .el-icon {
font-size: 48px;
margin-bottom: 10px;
}
.upload-placeholder .tip {
font-size: 12px;
margin-top: 8px;
}
.file-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
}
.filename {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filesize {
font-size: 12px;
color: #909399;
}
.file-actions {
display: flex;
align-items: center;
gap: 8px;
}
.file-actions .success {
color: #67c23a;
}
.file-actions .error {
color: #f56c6c;
}
.file-actions .remove {
cursor: pointer;
color: #909399;
}
.file-actions .remove:hover {
color: #f56c6c;
}
.upload-actions {
margin-top: 16px;
text-align: center;
}
</style>九、请求取消 #
9.1 AbortController 方式(推荐) #
// utils/request.ts(续)
class Request {
// 带取消功能的请求
async requestWithCancel<T>(
url: string,
config: AxiosRequestConfig & RequestOptions = {}
): Promise<{ data: Promise<T>; cancel: () => void }> {
const controller = new AbortController();
const dataPromise = this.request<T>('GET', url, {
...config,
signal: controller.signal,
});
return {
data: dataPromise,
cancel: () => controller.abort(),
};
}
}
// 使用示例
async function fetchData() {
const { data, cancel } = await request.requestWithCancel('/api/data');
// 取消请求
// cancel();
return data;
}9.2 CancelToken 方式(兼容旧版本) #
// utils/cancel.ts
import axios, { CancelTokenSource } from 'axios';
// 请求取消管理器
class RequestCanceler {
private pendingRequests: Map<string, CancelTokenSource> = new Map();
// 添加请求
addRequest(config: any) {
const requestKey = this.getRequestKey(config);
// 如果存在相同请求,先取消
this.cancelRequest(requestKey);
// 创建新的 CancelToken
const source = axios.CancelToken.source();
config.cancelToken = source.token;
this.pendingRequests.set(requestKey, source);
}
// 移除请求
removeRequest(config: any) {
const requestKey = this.getRequestKey(config);
this.pendingRequests.delete(requestKey);
}
// 取消请求
cancelRequest(requestKey: string, message?: string) {
const source = this.pendingRequests.get(requestKey);
if (source) {
source.cancel(message || '请求已取消');
this.pendingRequests.delete(requestKey);
}
}
// 取消所有请求
cancelAllRequests(message?: string) {
this.pendingRequests.forEach((source, key) => {
source.cancel(message || '请求已取消');
});
this.pendingRequests.clear();
}
// 生成请求唯一标识
private getRequestKey(config: any): string {
const { method, url } = config;
return `${method?.toUpperCase()}_${url}`;
}
}
export const requestCanceler = new RequestCanceler();9.3 Vue3 Composable #
// composables/useRequest.ts
import { ref, onUnmounted } from 'vue';
import axios, { CancelTokenSource } from 'axios';
export function useRequest<T>() {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const loading = ref(false);
let cancelTokenSource: CancelTokenSource | null = null;
// 执行请求
const execute = async (requestFn: (cancelToken: any) => Promise<T>) => {
// 取消之前的请求
cancel();
loading.value = true;
error.value = null;
// 创建新的 CancelToken
cancelTokenSource = axios.CancelToken.source();
try {
data.value = await requestFn(cancelTokenSource.token);
} catch (err) {
if (!axios.isCancel(err)) {
error.value = err as Error;
}
} finally {
loading.value = false;
}
};
// 取消请求
const cancel = (message?: string) => {
if (cancelTokenSource) {
cancelTokenSource.cancel(message || '请求已取消');
cancelTokenSource = null;
}
};
// 重置状态
const reset = () => {
data.value = null;
error.value = null;
loading.value = false;
cancel();
};
// 组件卸载时取消请求
onUnmounted(() => {
cancel('组件卸载,取消请求');
});
return {
data,
error,
loading,
execute,
cancel,
reset,
};
}
// 使用示例
function useUserList() {
const { data, loading, error, execute } = useRequest<User[]>();
const fetchUsers = (params: any) => {
return execute((cancelToken) =>
axios.get('/api/users', { params, cancelToken })
);
};
return { users: data, loading, error, fetchUsers };
}十、API 模块化管理 #
10.1 目录结构 #
src/
├── api/
│ ├── index.ts # 统一导出
│ ├── request.ts # Axios 封装
│ ├── types.ts # 类型定义
│ ├── modules/ # 按模块分 API
│ │ ├── user.ts # 用户相关
│ │ ├── product.ts # 商品相关
│ │ ├── order.ts # 订单相关
│ │ └── upload.ts # 上传相关
│ └── interceptors/ # 拦截器
│ ├── request.ts
│ └── response.ts10.2 模块化 API 定义 #
// api/types.ts
// 用户相关类型
export interface User {
id: string;
name: string;
email: string;
avatar: string;
role: string;
}
export interface LoginParams {
email: string;
password: string;
}
export interface LoginResult {
token: string;
refreshToken: string;
user: User;
}
export interface RegisterParams {
name: string;
email: string;
password: string;
}
// 商品相关类型
export interface Product {
id: string;
name: string;
price: number;
description: string;
images: string[];
stock: number;
}
export interface ProductQuery {
keyword?: string;
category?: string;
minPrice?: number;
maxPrice?: number;
page?: number;
pageSize?: number;
}
// 订单相关类型
export interface Order {
id: string;
userId: string;
products: OrderProduct[];
totalAmount: number;
status: 'pending' | 'paid' | 'shipped' | 'completed' | 'cancelled';
createdAt: string;
}
export interface OrderProduct {
productId: string;
name: string;
price: number;
quantity: number;
}
export interface CreateOrderParams {
products: { productId: string; quantity: number }[];
address: string;
}// api/modules/user.ts
import { request } from '../request';
import type { User, LoginParams, LoginResult, RegisterParams } from '../types';
export const userApi = {
// 登录
login(params: LoginParams) {
return request.post<LoginResult>('/auth/login', params);
},
// 注册
register(params: RegisterParams) {
return request.post<User>('/auth/register', params);
},
// 获取当前用户信息
getCurrentUser() {
return request.get<User>('/user/current');
},
// 更新用户信息
updateUser(params: Partial<User>) {
return request.put<User>('/user/profile', params);
},
// 修改密码
changePassword(params: { oldPassword: string; newPassword: string }) {
return request.post<void>('/user/change-password', params);
},
// 退出登录
logout() {
return request.post<void>('/auth/logout');
},
// 刷新 Token
refreshToken(refreshToken: string) {
return request.post<{ token: string }>('/auth/refresh', { refreshToken });
},
};// api/modules/product.ts
import { request } from '../request';
import type { Product, ProductQuery, PageResponse } from '../types';
export const productApi = {
// 获取商品列表
getProducts(params: ProductQuery) {
return request.get<PageResponse<Product>>('/products', params);
},
// 获取商品详情
getProduct(id: string) {
return request.get<Product>(`/products/${id}`);
},
// 搜索商品
searchProducts(keyword: string, params?: ProductQuery) {
return request.get<PageResponse<Product>>('/products/search', {
keyword,
...params,
});
},
// 获取推荐商品
getRecommendProducts(limit: number = 10) {
return request.get<Product[]>('/products/recommend', { limit });
},
};// api/modules/order.ts
import { request } from '../request';
import type { Order, CreateOrderParams, PageResponse } from '../types';
export const orderApi = {
// 获取订单列表
getOrders(params: { status?: string; page?: number; pageSize?: number }) {
return request.get<PageResponse<Order>>('/orders', params);
},
// 获取订单详情
getOrder(id: string) {
return request.get<Order>(`/orders/${id}`);
},
// 创建订单
createOrder(params: CreateOrderParams) {
return request.post<Order>('/orders', params, {
showSuccess: true,
successMessage: '订单创建成功',
});
},
// 取消订单
cancelOrder(id: string) {
return request.post<void>(`/orders/${id}/cancel`);
},
// 支付订单
payOrder(id: string, paymentMethod: string) {
return request.post<{ paymentUrl: string }>(`/orders/${id}/pay`, {
paymentMethod,
});
},
};// api/index.ts
export * from './types';
export * from './modules/user';
export * from './modules/product';
export * from './modules/order';
// 统一导出所有 API
export const api = {
user: userApi,
product: productApi,
order: orderApi,
};
export default api;10.3 在组件中使用 #
// 在 Vue 组件中使用
import { userApi, productApi, orderApi } from '@/api';
// 登录
async function handleLogin() {
try {
const result = await userApi.login({
email: 'user@example.com',
password: 'password123',
});
// 保存登录信息
auth.login(result.token, result.refreshToken, result.user);
} catch (error) {
console.error('登录失败:', error);
}
}
// 获取商品列表
async function fetchProducts() {
const { list, total } = await productApi.getProducts({
page: 1,
pageSize: 20,
});
products.value = list;
total.value = total;
}
// 创建订单
async function createOrder() {
await orderApi.createOrder({
products: [{ productId: '123', quantity: 1 }],
address: '北京市朝阳区',
});
}十一、Vue3 Composable 封装 #
11.1 useRequest 通用封装 #
// composables/useRequest.ts
import { ref, Ref, onUnmounted } from 'vue';
import type { ApiResponse, RequestOptions } from '@/types/api';
interface UseRequestOptions<T> extends RequestOptions {
immediate?: boolean; // 是否立即执行
initialData?: T; // 初始数据
onSuccess?: (data: T) => void; // 成功回调
onError?: (error: Error) => void; // 错误回调
onFinally?: () => void; // 完成回调
}
interface UseRequestReturn<T> {
data: Ref<T | null>;
error: Ref<Error | null>;
loading: Ref<boolean>;
execute: (...args: any[]) => Promise<T | null>;
reset: () => void;
}
export function useRequest<T = any>(
service: (...args: any[]) => Promise<T>,
options: UseRequestOptions<T> = {}
): UseRequestReturn<T> {
const {
immediate = false,
initialData,
onSuccess,
onError,
onFinally,
...requestOptions
} = options;
const data = ref<T | null>(initialData ?? null) as Ref<T | null>;
const error = ref<Error | null>(null);
const loading = ref(false);
const execute = async (...args: any[]): Promise<T | null> => {
loading.value = true;
error.value = null;
try {
const result = await service(...args);
data.value = result;
onSuccess?.(result);
return result;
} catch (err) {
const e = err as Error;
error.value = e;
onError?.(e);
return null;
} finally {
loading.value = false;
onFinally?.();
}
};
const reset = () => {
data.value = initialData ?? null;
error.value = null;
loading.value = false;
};
// 立即执行
if (immediate) {
execute();
}
return {
data,
error,
loading,
execute,
reset,
};
}11.2 usePagination 分页封装 #
// composables/usePagination.ts
import { ref, computed, watch, Ref } from 'vue';
interface PaginationOptions<T, P = any> {
service: (params: P & { page: number; pageSize: number }) => Promise<{
list: T[];
total: number;
}>;
defaultParams?: P;
defaultPageSize?: number;
immediate?: boolean;
}
export function usePagination<T, P = any>(
options: PaginationOptions<T, P>
) {
const {
service,
defaultParams = {} as P,
defaultPageSize = 10,
immediate = true,
} = options;
const list = ref<T[]>([]) as Ref<T[]>;
const loading = ref(false);
const error = ref<Error | null>(null);
const page = ref(1);
const pageSize = ref(defaultPageSize);
const total = ref(0);
const params = ref<P>({ ...defaultParams } as P);
// 是否有更多数据
const hasMore = computed(() => {
return list.value.length < total.value;
});
// 总页数
const totalPages = computed(() => {
return Math.ceil(total.value / pageSize.value);
});
// 加载数据
const loadData = async () => {
loading.value = true;
error.value = null;
try {
const result = await service({
...params.value,
page: page.value,
pageSize: pageSize.value,
});
list.value = result.list;
total.value = result.total;
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
};
// 刷新(重置到第一页)
const refresh = async () => {
page.value = 1;
await loadData();
};
// 加载下一页
const loadMore = async () => {
if (!hasMore.value || loading.value) return;
page.value++;
loading.value = true;
try {
const result = await service({
...params.value,
page: page.value,
pageSize: pageSize.value,
});
list.value.push(...result.list);
} catch (e) {
error.value = e as Error;
page.value--; // 回退页码
} finally {
loading.value = false;
}
};
// 搜索(重置到第一页)
const search = async (newParams: Partial<P>) => {
params.value = { ...defaultParams, ...newParams } as P;
page.value = 1;
await loadData();
};
// 改变每页数量
const changePageSize = async (size: number) => {
pageSize.value = size;
page.value = 1;
await loadData();
};
// 立即加载
if (immediate) {
loadData();
}
return {
list,
loading,
error,
page,
pageSize,
total,
hasMore,
totalPages,
loadData,
refresh,
loadMore,
search,
changePageSize,
};
}11.3 使用示例 #
<!-- UserList.vue -->
<template>
<div class="user-list">
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="搜索用户" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格 -->
<el-table :data="list" v-loading="loading">
<el-table-column prop="name" label="姓名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="role" label="角色" />
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="loadData"
@size-change="changePageSize"
/>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { usePagination } from '@/composables/usePagination';
import { userApi } from '@/api';
const searchForm = reactive({
keyword: '',
});
const {
list,
loading,
page,
pageSize,
total,
loadData,
search,
changePageSize,
} = usePagination({
service: (params) => userApi.getUsers(params),
defaultPageSize: 10,
});
function handleSearch() {
search(searchForm);
}
function handleReset() {
searchForm.keyword = '';
search({});
}
</script>十二、实战场景 #
场景 1:登录注册流程 #
<!-- Login.vue -->
<template>
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleSubmit">
<el-form-item prop="email">
<el-input v-model="form.email" placeholder="邮箱" prefix-icon="Message" />
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="loading"
block
>
登录
</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import { userApi } from '@/api';
import { auth } from '@/utils/auth';
import type { FormInstance, FormRules } from 'element-plus';
const router = useRouter();
const route = useRoute();
const formRef = ref<FormInstance>();
const loading = ref(false);
const form = reactive({
email: '',
password: '',
});
const rules: FormRules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少 6 位', trigger: 'blur' },
],
};
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
const result = await userApi.login({
email: form.email,
password: form.password,
});
// 保存登录信息
auth.login(result.token, result.refreshToken, result.user);
ElMessage.success('登录成功');
// 跳转到目标页面或首页
const redirect = (route.query.redirect as string) || '/';
router.push(redirect);
} catch (error: any) {
ElMessage.error(error.message || '登录失败');
} finally {
loading.value = false;
}
}
</script>场景 2:数据列表 + 搜索 + 分页 #
<!-- ProductList.vue -->
<template>
<div class="product-list">
<!-- 搜索区 -->
<div class="search-bar">
<el-input
v-model="searchParams.keyword"
placeholder="搜索商品"
clearable
@keyup.enter="handleSearch"
/>
<el-select v-model="searchParams.category" placeholder="分类" clearable>
<el-option
v-for="cat in categories"
:key="cat.value"
:label="cat.label"
:value="cat.value"
/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 商品列表 -->
<div class="products" v-loading="loading">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
@click="viewDetail(product.id)"
/>
</div>
<!-- 无数据 -->
<el-empty v-if="!loading && !products.length" description="暂无商品" />
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[12, 24, 48]"
layout="total, sizes, prev, pager, next"
@current-change="fetchProducts"
@size-change="handlePageSizeChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { productApi } from '@/api';
import type { Product } from '@/api/types';
import ProductCard from './ProductCard.vue';
const router = useRouter();
const loading = ref(false);
const products = ref<Product[]>([]);
const categories = ref([
{ label: '电子产品', value: 'electronics' },
{ label: '服装', value: 'clothing' },
{ label: '食品', value: 'food' },
]);
const searchParams = reactive({
keyword: '',
category: '',
});
const pagination = reactive({
page: 1,
pageSize: 12,
total: 0,
});
// 获取商品列表
async function fetchProducts() {
loading.value = true;
try {
const result = await productApi.getProducts({
keyword: searchParams.keyword || undefined,
category: searchParams.category || undefined,
page: pagination.page,
pageSize: pagination.pageSize,
});
products.value = result.list;
pagination.total = result.total;
} catch (error) {
console.error('获取商品列表失败:', error);
} finally {
loading.value = false;
}
}
// 搜索
function handleSearch() {
pagination.page = 1;
fetchProducts();
}
// 重置
function handleReset() {
searchParams.keyword = '';
searchParams.category = '';
pagination.page = 1;
fetchProducts();
}
// 改变每页数量
function handlePageSizeChange() {
pagination.page = 1;
fetchProducts();
}
// 查看详情
function viewDetail(id: string) {
router.push(`/products/${id}`);
}
onMounted(() => {
fetchProducts();
});
</script>场景 3:表单提交 #
<!-- UserForm.vue -->
<template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
@submit.prevent="handleSubmit"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="头像" prop="avatar">
<AvatarUpload v-model="form.avatar" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" native-type="submit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
<el-button @click="handleCancel">取消</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import { userApi } from '@/api';
import type { FormInstance, FormRules } from 'element-plus';
const router = useRouter();
const route = useRoute();
const formRef = ref<FormInstance>();
const loading = ref(false);
const userId = computed(() => route.params.id as string);
const isEdit = computed(() => !!userId.value);
const form = reactive({
name: '',
email: '',
phone: '',
avatar: '',
});
const rules: FormRules = {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名长度在 2-20 个字符', trigger: 'blur' },
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' },
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
};
// 获取用户详情(编辑模式)
async function fetchUserDetail() {
if (!userId.value) return;
try {
const user = await userApi.getUser(userId.value);
form.name = user.name;
form.email = user.email;
form.phone = user.phone;
form.avatar = user.avatar;
} catch (error) {
ElMessage.error('获取用户信息失败');
router.back();
}
}
// 提交表单
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
if (isEdit.value) {
await userApi.updateUser(userId.value, form);
ElMessage.success('更新成功');
} else {
await userApi.createUser(form);
ElMessage.success('创建成功');
}
router.push('/users');
} catch (error: any) {
ElMessage.error(error.message || '操作失败');
} finally {
loading.value = false;
}
}
// 取消
function handleCancel() {
router.back();
}
onMounted(() => {
if (isEdit.value) {
fetchUserDetail();
}
});
</script>十三、常见问题 #
Q1:如何处理跨域问题? #
开发环境: 配置 Vite/Webpack 代理
// vite.config.ts
export default {
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
};生产环境: 后端配置 CORS 或使用 Nginx 反向代理
Q2:如何取消重复请求? #
// 在请求拦截器中判断
const pendingRequests = new Map();
instance.interceptors.request.use((config) => {
const key = `${config.method}_${config.url}`;
// 如果存在相同请求,取消之前的
if (pendingRequests.has(key)) {
const cancel = pendingRequests.get(key);
cancel('取消重复请求');
}
// 设置新的取消令牌
config.cancelToken = new axios.CancelToken((cancel) => {
pendingRequests.set(key, cancel);
});
return config;
});
instance.interceptors.response.use(
(response) => {
const key = `${response.config.method}_${response.config.url}`;
pendingRequests.delete(key);
return response;
},
(error) => {
if (axios.isCancel(error)) {
console.log('请求被取消:', error.message);
}
return Promise.reject(error);
}
);Q3:如何实现请求缓存? #
// 简单缓存实现
const cache = new Map<string, { data: any; expire: number }>();
async function requestWithCache<T>(
key: string,
requestFn: () => Promise<T>,
ttl: number = 5 * 60 * 1000 // 5 分钟
): Promise<T> {
// 检查缓存
const cached = cache.get(key);
if (cached && cached.expire > Date.now()) {
return cached.data;
}
// 发送请求
const data = await requestFn();
// 存入缓存
cache.set(key, {
data,
expire: Date.now() + ttl,
});
return data;
}
// 使用
const users = await requestWithCache(
'users_list',
() => userApi.getUsers({ page: 1 }),
10 * 60 * 1000 // 缓存 10 分钟
);Q4:如何处理大文件上传? #
// 分片上传
async function uploadLargeFile(
url: string,
file: File,
chunkSize: number = 5 * 1024 * 1024 // 5MB
) {
const totalChunks = Math.ceil(file.size / chunkSize);
const fileId = `${Date.now()}_${file.name}`;
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileId', fileId);
formData.append('chunkIndex', String(i));
formData.append('totalChunks', String(totalChunks));
formData.append('fileName', file.name);
await axios.post(url, formData);
}
// 合并分片
await axios.post(`${url}/merge`, {
fileId,
fileName: file.name,
totalChunks,
});
}Q5:如何处理并发请求限制? #
// 并发限制
async function limitConcurrency<T>(
tasks: (() => Promise<T>)[],
limit: number
): Promise<T[]> {
const results: T[] = [];
let currentIndex = 0;
async function runTask() {
while (currentIndex < tasks.length) {
const index = currentIndex++;
results[index] = await tasks[index]();
}
}
const workers = Array(Math.min(limit, tasks.length))
.fill(null)
.map(() => runTask());
await Promise.all(workers);
return results;
}
// 使用:最多同时 3 个请求
const results = await limitConcurrency(
urls.map(url => () => axios.get(url)),
3
);Q6:如何实现请求超时自动重试? #
// 在 Axios 配置中添加
const instance = axios.create({
timeout: 10000,
});
// 自定义超时重试
instance.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
// 设置重试次数
config.__retryCount = config.__retryCount || 0;
// 检查是否应该重试
if (
config.__retryCount >= 3 ||
error.code !== 'ECONNABORTED'
) {
return Promise.reject(error);
}
config.__retryCount++;
// 延迟重试
await new Promise(resolve => setTimeout(resolve, 1000 * config.__retryCount));
return instance(config);
}
);Q7:如何在请求头中添加签名? #
// 生成签名
function generateSign(params: any, secret: string): string {
const sortedParams = Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
return CryptoJS.HmacSHA256(sortedParams, secret).toString();
}
// 请求拦截器添加签名
instance.interceptors.request.use((config) => {
const timestamp = Date.now();
const nonce = Math.random().toString(36).substr(2);
const sign = generateSign(
{
...config.params,
...config.data,
timestamp,
nonce,
},
SECRET_KEY
);
config.headers['X-Timestamp'] = timestamp;
config.headers['X-Nonce'] = nonce;
config.headers['X-Sign'] = sign;
return config;
});十四、总结速记 #
封装核心功能 #
| 功能 | 实现方式 |
|---|---|
| 基础配置 | baseURL、timeout、headers |
| 请求拦截 | Token 注入、签名、请求 ID |
| 响应拦截 | 数据格式化、错误处理、Token 刷新 |
| 错误重试 | 指数退避、条件判断 |
| 全局 Loading | 计数器、防闪烁 |
| 请求取消 | AbortController、CancelToken |
| 文件上传 | FormData、进度回调 |
| 文件下载 | Blob、URL.createObjectURL |
| API 模块化 | 按业务分文件、TypeScript 类型 |
常用配置速查 #
// 创建实例
const instance = axios.create({
baseURL: '/api',
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
});
// 请求拦截
instance.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 响应拦截
instance.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// 跳转登录
}
return Promise.reject(error);
}
);错误处理速查 #
| 错误类型 | 处理方式 |
|---|---|
| 401 | 清除 Token,跳转登录 |
| 403 | 提示无权限 |
| 404 | 提示资源不存在 |
| 500 | 提示服务器错误 |
| 网络错误 | 提示检查网络,可重试 |
| 超时 | 提示超时,可重试 |
上传下载速查 #
// 上传
const formData = new FormData();
formData.append('file', file);
await axios.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => console.log(e.loaded / e.total),
});
// 下载
const response = await axios.get('/download', { responseType: 'blob' });
const url = URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
link.download = 'filename.ext';
link.click();Composable 速查 #
// useRequest
const { data, loading, error, execute } = useRequest(() => api.getData());
// usePagination
const { list, total, loading, search, loadMore } = usePagination({
service: (params) => api.getList(params),
});附录:完整配置示例 #
// utils/request.ts 完整版
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { ElLoading, ElMessage } from 'element-plus';
import type { ApiResponse, RequestOptions, ApiError } from '@/types/api';
import { auth } from './auth';
// 默认配置
const DEFAULT_CONFIG: AxiosRequestConfig = {
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
};
// 默认选项
const DEFAULT_OPTIONS: RequestOptions = {
showLoading: true,
showError: true,
retryTimes: 0,
retryDelay: 1000,
};
class Request {
private instance: AxiosInstance;
private loadingInstance: ReturnType<typeof ElLoading.service> | null = null;
private loadingCount = 0;
private isRefreshing = false;
private refreshSubscribers: Array<(token: string) => void> = [];
constructor(config: AxiosRequestConfig = {}) {
this.instance = axios.create({ ...DEFAULT_CONFIG, ...config });
this.setupInterceptors();
}
private setupInterceptors() {
// 请求拦截
this.instance.interceptors.request.use(
this.handleRequest.bind(this),
this.handleRequestError.bind(this)
);
// 响应拦截
this.instance.interceptors.response.use(
this.handleResponse.bind(this),
this.handleResponseError.bind(this)
);
}
// ... 所有方法实现(见上文)
// 快捷方法
get<T>(url: string, params?: any, options?: RequestOptions): Promise<T> { /* ... */ }
post<T>(url: string, data?: any, options?: RequestOptions): Promise<T> { /* ... */ }
put<T>(url: string, data?: any, options?: RequestOptions): Promise<T> { /* ... */ }
delete<T>(url: string, params?: any, options?: RequestOptions): Promise<T> { /* ... */ }
}
export const request = new Request();
export default Request;最后更新:2026-03-29
继续阅读
返回文章列表下一篇
已经是最早文章