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 项目中。

相关推荐
_一条咸鱼_22 分钟前
深入解析 Vue API 模块原理:从基础到源码的全方位探究(八)
前端·javascript·面试
患得患失94934 分钟前
【前端】【难点】前端富文本开发的核心难点总结与思路优化
前端·富文本
执键行天涯37 分钟前
在vue项目中package.json中的scripts 中 dev:“xxx“中的xxx什么概念
前端·vue.js·json
雯0609~1 小时前
html:文件上传-一次性可上传多个文件,将文件展示到页面(可删除
前端·html
涵信1 小时前
2024年React最新高频面试题及核心考点解析,涵盖基础、进阶和新特性,助你高效备战
前端·react.js·前端框架
mmm.c1 小时前
应对多版本vue,nvm,node,npm,yarn的使用
前端·vue.js·npm
混血哲谈1 小时前
全新电脑如何快速安装nvm,npm,pnpm
前端·npm·node.js
天天扭码1 小时前
项目登录注册页面太丑?试试我“仿制”的丝滑页面(全源码可复制)
前端·css·html
桂月二二2 小时前
Vue3服务端渲染深度实战:SSR架构优化与企业级应用
前端·vue.js·架构
萌萌哒草头将军2 小时前
🚀🚀🚀 这六个事半功倍的 Pinia 库,你一定要知道!
前端·javascript·vue.js