Axios 封装:集成 Loading、Token、401 与 503 智能重试

核心目标:

这段代码旨在创建一个可重用的 Axios 实例 (axiosIns),它不仅能处理基本的 HTTP 请求,还集成了以下关键功能:

  1. 全局 Loading 状态管理: 自动跟踪进行中的(需要显示 Loading 的)请求数量,并提供一个响应式的 isLoading 状态供 UI 使用。
  2. 自动 Token 注入: 在请求头中自动添加本地存储的 Authorization Token。
  3. 统一的 401 (未授权) 处理: 捕获 401 错误(无论是 HTTP 状态码还是业务状态码),清除本地 Token,提示用户并自动跳转到登录页。
  4. 503 (服务不可用) 自动重试:
    • 当遇到 HTTP 503 错误时,自动进行最多 MAX_RETRIES 次重试。
    • 重试间隔会优先遵循服务器返回的 Retry-After 响应头(支持秒数和日期格式)。
    • 如果没有有效的 Retry-After 头,则使用指数退避 (Exponential Backoff) 策略计算延迟时间,并设有最大延迟上限 (MAX_RETRY_DELAY_MS)。
  5. 可配置性: 允许通过 noLoadingUrl 数组排除特定 URL 的 Loading 效果,并可调整重试次数、延迟时间等常量。

关键实现要点分析:

  1. 全局 Loading 状态 (httpState, updateHttpState):

    • 使用 Vue 的 ref 创建响应式状态 pendingCountisLoading
    • updateHttpState 函数负责原子地增减 pendingCount 并更新 isLoading
    • 优点: 提供了一个简单集中的方式来控制全局 Loading UI。
    • 关键逻辑 (方法一实现): 通过在请求拦截器中为原始请求 增加计数 (_countedForLoading 标记),并在响应成功拦截器响应错误拦截器的最终失败路径 中减少计数,确保了 isLoading 状态能正确覆盖整个请求生命周期(包括重试),避免闪烁。
  2. 请求拦截器 (instance.interceptors.request.use):

    • Token 注入: 标准的 Token 处理方式。
    • 重试与 Loading 标记:
      • 使用 _isRetry, _isOriginalRequest, _countedForLoading 三个内部标记来精细控制重试逻辑和 Loading 计数。
      • 确保只有原始请求(且需要 Loading)才增加计数。
      • 初始化 _retryCount
  3. 响应拦截器 (instance.interceptors.response.use):

    • 成功处理:
      • 检查 _countedForLoading 标记,如果是,则减少计数。
      • 包含对业务状态码 401 的检查和处理(假设成功响应体中可能包含表示未授权的 code)。
    • 错误处理 (async (error) => ...) :
      • 错误分类处理: 代码结构清晰地处理了几种主要错误情况:无法处理的错误 (!config)、网络错误 (!response)、HTTP 401、HTTP 503 可重试、最终失败(其他错误或 503 重试耗尽)。
      • 503 重试核心:
        • 判断条件 response.status === 503 && config._retryCount < MAX_RETRIES
        • 调用 calculateRetryDelay 获取延迟时间。
        • 使用 await delay() 执行等待。
        • 关键: return instance(config) 重新发起请求,并将 Promise 链传递下去。此时不减少 loading 计数
      • 最终失败路径减少计数: 在所有不可重试的错误路径(包括 503 重试耗尽)中,都检查 _countedForLoading 标记,如果为 true,则调用 updateHttpState(false)
      • 乐观的 !config 处理: 在无法获取 config 的极端情况下,乐观地减少一次计数,以尝试避免计数器泄漏。
      • 其他错误处理: 包含了对 400, 403, 404, 5xx (非 503) 等常见错误的日志记录和可选的全局提示。
  4. 重试延迟计算 (calculateRetryDelay):

    • 优先 Retry-After: 尝试解析秒数和日期格式,提供更智能的延迟。
    • 后备指数退避: 在没有有效 Retry-After 时,提供标准的指数退避策略。
    • 最大延迟限制: 避免因指数退避或过长的 Retry-After 导致无限期等待。
  5. 代码结构与可维护性:

    • 将配置常量、辅助函数、实例创建和拦截器逻辑清晰地组织在一起。
    • 使用了注释来解释关键逻辑和标记。
    • 将 Axios 实例和状态导出,方便在应用中统一使用。
js 复制代码
import axios from "axios";
import { NotifyPlugin } from "tdesign-vue-next"; // 假设你使用了 TDesign 的 Notify
import { ref } from "vue";

// --- 配置 ---
// 不需要全局 loading 效果的 URL 列表
const noLoadingUrl = ["/component/captcha"];
// 重试配置
const MAX_RETRIES = 3; // 最大重试次数
const INITIAL_RETRY_DELAY_MS = 1000; // 初始重试延迟(毫秒)
const MAX_RETRY_DELAY_MS = 10000; // 最大重试延迟(10秒)

// --- 请求状态管理 ---
const httpState = {
  pendingCount: ref(0), // 当前正在进行的、计入loading的请求数量
  isLoading: ref(false), // 全局 loading 状态
  lastRequestTime: ref(0), // 可选:记录最后请求时间
};

// 更新全局 loading 状态的函数
const updateHttpState = (increment = true) => {
  if (increment) {
    httpState.pendingCount.value++;
    // console.log('[HTTP State] Incremented pendingCount:', httpState.pendingCount.value);
  } else {
    httpState.pendingCount.value = Math.max(
      0,
      httpState.pendingCount.value - 1,
    );
    // console.log('[HTTP State] Decremented pendingCount:', httpState.pendingCount.value);
  }
  httpState.isLoading.value = httpState.pendingCount.value > 0;
  httpState.lastRequestTime.value = Date.now();
};

// --- 基础 URL 配置 ---
let baseURL = "";
const env = import.meta.env.MODE || "development";
if (env === "development") {
  baseURL = import.meta.env.VITE_API_URL_PREFIX; // 提供一个默认值
} else if (env === "production") {
  baseURL = import.meta.env.VITE_API_URL + import.meta.env.VITE_API_URL_PREFIX;
}

// --- 辅助函数 ---
// 延迟函数
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// 计算重试延迟时间
const calculateRetryDelay = (retryCount, responseHeaders) => {
  let delayMs = 0;
  const retryAfterHeader = responseHeaders?.["retry-after"]; // Axios header keys are often lowercase

  if (retryAfterHeader) {
    // 尝试解析秒数
    const retryAfterSeconds = parseInt(retryAfterHeader, 10);
    if (!isNaN(retryAfterSeconds)) {
      delayMs = retryAfterSeconds * 1000;
      console.log(
        `[Axios Retry] Using Retry-After header (seconds): ${retryAfterSeconds}s`,
      );
    } else {
      // 尝试解析 HTTP 日期格式
      try {
        const retryDate = new Date(retryAfterHeader);
        if (!isNaN(retryDate.getTime())) {
          const waitTime = retryDate.getTime() - Date.now();
          delayMs = Math.max(0, waitTime); // 确保非负
          console.log(
            `[Axios Retry] Using Retry-After header (date): ${retryAfterHeader}. Wait ${delayMs}ms`,
          );
        }
      } catch (e) {
        console.warn(
          "[Axios Retry] Could not parse Retry-After header date format:",
          retryAfterHeader,
        );
      }
    }
  }

  // 如果没有有效的 Retry-After 或计算出的延迟 <= 0,使用指数退避
  if (delayMs <= 0) {
    // prettier-ignore
    delayMs = Math.min(
            INITIAL_RETRY_DELAY_MS * (2 ** (retryCount - 1)), // 指数增长
            MAX_RETRY_DELAY_MS // 不超过最大延迟
        );
    console.log(`[Axios Retry] Using Exponential Backoff. Wait ${delayMs}ms`);
  }

  // 再次确保不超过最大延迟
  return Math.min(delayMs, MAX_RETRY_DELAY_MS);
};

// --- 创建并配置 Axios 实例 ---
const getAxiosInstance = () => {
  const instance = axios.create({
    baseURL: baseURL,
    // timeout: 60000, // 设置一个默认超时时间,例如 60 秒
  });

  // --- 请求拦截器 ---
  instance.interceptors.request.use(
    (config) => {
      // 1. 添加认证 Token
      let token = localStorage.getItem("token");
      if (token) {
        config.headers["Authorization"] = token; // 推荐使用 Bearer scheme
      }

      // 2. 处理重试和 Loading 计数标记
      // 如果 _isRetry 标志不存在或为 false,则认为是原始请求
      if (!config._isRetry) {
        config._isOriginalRequest = true; // 标记为原始请求
        config._retryCount = 0; // 初始化重试计数

        // 判断此 URL 是否需要计入 loading
        const shouldTrackLoading = !noLoadingUrl.includes(config.url);
        if (shouldTrackLoading) {
          config._countedForLoading = true; // 添加标记,表示此请求链需要管理计数
          updateHttpState(true); // 增加 loading 计数
          // console.log(`[Request Start] ${config.method?.toUpperCase()} ${config.url} - Count Incremented`);
        } else {
          config._countedForLoading = false;
        }
      } else {
        // 如果是重试请求,则不增加 loading 计数
        config._isOriginalRequest = false;
        // _countedForLoading 标记从原始请求继承而来,无需修改
        // console.log(`[Request Retry] ${config.method?.toUpperCase()} ${config.url} - Retry #${config._retryCount}`);
      }

      return config;
    },
    (error) => {
      // 请求配置阶段的错误,通常比较少见
      console.error("[Axios Request Setup Error]", error);
      // 理论上,如果这里出错,应该也要尝试减少计数,但可能没有 config 对象
      // 这个问题通常需要排查拦截器或请求代码本身
      return Promise.reject(error);
    },
  );

  // --- 响应拦截器 ---
  instance.interceptors.response.use(
    (response) => {
      // --- 成功响应处理 ---
      const config = response.config;

      // 如果这个请求链之前增加了 loading 计数,现在成功了就减少计数
      if (config._countedForLoading) {
        updateHttpState(false);
        // config._countedForLoading = false; // 重置标记(可选,下次请求会重新设置)
        // console.log(`[Response Success] ${config.method?.toUpperCase()} ${config.url} - Count Decremented`);
      }

      // --- 处理 401 (未授权) ---
      // 注意:这里假设你的后端在 Token 无效时,即使是 API 调用成功 (HTTP 200),
      // 也会在响应体 data 中包含一个特定的 code (例如 401) 来表示需要重新登录。
      // 如果后端直接返回 HTTP 401 状态码,则处理逻辑应放在下面的错误拦截器中。
      if (
        response.data &&
        [401].includes(response.data.code) && // 检查业务状态码
        location.pathname !== "/login"
      ) {
        localStorage.removeItem("token");
        NotifyPlugin("error", {
          title: response.data.message || "登录信息已过期,请重新登录!",
        });
        setTimeout(() => {
          location.href = "/login"; // 或者使用 Vue Router 跳转
        }, 1500);
        // 可以考虑抛出一个特定错误或修改响应,防止后续代码错误地处理数据
        // return Promise.reject(new Error("Business logic unauthorized"));
      }

      // 返回成功的响应数据
      return response;
    },
    async (error) => {
      // --- 错误响应处理 ---
      const { config, response } = error;

      // 1. 无法处理的错误 (例如请求被取消,或配置错误)
      if (!config) {
        console.error("[Axios Error] Request config missing in error.", error);
        // 无法确定来源,但乐观地假设它可能对应一个增加了计数的请求,尝试减少计数
        // 注意:这里没有 URL,无法判断是否在 noLoadingUrl 中,所以只能减少全局计数
        // 更好的方式是,如果能从 error 中获取更多信息来判断,但通常很难
        updateHttpState(false); // 乐观减少计数
        return Promise.reject(error);
      }

      // 获取此请求是否需要管理 loading 计数
      const shouldTrackLoading = config._countedForLoading === true;

      // 2. 网络错误或其他无响应错误 (error.response 不存在)
      if (!response) {
        // 如果这个请求链之前增加了计数,现在失败了就减少计数
        if (shouldTrackLoading) {
          updateHttpState(false);
          // config._countedForLoading = false; // 重置标记
          // console.log(`[Response Error] Network Error for ${config.url} - Count Decremented`);
        }
        console.error(
          "[Axios Error] Network or other error without response for:",
          config.url,
          error.message,
        );
        return Promise.reject(error);
      }

      // --- 以下是有 response 的错误 ---

      // 3. 处理 HTTP 401 (未授权) - 服务器直接返回 401 状态码
      if (response.status === 401 && location.pathname !== "/login") {
        // 如果这个请求链之前增加了计数,现在失败了就减少计数
        if (shouldTrackLoading) {
          updateHttpState(false);
          // config._countedForLoading = false;
          // console.log(`[Response Error] 401 Unauthorized for ${config.url} - Count Decremented`);
        }
        localStorage.removeItem("token");
        NotifyPlugin("error", {
          title: error.response?.data?.message || "登录认证失败,请重新登录!", // 尝试从响应体获取更具体信息
        });
        setTimeout(() => {
          location.href = "/login"; // 或者使用 Vue Router 跳转
        }, 1500);
        // 抛出一个特定错误,中断 Promise 链,避免后续处理
        return Promise.reject(new Error("Unauthorized - Redirecting to login"));
      }

      // 4. 检查是否为 503 且可重试
      config._retryCount = config._retryCount || 0; // 确保计数器存在

      if (response.status === 503 && config._retryCount < MAX_RETRIES) {
        // --- 执行重试逻辑 ---
        config._retryCount += 1;
        config._isRetry = true; // 标记为重试请求,下次请求拦截器不会增加计数

        const delayMs = calculateRetryDelay(
          config._retryCount,
          response.headers,
        );
        console.log(
          `[Axios Retry] Attempt ${config._retryCount}/${MAX_RETRIES} for ${config.url} after ${delayMs}ms delay due to 503.`,
        );

        await delay(delayMs);

        console.log(`[Axios Retry] Retrying request: ${config.url}`);
        // **发起重试,此时不改变 loading 计数**
        return instance(config); // 返回重试请求的 Promise
      }

      // 5. 处理最终失败 (非 503, 或 503 重试耗尽, 或其他 4xx/5xx 错误)
      // 如果这个请求链之前增加了计数,现在最终失败了就减少计数
      if (shouldTrackLoading) {
        if (response.status === 503) {
          // 如果是 503 耗尽次数失败
          console.error(
            `[Axios Retry] Max retries reached for 503 on ${config.url}. Final fail. Count Decremented.`,
          );
        } else {
          // 其他最终错误
          // console.log(`[Response Error] Final error ${response.status} for ${config.url} - Count Decremented.`);
        }
        updateHttpState(false);
        // config._countedForLoading = false; // 重置标记
      }

      // 在这里可以添加对其他特定错误码的通用处理逻辑,例如:
      if (response.status === 400) {
        console.error("Bad Request:", error.response.data);
        // NotifyPlugin('warning', { title: '请求参数错误' });
      } else if (response.status === 403) {
        console.error("Forbidden:", error.response.data);
        NotifyPlugin("error", { title: "您没有权限执行此操作" });
      } else if (response.status === 404) {
        console.warn("Not Found:", config.url);
        // 可能不需要全局提示 404
      } else if (response.status >= 500 && response.status !== 503) {
        // 处理 500, 502, 504 等
        console.error(`Server Error ${response.status}:`, error.response.data);
        NotifyPlugin("error", {
          title: "服务器内部错误,请稍后再试或联系管理员",
        });
      }

      // 将最终的、未经处理的错误传递下去
      return Promise.reject(error);
    },
  );

  return instance;
};

// --- 导出实例和状态 ---
const axiosIns = getAxiosInstance();

export { axiosIns, httpState };

潜在的优化或考虑点 (虽然代码已经很完善):

  • Token 刷新逻辑: 目前只处理了 Token 失效后的跳转,没有包含 Refresh Token 的自动刷新逻辑。如果需要,可以在 401 处理中加入尝试刷新 Token 并重发请求的机制(这会增加复杂度)。
  • 更细粒度的错误处理: 可以根据业务需求,对不同的业务错误码(如 response.data.code)进行更具体的处理,而不仅仅是 401。
  • 并发请求取消: 如果需要在某些场景下取消"重复"的请求(例如,用户快速切换筛选条件),可能需要引入更复杂的请求取消管理,但这超出了当前封装的范围。
  • TypeScript 支持: 如果项目使用 TypeScript,添加类型定义会更有优势。

总结:

这段代码提供了一个功能丰富、健壮且考虑了多种边界情况的 Axios 封装方案。它通过巧妙地使用拦截器和自定义配置标记,优雅地实现了全局 Loading 管理、Token 处理、401 跳转以及带 Retry-After 支持和指数退避的 503 自动重试功能,并且保证了 Loading 状态的准确性。这是一个非常实用的封装,可以直接应用于许多 Vue 项目中。

相关推荐
崔庆才丨静觅10 分钟前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60611 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了1 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅1 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅1 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment2 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅2 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊2 小时前
jwt介绍
前端
爱敲代码的小鱼2 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax