核心目标:
这段代码旨在创建一个可重用的 Axios 实例 (axiosIns
),它不仅能处理基本的 HTTP 请求,还集成了以下关键功能:
- 全局 Loading 状态管理: 自动跟踪进行中的(需要显示 Loading 的)请求数量,并提供一个响应式的
isLoading
状态供 UI 使用。 - 自动 Token 注入: 在请求头中自动添加本地存储的
Authorization
Token。 - 统一的 401 (未授权) 处理: 捕获 401 错误(无论是 HTTP 状态码还是业务状态码),清除本地 Token,提示用户并自动跳转到登录页。
- 503 (服务不可用) 自动重试:
- 当遇到 HTTP 503 错误时,自动进行最多
MAX_RETRIES
次重试。 - 重试间隔会优先遵循服务器返回的
Retry-After
响应头(支持秒数和日期格式)。 - 如果没有有效的
Retry-After
头,则使用指数退避 (Exponential Backoff) 策略计算延迟时间,并设有最大延迟上限 (MAX_RETRY_DELAY_MS
)。
- 当遇到 HTTP 503 错误时,自动进行最多
- 可配置性: 允许通过
noLoadingUrl
数组排除特定 URL 的 Loading 效果,并可调整重试次数、延迟时间等常量。
关键实现要点分析:
-
全局 Loading 状态 (
httpState
,updateHttpState
):- 使用 Vue 的
ref
创建响应式状态pendingCount
和isLoading
。 updateHttpState
函数负责原子地增减pendingCount
并更新isLoading
。- 优点: 提供了一个简单集中的方式来控制全局 Loading UI。
- 关键逻辑 (方法一实现): 通过在请求拦截器中为原始请求 增加计数 (
_countedForLoading
标记),并在响应成功拦截器 或响应错误拦截器的最终失败路径 中减少计数,确保了isLoading
状态能正确覆盖整个请求生命周期(包括重试),避免闪烁。
- 使用 Vue 的
-
请求拦截器 (
instance.interceptors.request.use
):- Token 注入: 标准的 Token 处理方式。
- 重试与 Loading 标记:
- 使用
_isRetry
,_isOriginalRequest
,_countedForLoading
三个内部标记来精细控制重试逻辑和 Loading 计数。 - 确保只有原始请求(且需要 Loading)才增加计数。
- 初始化
_retryCount
。
- 使用
-
响应拦截器 (
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) 等常见错误的日志记录和可选的全局提示。
- 错误分类处理: 代码结构清晰地处理了几种主要错误情况:无法处理的错误 (
- 成功处理:
-
重试延迟计算 (
calculateRetryDelay
):- 优先
Retry-After
: 尝试解析秒数和日期格式,提供更智能的延迟。 - 后备指数退避: 在没有有效
Retry-After
时,提供标准的指数退避策略。 - 最大延迟限制: 避免因指数退避或过长的
Retry-After
导致无限期等待。
- 优先
-
代码结构与可维护性:
- 将配置常量、辅助函数、实例创建和拦截器逻辑清晰地组织在一起。
- 使用了注释来解释关键逻辑和标记。
- 将 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 项目中。