Skip to content

axios 封装

功能列表

  • 1、全局 Loading 控制
  • 2、自动防止重复提交
  • 3、路由跳转自动取消请求
  • 4、双 Token,无感知刷新
  • 5、全局拦截与错误处理
  • 6、快捷调用方式

全局 Loading 控制

全局 Loading 控制机制

  1. 计数器初始化
    全局维护一个请求计数器 loadingCount,初始值为 0

  2. Loading 触发机制

    • 当发起新请求时,若计数器为 0 则显示全局 Loading 动画
    • 每次请求发起时计数器自增(无论是否显示 Loading)
  3. 智能关闭机制

    • 请求完成后(无论成功或失败)计数器自减
    • 仅当计数器归零时自动关闭 Loading 动画
    • 确保所有并发请求完成前保持 Loading 状态

核心优势

  1. 精准控制
    避免多请求场景下 Loading 频繁闪烁

  2. 资源优化
    仅当首个请求触发显示,后续请求复用状态

  3. 容错保障
    异常场景也能确保计数器递减,防止"卡死"

  4. 无缝体验
    所有并发请求完成后自然关闭,无提前关闭风险

示例场景:当页面同时发起 3 个请求时,Loading 只会在首个请求时显示,并在最后一个请求完成后关闭,即使各请求响应时间不同。

自动防止重复提交

  1. 全局请求映射表
    创建 RequestMap(Map 类型)存储所有进行中的请求标记
  2. 唯一请求指纹生成
    根据请求的方式、参数,生成一个请求指纹
  3. 请求拦截校验
    在请求发送前执行拦截,检查 RequestMap 中是否存在该请求指纹,若存在则直接抛出异常,终止请求
  4. 自动清理机制 请求成功、失败或被取消时,从 RequestMap 中删除对应的请求指纹

路由跳转自动取消请求

  1. 请求拦截阶段,在请求发送前创建取消令牌并存储

  2. 路由拦截阶段,在路由跳转前取消未完成请求

双 Token,无感知刷新(解决并发登录)

  1. Token 检查机制 在请求响应阶段进行拦截,检查当前 Token 是否过期
  2. Token 过期处理
    若 Token 过期,则尝试刷新 Token
  3. Token 刷新机制
    在刷新 Token 的过程中,将所有未完成的请求挂起(存储在订阅队列中),等待 Token 刷新成功后再继续发送
  4. Token 刷新成功
    刷新成功后,将所有挂起的请求进行派发重新发送
  5. Token 刷新失败
    刷新失败后,统一处理错误,如跳转到登录页面
ts
import axios from "axios";
import type {
  AxiosInstance,
  AxiosRequestConfig,
  Canceler,
  AxiosResponse,
} from "axios";
import { showLoadingToast, closeToast, showFailToast } from "vant";
import { refreshToken } from "./../apis";

import { store } from "./../store";
import { I18nMessages, Typelanguage } from "../locales";
import pubSub from ".";

interface Params {
  [key: string]: unknown;
}

interface IAxiosHelper {
  isLoading: boolean;
  isToast: boolean;
  isCancelReq: boolean;
  isDeduplication: boolean;
  reqUniqueKey: string;
}

const { subscribe, dispatch } = pubSub();

axios.defaults.headers.common["Content-Type"] =
  "application/json;charset=UTF-8";
class Httprequest {
  private timeout: number;
  private queue: Params;
  private loadingCount: number;
  private waitToken: boolean; // 是否正在换取新的Token
  constructor() {
    this.timeout = 50000; // 请求的超时时间50秒
    this.queue = {}; // 请求队列,防止重复点击
    this.loadingCount = 0;
    this.waitToken = false;
  }

  _closeLoading(isLoading: boolean) {
    if (isLoading) {
      this.loadingCount--;
      if (this.loadingCount <= 0) {
        closeToast();
        this.loadingCount = 0;
      }
    }
  }

  setinterceptors(
    instance: AxiosInstance,
    {
      isLoading,
      isToast,
      isCancelReq,
      isDeduplication,
      reqUniqueKey,
    }: IAxiosHelper
  ) {
    const { processing, serverException } =
      I18nMessages[store.state.language.type as Typelanguage].http;
    instance.interceptors.request.use((config) => {
      const token: string = store.state.user.token;
      if (config.headers) {
        if (token) {
          config.headers["X-Token"] = token;
        }
      }

      if (isDeduplication) {
        if (this.queue[reqUniqueKey]) {
          throw "EXCEPTION:REPEATCLICK";
        } else {
          this.queue[reqUniqueKey] = reqUniqueKey;
        }
      }

      if (isLoading) {
        this.loadingCount++;
        showLoadingToast({
          duration: 0,
          forbidClick: true,
          message: "Loading...",
        });
      }
      if (isCancelReq) {
        config.cancelToken = new axios.CancelToken((cancel: Canceler) => {
          store.dispatch(`cancelReq/enqueue`, cancel);
        });
      }

      return config;
    });

    instance.interceptors.response.use(
      (res: AxiosResponse) => {
        delete this.queue[reqUniqueKey];
        this._closeLoading(isLoading);
        if (Number(res.data.code) === 0) {
          return Promise.resolve(res.data.data);
        } else if (res.data.code === "0200002") {
          console.log("登录过期");
          // 登录过期
          const refreshTokenApi = new Promise<void>((resolve, reject) => {
            if (!this.waitToken) {
              this.waitToken = true;
              // 换取刷新Token
              store.dispatch("user/updateUserInfo", {
                token: store.state.user.refreshToken,
              });
              refreshToken()
                .then((tokenRes) => {
                  const token = tokenRes.token;
                  if (token) {
                    // 更新用户token&refreshToken
                    store.dispatch("user/updateUserInfo", {
                      token: token,
                      refreshToken: tokenRes.refreshToken,
                    });
                    resolve();
                    this.waitToken = false;
                    dispatch();
                  } else {
                    console.log("重新登录");
                    reject();
                    this.waitToken = false;
                    dispatch();
                  }
                })
                .catch(() => {
                  reject();
                  this.waitToken = false;
                  dispatch();
                });
            } else {
              console.log("正在换取Token,请等待", this.waitToken);
              return new Promise((resolve) => {
                subscribe(() => {
                  setTimeout(() => resolve(this.request(res.config)));
                });
              });
            }
          });
          return refreshTokenApi.then(() => this.request(res.config));
        }
        if (isToast) {
          showFailToast(res.data.message);
        }
        return Promise.reject(res.data);
      },
      (error) => {
        this._closeLoading(isLoading);
        if (error === "EXCEPTION:REPEATCLICK") {
          showFailToast(processing);
        } else if (error.__proto__.constructor.name === "Cancel") {
          delete this.queue[reqUniqueKey];
          console.log("请求取消");
        } else {
          delete this.queue[reqUniqueKey];
          // 请求取消不处理
          if (error.message !== "canceled") {
            showFailToast(serverException);
          }
        }

        return Promise.reject(error.message || error);
      }
    );
  }
  /**
   *
   * @param {*} axiosConfig axios 参数
   * @param {*} isLoading 开启loading
   * @param {*} isToast 开启接口异常提示
   * @param {*} isCancelReq 取消上一个页面未完成的请求
   * @param {*} isDeduplication 开启接口防重
   * @returns
   */
  request<T>(
    axiosConfig: AxiosRequestConfig,
    {
      isLoading = true,
      isToast = true,
      isCancelReq = true,
      isDeduplication = true,
    }: Partial<IAxiosHelper> = {}
  ): Promise<T> {
    const instance: AxiosInstance = axios.create();
    axiosConfig.method || (axiosConfig.method = "post");
    const config = {
      baseURL: import.meta.env.VITE_API_HOST,
      timeout: this.timeout,
      ...axiosConfig,
    };
    let reqUniqueKey = "";
    if (isDeduplication) {
      let paramsStr = "";
      if (axiosConfig.method.toLocaleUpperCase() === "GET") {
        paramsStr = JSON.stringify(config.params || {});
      } else {
        paramsStr = JSON.stringify(config.data || {});
      }
      // 确定唯一性(路由+接口地址+请求方式)
      reqUniqueKey =
        `${location.href}${config.method}${config.url}${paramsStr}`.toLocaleUpperCase();
    }

    this.setinterceptors(instance, {
      isLoading,
      isToast,
      isCancelReq,
      isDeduplication,
      reqUniqueKey,
    });
    return instance(config);
  }
}

const http = new Httprequest();

export const httpGet = <T>(
  url: string,
  params?: Params,
  axiosHelper?: Partial<IAxiosHelper>
): Promise<T> => {
  return http.request<T>(
    {
      url,
      params,
      method: "GET",
    },
    axiosHelper
  );
};
export const httpPost = <T>(
  url: string,
  data?: Params,
  axiosHelper?: Partial<IAxiosHelper>
): Promise<T> => {
  return http.request<T>(
    {
      url,
      data,
    },
    axiosHelper
  );
};

export default http.request.bind(http);
ts
import { UserInfo } from "../store/user";
import { httpGet, httpPost } from "../utils/Httprequest";

export const loginApi = (data: {
  phone: string;
  code: string;
}): Promise<UserInfo> => {
  return httpPost("/user/login", data);
};

// 刷新Token
export const refreshToken = (): Promise<{
  token: string;
  refreshToken: string;
}> => {
  return httpGet(
    "/user/refresh",
    {},
    {
      isLoading: false,
      isToast: false,
      isCancelReq: false,
      isDeduplication: false,
    }
  );
};