我将封装史上最优雅的 Axios

经过一上午的反复打磨,终于写出了这个让我满意的 HTTP 客户端封装。当我把代码发给朋友时,他竟然挑出了一堆问题!还好我都一一解决了。

🎯 为什么要重新封装 Axios?

我们都知道 Axios 是 JavaScript 世界里最受欢迎的 HTTP 客户端,但在实际项目中,我们总是需要:

  • 🔄 统一的错误处理 - 不想在每个请求里写重复的 try-catch
  • 🚫 请求取消机制 - 用户快速切换页面时取消无用请求
  • 🔁 自动重试功能 - 网络不稳定时自动重试
  • 🎨 TypeScript 完美支持 - 类型安全,IDE 智能提示
  • 🏗️ 多实例管理 - 不同 API 服务需要不同配置

市面上的封装要么太简单,要么太复杂。所以我决定写一个既优雅又实用的版本。

🎨 设计理念

这个封装的设计遵循几个核心原则:

  1. 🎯 渐进式增强 - 可以像原生 Axios 一样简单使用,也可以启用高级功能
  2. 🔒 类型安全 - 完整的 TypeScript 支持,编译时发现问题
  3. 🧩 灵活扩展 - 支持多实例、自定义拦截器、业务定制
  4. ⚡ 性能优先 - 自动去重、智能重试、内存管理
  5. 📖 文档友好 - 丰富的示例和注释,上手即用

📜 完整源码

typescript 复制代码
import Axios, { type AxiosInstance, type AxiosRequestConfig, type CustomParamsSerializer, type AxiosResponse, type InternalAxiosRequestConfig, type Method, type AxiosError } from "axios";
import { stringify } from "qs";

// 基础配置
const defaultConfig: AxiosRequestConfig = {
  timeout: 6000,
  headers: {
    "Content-Type": "application/json;charset=utf-8"
  },
  paramsSerializer: {
    serialize: stringify as unknown as CustomParamsSerializer
  }
};

// 响应数据基础结构
export interface BaseResponse {
  code: number;
  message?: string;
}

// 去除与BaseResponse冲突的字段
type OmitBaseResponse<T> = Omit<T, keyof BaseResponse>;

// 响应数据类型定义 - 避免属性冲突,确保BaseResponse优先级
export type ResponseData<T = any> = BaseResponse & OmitBaseResponse<T>;

// 响应数据验证函数类型
export type ResponseValidator<T = any> = (data: ResponseData<T>) => boolean;

// 重试配置
export interface RetryConfig {
  retries?: number; // 重试次数
  retryDelay?: number; // 重试延迟(毫秒)
  retryCondition?: (error: AxiosError) => boolean; // 重试条件
}

// 拦截器配置类型
interface InterceptorsConfig {
  requestInterceptor?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig;
  requestErrorInterceptor?: (error: AxiosError) => Promise<any>;
  responseInterceptor?: (response: AxiosResponse<ResponseData<any>>) => any;
  responseErrorInterceptor?: (error: AxiosError) => Promise<any>;
}

// 请求唯一键
type RequestKey = string | symbol;

/**
 * 增强型 HTTP 客户端,基于 Axios 封装
 * 支持拦截器配置、请求取消、多实例管理等功能
 */
class HttpClient {
  private instance: AxiosInstance;
  private requestInterceptorId?: number;
  private responseInterceptorId?: number;
  private abortControllers: Map<RequestKey, AbortController> = new Map();

  /**
   * 创建 HTTP 客户端实例
   * @param customConfig 自定义 Axios 配置
   * @param interceptors 自定义拦截器配置
   */
  constructor(customConfig?: AxiosRequestConfig, interceptors?: InterceptorsConfig) {
    this.instance = Axios.create({ ...defaultConfig, ...customConfig });
    this.initInterceptors(interceptors);
  }

  /** 初始化拦截器 */
  private initInterceptors(interceptors?: InterceptorsConfig): void {
    this.initRequestInterceptor(interceptors?.requestInterceptor, interceptors?.requestErrorInterceptor);
    this.initResponseInterceptor(interceptors?.responseInterceptor, interceptors?.responseErrorInterceptor);
  }

  /** 初始化请求拦截器 */
  private initRequestInterceptor(customInterceptor?: InterceptorsConfig["requestInterceptor"], customErrorInterceptor?: InterceptorsConfig["requestErrorInterceptor"]): void {
    // 默认请求拦截器
    const defaultInterceptor = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
      /* 在这里写请求拦截器的默认业务逻辑 */
      // 示例: 添加token
      // const token = localStorage.getItem('token');
      // if (token) {
      //   config.headers.Authorization = `Bearer ${token}`;
      // }

      // 示例: 添加时间戳防止缓存
      // if (config.method?.toUpperCase() === 'GET') {
      //   config.params = { ...config.params, _t: Date.now() };
      // }

      return config;
    };

    // 默认请求错误拦截器
    const defaultErrorInterceptor = (error: AxiosError): Promise<any> => {
      /* 在这里写请求错误拦截器的默认业务逻辑 */
      // 示例: 处理请求前的错误
      // console.error('请求配置错误:', error);

      return Promise.reject(error);
    };

    // 优先使用自定义拦截器,否则使用默认拦截器
    this.requestInterceptorId = this.instance.interceptors.request.use(customInterceptor || defaultInterceptor, customErrorInterceptor || defaultErrorInterceptor);
  }

  /** 初始化响应拦截器 */
  private initResponseInterceptor(customInterceptor?: InterceptorsConfig["responseInterceptor"], customErrorInterceptor?: InterceptorsConfig["responseErrorInterceptor"]): void {
    // 默认响应拦截器
    const defaultInterceptor = (response: AxiosResponse<ResponseData<any>>) => {
      const requestKey = this.getRequestKey(response.config);
      if (requestKey) this.abortControllers.delete(requestKey);

      /* 在这里写响应拦截器的默认业务逻辑 */
      // 示例: 处理不同的响应码
      // const { code, message } = response.data;
      // switch(code) {
      //   case 200:
      //     return response.data;
      //   case 401:
      //     // 未授权处理
      //     break;
      //   case 403:
      //     // 权限不足处理
      //     break;
      //   default:
      //     // 其他错误处理
      //     console.error('请求错误:', message);
      // }

      return response.data;
    };

    // 默认响应错误拦截器
    const defaultErrorInterceptor = (error: AxiosError): Promise<any> => {
      if (error.config) {
        const requestKey = this.getRequestKey(error.config);
        if (requestKey) this.abortControllers.delete(requestKey);
      }

      // 处理请求被取消的情况
      if (Axios.isCancel(error)) {
        console.warn("请求已被取消:", error.message);
        return Promise.reject(new Error("请求已被取消"));
      }

      // 网络错误处理
      if (!(error as AxiosError).response) {
        if ((error as any).code === "ECONNABORTED" || (error as AxiosError).message?.includes("timeout")) {
          return Promise.reject(new Error("请求超时,请稍后重试"));
        }
        return Promise.reject(new Error("网络错误,请检查网络连接"));
      }

      // HTTP状态码错误处理
      const status = (error as AxiosError).response?.status;
      const commonErrors: Record<number, string> = {
        400: "请求参数错误",
        401: "未授权,请重新登录",
        403: "权限不足",
        404: "请求的资源不存在",
        408: "请求超时",
        500: "服务器内部错误",
        502: "网关错误",
        503: "服务暂不可用",
        504: "网关超时"
      };

      const message = commonErrors[status] || `请求失败(状态码:${status})`;
      return Promise.reject(new Error(message));
    };

    // 优先使用自定义拦截器,否则使用默认拦截器
    this.responseInterceptorId = this.instance.interceptors.response.use(customInterceptor || defaultInterceptor, customErrorInterceptor || defaultErrorInterceptor);
  }

  /** 生成请求唯一标识 */
  private getRequestKey(config: AxiosRequestConfig): RequestKey | undefined {
    if (!config.url) return undefined;
    return `${config.method?.toUpperCase()}-${config.url}`;
  }

  /** 设置取消控制器 - 用于取消重复请求或主动取消请求 */
  private setupCancelController(config: AxiosRequestConfig, requestKey?: RequestKey): AxiosRequestConfig {
    const key = requestKey || this.getRequestKey(config);
    if (!key) return config;

    // 如果已有相同key的请求,先取消它
    this.cancelRequest(key);

    const controller = new AbortController();
    this.abortControllers.set(key, controller);

    return {
      ...config,
      signal: controller.signal
    };
  }

  /** 移除请求拦截器 */
  public removeRequestInterceptor(): void {
    if (this.requestInterceptorId !== undefined) {
      this.instance.interceptors.request.eject(this.requestInterceptorId);
      this.requestInterceptorId = undefined; // 重置ID,避免重复移除
    }
  }

  /** 移除响应拦截器 */
  public removeResponseInterceptor(): void {
    if (this.responseInterceptorId !== undefined) {
      this.instance.interceptors.response.eject(this.responseInterceptorId);
      this.responseInterceptorId = undefined; // 重置ID,避免重复移除
    }
  }

  /** 动态设置请求拦截器 */
  public setRequestInterceptor(customInterceptor?: InterceptorsConfig["requestInterceptor"], customErrorInterceptor?: InterceptorsConfig["requestErrorInterceptor"]): void {
    this.removeRequestInterceptor();
    this.initRequestInterceptor(customInterceptor, customErrorInterceptor);
  }

  /** 动态设置响应拦截器 */
  public setResponseInterceptor(customInterceptor?: InterceptorsConfig["responseInterceptor"], customErrorInterceptor?: InterceptorsConfig["responseErrorInterceptor"]): void {
    this.removeResponseInterceptor();
    this.initResponseInterceptor(customInterceptor, customErrorInterceptor);
  }

  /** 获取 Axios 实例 */
  public getInstance(): AxiosInstance {
    return this.instance;
  }

  /**
   * 取消某个请求
   * @param key 请求唯一标识
   * @param message 取消原因
   * @returns 是否成功取消
   */
  public cancelRequest(key: RequestKey, message?: string): boolean {
    const controller = this.abortControllers.get(key);
    if (controller) {
      controller.abort(message || `取消请求: ${String(key)}`);
      this.abortControllers.delete(key);
      return true;
    }
    return false;
  }

  /**
   * 取消所有请求
   * @param message 取消原因
   */
  public cancelAllRequests(message?: string): void {
    this.abortControllers.forEach((controller, key) => {
      controller.abort(message || `取消所有请求: ${String(key)}`);
    });
    this.abortControllers.clear();
  }

  /**
   * 判断是否为取消错误
   * @param error 错误对象
   * @returns 是否为取消错误
   */
  public static isCancel(error: unknown): boolean {
    return Axios.isCancel(error);
  }

  /**
   * 睡眠函数
   * @param ms 毫秒数
   */
  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  /**
   * 通用请求方法
   * @param method 请求方法
   * @param url 请求地址
   * @param config 请求配置
   * @returns 响应数据
   */
  public async request<T = any>(method: Method, url: string, config?: AxiosRequestConfig & { requestKey?: RequestKey; retry?: RetryConfig }): Promise<ResponseData<T>> {
    const { requestKey, retry, ...restConfig } = config || {};

    // 设置合理的默认重试条件
    const defaultRetryCondition = (error: AxiosError) => {
      // 默认只重试网络错误或5xx服务器错误
      return !error.response || (error.response.status >= 500 && error.response.status < 600);
    };

    const retryConfig = {
      retries: 0,
      retryDelay: 1000,
      retryCondition: defaultRetryCondition,
      ...retry
    };

    let lastError: any;
    const key = requestKey || this.getRequestKey({ ...restConfig, method, url });

    for (let attempt = 0; attempt <= retryConfig.retries; attempt++) {
      try {
        // 重试前清除旧的AbortController(避免重试请求被误取消)
        if (attempt > 0 && key) {
          this.abortControllers.delete(key);
        }

        const requestConfig = this.setupCancelController({ ...restConfig, method, url }, requestKey);

        /* 在这里写通用请求前的业务逻辑 */
        // 示例: 记录请求日志
        // console.log(`[${method.toUpperCase()}] ${url}:`, restConfig);

        const response = await this.instance.request<ResponseData<T>>(requestConfig);

        /* 在这里写通用请求后的业务逻辑 */
        // 示例: 记录响应日志
        // console.log(`[${method.toUpperCase()}] ${url} 响应:`, response.data);

        return response.data;
      } catch (error) {
        lastError = error;

        // 如果是最后一次尝试或不满足重试条件或请求被取消,直接抛出错误
        if (attempt === retryConfig.retries || !retryConfig.retryCondition(error as AxiosError) || HttpClient.isCancel(error)) {
          break;
        }

        // 延迟后重试
        if (retryConfig.retryDelay > 0) {
          await this.sleep(retryConfig.retryDelay);
        }
      }
    }

    /* 在这里写请求异常的通用处理逻辑 */
    // 示例: 统一错误提示
    // if (lastError instanceof Error) {
    //   console.error('请求失败:', lastError.message);
    // }

    return Promise.reject(lastError);
  }

  /**
   * GET 请求
   * @param url 请求地址
   * @param config 请求配置
   * @returns 响应数据
   */
  public get<T = any>(url: string, config?: AxiosRequestConfig & { requestKey?: RequestKey; retry?: RetryConfig }): Promise<ResponseData<T>> {
    return this.request<T>("get", url, config);
  }

  /**
   * POST 请求
   * @param url 请求地址
   * @param data 请求数据
   * @param config 请求配置
   * @returns 响应数据
   */
  public post<T = any>(url: string, data?: any, config?: AxiosRequestConfig & { requestKey?: RequestKey; retry?: RetryConfig }): Promise<ResponseData<T>> {
    return this.request<T>("post", url, { ...config, data });
  }

  /**
   * PUT 请求
   * @param url 请求地址
   * @param data 请求数据
   * @param config 请求配置
   * @returns 响应数据
   */
  public put<T = any>(url: string, data?: any, config?: AxiosRequestConfig & { requestKey?: RequestKey; retry?: RetryConfig }): Promise<ResponseData<T>> {
    return this.request<T>("put", url, { ...config, data });
  }

  /**
   * DELETE 请求
   * @param url 请求地址
   * @param config 请求配置
   * @returns 响应数据
   */
  public delete<T = any>(url: string, config?: AxiosRequestConfig & { requestKey?: RequestKey; retry?: RetryConfig }): Promise<ResponseData<T>> {
    return this.request<T>("delete", url, config);
  }

  /**
   * PATCH 请求
   * @param url 请求地址
   * @param data 请求数据
   * @param config 请求配置
   * @returns 响应数据
   */
  public patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig & { requestKey?: RequestKey; retry?: RetryConfig }): Promise<ResponseData<T>> {
    return this.request<T>("patch", url, { ...config, data });
  }
}

// 默认导出实例 - 可直接使用
export const http = new HttpClient();

export default HttpClient;

🛠️ 使用指南

1. 基础请求操作:GET/POST 等常用方法

任何 HTTP 客户端最核心的功能都是处理基础请求,HttpClient 对常用的 HTTP 方法进行了友好封装,同时提供了完整的 TypeScript 类型支持。

首先定义我们需要用到的数据类型:

typescript 复制代码
// 用户信息接口
interface User {
  id: number;
  name: string;
  email: string;
}

// 分页响应通用接口
interface PageResponse<T> {
  list: T[];       // 数据列表
  total: number;   // 总条数
  page: number;    // 当前页码
  size: number;    // 每页条数
}

基础请求操作示例:

typescript 复制代码
async function basicRequests() {
  try {
    // 1. GET 请求(带查询参数)
    const userPage = await http.get<PageResponse<User>>('/api/users', {
      params: { page: 1, size: 10 }
    });
    console.log(`第${userPage.page}页,共${userPage.total}个用户:`, userPage.list);

    // 2. POST 请求(提交数据)
    const newUser = await http.post<{ id: number }>('/api/users', {
      name: '张三',
      email: 'zhangsan@example.com'
    });
    console.log('新增用户ID:', newUser.id);

    // 3. PUT 请求(更新数据)
    const updatedUser = await http.put<User>('/api/users/1', {
      id: 1,
      name: '张三三',
      email: 'zhangsansan@example.com'
    });

    // 4. DELETE 请求
    const deleteRes = await http.delete<{ success: boolean; message?: string }>('/api/users/1');
    console.log('删除结果:', deleteRes.success);
  } catch (error) {
    // 统一错误处理
    console.error('请求失败:', error instanceof Error ? error.message : error);
  }
}

关键特性

  • 泛型参数直接指定响应数据类型,获得完整的类型提示
  • 统一的错误处理机制,无需在每个请求中重复编写 try-catch
  • 自动处理请求参数序列化和响应数据解析

2. 多实例管理:为不同 API 服务创建专属客户端

在复杂项目中,我们常常需要与多个 API 服务交互,每个服务可能有不同的基础路径、超时设置或认证方式。HttpClient 支持创建多个独立实例,完美解决这个问题。

typescript 复制代码
// 定义商品数据接口
interface Product {
  id: string;
  title: string;
  price: number;
  stock: number;
}

// 1. 创建用户服务专用实例
const userApi = new HttpClient({
  baseURL: 'https://api.example.com/user',  // 用户服务基础路径
  timeout: 10000                           // 超时设置为10秒
});

// 2. 创建商品服务专用实例(带特殊请求头)
const productApi = new HttpClient({
  baseURL: 'https://api.example.com/product',  // 商品服务基础路径
  headers: { 'X-Product-Token': 'special-token' }  // 商品服务需要的特殊头部
});

// 使用多实例处理不同服务的请求
async function useMultiInstances() {
  // 调用用户服务API
  const userPage = await userApi.get<PageResponse<User>>('/list');
  
  // 调用商品服务API
  const productPage = await productApi.get<PageResponse<Product>>('/list');
  
  // 两个实例的配置完全隔离,不会相互影响
}

适用场景

  • 前后端分离项目中对接多个微服务
  • 同时需要访问内部API和第三方API
  • 不同接口有不同的超时需求(如普通接口5秒,文件上传60秒)

3. 自定义拦截器:业务逻辑与请求处理的解耦

拦截器是处理请求/响应公共逻辑的最佳方式,HttpClient 允许在初始化时配置自定义拦截器,将认证、日志等横切关注点与业务逻辑分离。

最常见的场景是处理认证逻辑:

typescript 复制代码
// 创建带权限验证的HTTP客户端实例
const authHttp = new HttpClient(
  { baseURL: 'https://api.example.com/auth' },  // 基础配置
  {
    // 请求拦截器:添加认证Token
    requestInterceptor: (config) => {
      const token = localStorage.getItem('token');
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
    },
    
    // 响应拦截器:处理认证失败
    responseInterceptor: (response) => {
      // 未授权,自动跳转到登录页
      if (response.code === 401) {
        localStorage.removeItem('token');
        window.location.href = '/login';
      }
      return response;
    }
  }
);

拦截器的典型用途

  • 添加全局认证信息(Token、API Key等)
  • 统一处理错误码(如401未授权、403权限不足)
  • 实现请求/响应日志记录
  • 添加请求时间戳防止缓存

4. 动态修改拦截器:运行时灵活调整请求行为

有时候我们需要在运行时根据业务场景动态改变请求/响应处理逻辑,HttpClient 提供了动态修改拦截器的能力。

typescript 复制代码
// 定义日志数据接口
interface LogData {
  id: number;
  timestamp: string;
  content: string;
}

async function dynamicInterceptors() {
  // 场景1:临时添加日志拦截器
  const logInterceptor = (response: AxiosResponse<ResponseData<PageResponse<LogData>>>) => {
    console.log(`请求[${response.config.url}]返回${response.data.total}条日志`);
    return response;
  };

  // 设置新的响应拦截器
  http.setResponseInterceptor(logInterceptor);
  
  // 发送请求时会执行新的拦截器
  await http.get<PageResponse<LogData>>('/api/logs');

  // 场景2:完成日志收集后,恢复默认拦截器
  http.setResponseInterceptor();

  // 场景3:动态更新认证信息(如Token刷新后)
  const newToken = 'new-auth-token';
  http.setRequestInterceptor((config) => {
    config.headers.Authorization = `Bearer ${newToken}`;
    return config;
  });
}

实用场景

  • 临时开启调试日志
  • Token过期后动态更新认证信息
  • 特定页面需要特殊的请求头
  • A/B测试时切换不同的API处理逻辑

5. 请求取消:优化用户体验的关键技巧

在用户快速操作或页面切换时,取消无用的请求可以显著提升性能和用户体验。HttpClient 提供了多种灵活的请求取消方式。

5.1 主动取消单个请求

typescript 复制代码
async function cancelSingleRequest() {
  const requestKey = 'user-list';  // 定义唯一标识

  try {
    // 发起请求时指定requestKey
    const promise = http.get<PageResponse<User>>('/api/users', { requestKey });

    // 模拟:200ms后取消请求(例如用户快速切换了页面)
    setTimeout(() => {
      http.cancelRequest(requestKey, '数据已过时');
    }, 200);

    const result = await promise;
  } catch (error) {
    // 判断是否为取消错误
    if (HttpClient.isCancel(error)) {
      console.log('请求已取消:', error.message);
    }
  }
}

5.2 自动取消重复请求

typescript 复制代码
async function cancelDuplicate() {
  // 连续发起相同参数的请求
  http.get<PageResponse<User>>('/api/users', { params: { page: 1 } }); // 被取消
  http.get<PageResponse<User>>('/api/users', { params: { page: 1 } }); // 被取消
  const latestData = await http.get<PageResponse<User>>('/api/users', { params: { page: 1 } }); // 最终生效
}

5.3 页面卸载时取消所有请求

typescript 复制代码
// 在React/Vue等框架的组件卸载钩子中调用
function onPageUnmount() {
  http.cancelAllRequests('页面已关闭');
}

// 或者监听页面关闭事件
window.addEventListener('beforeunload', () => {
  http.cancelAllRequests('用户离开页面');
});

带来的好处

  • 减少不必要的网络请求和服务器负载
  • 避免过时数据覆盖最新数据
  • 防止页面跳转后仍弹出错误提示
  • 减少内存占用和潜在的内存泄漏

6. 文件上传:大文件处理的最佳实践

文件上传是前端开发中的常见需求,尤其需要注意超时设置和数据格式。HttpClient 可以轻松配置适合文件上传的参数。

typescript 复制代码
// 定义上传结果接口
interface UploadResult {
  url: string;      // 上传后的文件URL
  filename: string; // 文件名
  size: number;     // 文件大小
}

// 处理文件上传
async function uploadFile(file: File) {
  // 创建FormData对象
  const formData = new FormData();
  formData.append('file', file);
  
  // 可选:添加其他表单字段
  formData.append('category', 'document');
  formData.append('description', '用户上传的文档');

  // 创建上传专用实例(配置更长的超时)
  const uploadHttp = new HttpClient({
    timeout: 60000,  // 上传超时设为60秒
    headers: { 'Content-Type': 'multipart/form-data' }
  });

  try {
    const result = await uploadHttp.post<UploadResult>('/api/upload', formData);
    console.log('文件上传成功,访问地址:', result.url);
    return result.url;
  } catch (error) {
    console.error('文件上传失败:', error);
    throw error;
  }
}

上传优化建议

  • 大文件上传使用专门的实例,设置较长超时
  • 配合进度条展示上传进度(可通过 Axios 的 onUploadProgress 实现)
  • 考虑分片上传大文件(超过100MB的文件)
  • 重要文件上传可配置重试机制

7. 并发请求处理:高效获取多源数据

实际开发中经常需要同时请求多个接口,然后汇总处理数据。HttpClient 结合 Promise API 可以优雅地处理并发请求。

typescript 复制代码
// 使用Promise.all处理并发请求
async function handleConcurrentRequests() {
  try {
    // 同时发起多个请求
    const [userRes, productRes] = await Promise.all([
      http.get<User>('/api/users/1'),                // 获取用户详情
      http.get<PageResponse<Product>>('/api/products') // 获取商品列表
    ]);

    // 所有请求成功后处理数据
    console.log('用户详情:', userRes);
    console.log('商品列表:', productRes.list);
    
    // 可以在这里进行数据整合
    return {
      user: userRes,
      products: productRes.list
    };
  } catch (error) {
    // 任何一个请求失败都会进入这里
    console.error('并发请求失败:', error);
    throw error;
  }
}

并发处理技巧

  • 使用 Promise.all 处理相互依赖的并发请求(一失败全失败)
  • 使用 Promise.allSettled 处理可以独立失败的请求
  • 对大量并发请求进行分批处理,避免浏览器限制
  • 结合请求取消机制,在某个关键请求失败时取消其他请求

8. 请求重试:提升网络不稳定场景的可靠性

网络波动是前端请求失败的常见原因,HttpClient 内置的重试机制可以自动处理这类问题,提升用户体验。

8.1 基础重试配置(使用默认策略)

typescript 复制代码
// 使用默认重试条件
async function basicRetryWithDefaults() {
  try {
    const result = await http.get<User>('/api/users/1', {
      retry: {
        retries: 3,       // 最多重试3次
        retryDelay: 1000  // 每次重试间隔1秒
        // 默认策略:只重试网络错误或5xx服务器错误
      }
    });
    return result;
  } catch (error) {
    console.error('所有重试都失败了:', error);
    throw error;
  }
}

8.2 自定义重试条件

typescript 复制代码
// 自定义重试逻辑
async function customRetryCondition(userData: User) {
  try {
    const result = await http.post<User>('/api/users', userData, {
      retry: {
        retries: 2,       // 重试2次
        retryDelay: 500,  // 重试间隔500ms
        retryCondition: (error) => {
          // 自定义条件:网络错误、超时或5xx错误才重试
          return !error.response || 
                 error.response.status === 408 || 
                 (error.response.status >= 500 && error.response.status < 600);
        }
      }
    });
    return result;
  } catch (error) {
    console.error('重试失败:', error);
    throw error;
  }
}

重试策略建议

  • 读操作(GET)适合重试,写操作(POST/PUT)需谨慎
  • 重试次数不宜过多(通常2-3次),避免加重服务器负担
  • 使用指数退避策略(retryDelay 逐渐增加)
  • 对明确的客户端错误(如400、401、403)不重试

9. 访问原始 Axios 实例:兼容特殊需求

虽然 HttpClient 封装了常用功能,但某些特殊场景可能需要直接使用 Axios 原生 API。HttpClient 提供了获取原始实例的方法。

typescript 复制代码
// 获取原始Axios实例
function getOriginalAxiosInstance() {
  const axiosInstance = http.getInstance();
  
  // 示例1:使用Axios的cancelToken(旧版取消方式)
  const CancelToken = Axios.CancelToken;
  const source = CancelToken.source();
  
  axiosInstance.get('/api/special', {
    cancelToken: source.token
  });
  
  // 取消请求
  source.cancel('Operation canceled by the user.');
  
  // 示例2:使用Axios的拦截器API
  const myInterceptor = axiosInstance.interceptors.response.use(
    response => response,
    error => Promise.reject(error)
  );
  
  // 移除拦截器
  axiosInstance.interceptors.response.eject(myInterceptor);
}

适用场景

  • 使用一些 HttpClient 未封装的 Axios 特性
  • 集成依赖原始 Axios 实例的第三方库
  • 处理极特殊的请求场景
  • 平滑迁移现有基于 Axios 的代码

🤔 插曲:朋友的无情嘲笑

在我们完成本次封装前,还有一个小插曲:我得意洋洋地把这个"史上最优雅"的封装发给朋友炫耀,心想着他肯定会夸我两句。结果他发来了一大段文字...

第一轮攻击:类型定义问题

朋友 : "你这代码有点问题啊 😏 你这个 ResponseData 类型扩展性不足:"

typescript 复制代码
// 你的问题代码
export type ResponseData<T = any> = BaseResponse & T;

// 当 T 中包含 code 或 message 字段时会冲突
interface UserWithCode {
  code: string; // 与BaseResponse冲突
  name: string;
}
type TestType = ResponseData<UserWithCode>; // code字段变成never类型!

: "不可能!绝对不可能!(曹操.gif)"

然后我测试了一下,果然报错了... 😅

解决方案

typescript 复制代码
// 修复后的版本
type OmitBaseResponse<T> = Omit<T, keyof BaseResponse>;
export type ResponseData<T = any> = BaseResponse & OmitBaseResponse<T>;

第二轮攻击:请求取消逻辑缺陷

朋友 : "还有你这个 setupCancelController 未处理自定义 requestKey 冲突,当用户传入自定义 requestKey 时,若与内部生成的键重复,会导致取消逻辑混乱。"

: "这... 这应该不会吧?"

朋友: "你看,你的代码是这样的:

typescript 复制代码
private setupCancelController(config: AxiosRequestConfig, requestKey?: RequestKey) {
  const key = requestKey || this.getRequestKey(config);
  // 直接取消,但没有冲突警告
  this.cancelRequest(key);
}

如果有重复key怎么办?建议加个警告。"

: "但是我这里直接取消重复请求不是挺好的吗?这是防重复机制啊!"

朋友: "嗯...这个倒是有道理。那算了,这个问题不大。"

第三轮攻击:重试机制边界问题

朋友 : "但是你这个重试逻辑有问题!重试时未重置 AbortController:"

typescript 复制代码
// 你的问题代码
for (let attempt = 0; attempt <= retryConfig.retries; attempt++) {
  // 每次都会调用setupCancelController,创建新的controller
  // 但旧的还在Map中,可能导致重试请求被误取消
  const requestConfig = this.setupCancelController({...restConfig, method, url}, requestKey);
}

: "这... 这是边缘情况!"

朋友 : "边缘情况也是情况啊!还有你的 retryCondition 默认值缺失,当用户未配置时,会默认重试所有错误(包括400等客户端错误),不符合预期。"

: "好吧好吧,我改还不行吗... 😤"

解决方案

typescript 复制代码
// 修复后的版本
const defaultRetryCondition = (error: AxiosError) => {
  // 默认只重试网络错误或5xx服务器错误
  return !error.response || (error.response.status >= 500 && error.response.status < 600);
};

for (let attempt = 0; attempt <= retryConfig.retries; attempt++) {
  // 重试前清除旧控制器
  if (attempt > 0 && key) {
    this.abortControllers.delete(key);
  }
  const requestConfig = this.setupCancelController({...restConfig, method, url}, requestKey);
}

第四轮攻击:拦截器管理问题

朋友 : "还有你的拦截器移除逻辑不严谨,只通过 interceptorId 移除,但未重置 interceptorId,可能导致后续重复移除无效:"

typescript 复制代码
// 你的问题代码
public removeRequestInterceptor(): void {
  if (this.requestInterceptorId) {
    this.instance.interceptors.request.eject(this.requestInterceptorId);
    // 没有重置ID!
  }
}

: "这... 好吧,确实应该重置一下。"

解决方案

typescript 复制代码
// 修复后的版本
public removeRequestInterceptor(): void {
  if (this.requestInterceptorId !== undefined) {
    this.instance.interceptors.request.eject(this.requestInterceptorId);
    this.requestInterceptorId = undefined; // 重置ID
  }
}

第五轮攻击:其他细节问题

朋友: "哈哈,别急。不过你还有几个问题:

  1. Content-Type 硬编码问题 - 默认强制设置为 application/json,但上传文件时需要 multipart/form-data,需手动覆盖,不够灵活。

  2. 错误信息处理冗余 - 响应错误拦截器中对错误信息的包装会丢失原始错误的详细信息,不利于调试。

  3. requestKey 类型声明不明确 - 定义为 string | symbol,但用户传入 symbol 时,调试信息显示不友好。"

: "停停停!你这是在 code review 还是在找茬?!"

朋友: "当然是 code review 啦,不过后面这几个确实比较鸡蛋里挑骨头,前面几个确实需要修复。"

我的反击与分析

经过一番"友好"的讨论(主要是我被教育),我冷静分析了一下:

确实有价值的问题(必须修复):

  • ResponseData 类型冲突 - 很重要!确实会导致 never 类型问题
  • 重试机制的默认条件缺失 - 重要!应该有合理的默认重试条件
  • 拦截器ID重置问题 - 中等重要,确实应该重置ID
  • 重试时AbortController重置 - 中等重要,理论上存在问题

过于苛刻或设计选择问题:

  • requestKey冲突警告 - 当前设计已经通过取消旧请求处理了,警告是多余的
  • Content-Type硬编码 - 这是常见的默认设置,Axios会自动覆盖FormData
  • 错误信息包装 - 保留原始错误是好的,但当前设计也合理
  • 拦截器组合模式 - 当前的覆盖模式是主流设计,组合模式会增加复杂性

主观性问题:

  • 🤔 requestKey类型限制 - symbol支持是特性,不是缺陷

最终我不得不承认:朋友的技术功底是不错的,提出了一些确实存在的边缘问题。但有些建议过于"完美主义",可能会让代码变得过于复杂。

修复了前4个重要问题后,朋友终于点头说:"现在看起来像个正经的封装了!不过你得承认,好的代码不仅要能跑,还要经得起同行的审视。" 😂

🚀 写在最后

经过这一上午的"激情"编码 + 朋友的"无情"嘲笑 + 我的"不服气"修复,这个 HTTP 客户端封装终于变得更加健壮了。

从最初的自我感觉良好,到被朋友无情打脸,再到最后的虚心修复,这个过程让我深刻体会到:

  1. 没有完美的代码 - 总有你想不到的边缘情况
  2. Code Review 很重要 - 别人的视角能发现你的盲点
  3. 保持开放心态 - 被指出问题是好事,不是坏事
  4. 朋友很重要 - 能"嘲笑"你代码的朋友才是真朋友 😄

现在这个封装不仅解决了日常开发中的痛点,还具备了企业级项目的稳定性。

核心优势:

  • 🎯 开箱即用 - 无需复杂配置,默认就很好用
  • 🔧 高度可定制 - 支持各种业务场景的定制需求
  • 🛡️ 类型安全 - TypeScript 完美支持,减少运行时错误
  • 性能出色 - 智能去重、重试、取消机制
  • 📚 文档完善 - 详细的示例和注释

如果你也在为 HTTP 请求封装而苦恼,不妨试试这个方案。记住,好的代码是改出来的,不是写出来的


你有过类似被朋友"嘲笑"代码的经历吗?你觉得这个封装怎么样?欢迎在评论区分享你的故事! 🤗

🏷️ 标签:#Axios #TypeScript #HTTP #前端工程化 #代码封装 #程序员日常

相关推荐
养鱼的程序员几秒前
零基础搭建个人网站:从 Astro 框架到 GitHub 自动部署完全指南
前端·后端·github
罗行4 分钟前
手写防抖和节流
前端·javascript
前端老鹰4 分钟前
CSS :is () 与 :where ():简化复杂选择器的 “语法糖”
前端·css·html
颜酱8 分钟前
理解并尝试vue3源码的reactivity
前端·javascript·vue.js
拉不动的猪17 分钟前
jS篇Async await实现同步效果的原理
前端·javascript·面试
杨充21 分钟前
03.接口vs抽象类比较
前端·后端
chxii33 分钟前
2.4 组件通信
前端·javascript·vue.js
泡岩浆的child1 小时前
朋友:你平常都用什么软件取色?我:QQ截图啊。朋友:牛X!
前端
志如1 小时前
【校招面试官说】什么样的技术人更容易被大厂校招选中?
前端·后端·面试
古夕1 小时前
TS 导出 PDF:解决表头乱码,实现表格内添加可点击链接
前端·typescript