CodeL
以前端为翼,以 AI 为脑,向全栈而行
2026-03-31

Vue3 + TypeScript Axios 完全封装指南

Vue3 + TypeScript Axios 完全封装指南 从基础封装到高级特性,错误重试、全局Loading、鉴权拦截、文件下载,覆盖 Vue3 项目中所有 Axios 使用场景 目录 1. 核心概念 2. 基础封装...

Vue3 + TypeScript Axios 完全封装指南 #

从基础封装到高级特性,错误重试、全局Loading、鉴权拦截、文件下载,覆盖 Vue3 项目中所有 Axios 使用场景


目录 #

  1. 核心概念
  2. 基础封装
  3. 请求拦截器
  4. 响应拦截器
  5. 错误处理与重试
  6. 全局 Loading
  7. 鉴权处理
  8. 文件上传下载
  9. 请求取消
  10. API 模块化管理
  11. Vue3 Composable 封装
  12. 实战场景
  13. 常见问题
  14. 总结速记

一、核心概念 #

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-plus

2.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.ts

10.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

继续阅读

返回文章列表

下一篇

已经是最早文章