Appearance
Axios 网络请求封装
文档基础信息
- 技术栈:
Vue3+TypeScript+Pinia+Axios+Arco Design Vue - 封装核心:基于原生 Axios 高阶封装,深度整合前端业务通用能力
- 适用场景:中后台管理系统/各类 Vue3 项目的标准化网络请求层开发
- 核心特点:强类型约束、功能全覆盖、开箱即用、无侵入式扩展、贴合企业级开发规范
- 文档说明:本文档包含「封装说明、配置说明、完整使用示例、高级功能、最佳实践」
一、封装概述
1.1 封装介绍
本封装是对 axios 的企业级完整封装,基于 Vue3 + TS 生态,在原生 Axios 能力之上,整合了前端开发中所有高频必备的请求层能力,将请求相关的通用逻辑(Token 注入、请求拦截、响应处理、错误提示、Loading 状态、重复请求拦截、请求取消、缓存策略、Token 无感刷新)全部封装到独立类中,业务侧仅需调用简洁的 API 即可完成请求调用,无需重复开发通用逻辑。
封装遵循「高内聚、低耦合」原则:内部封装复杂逻辑,对外暴露极简的调用方式;同时保留 Axios 原生配置的完全透传,支持业务侧按需自定义配置,兼顾「标准化」与「灵活性」。
1.2 核心功能特性 ✅ 全量支持
✅ TypeScript 强类型全覆盖,无 any 松散类型,所有请求/响应/配置均有精准类型约束,开发阶段校验错误
✅ 统一请求/响应拦截器,全局注入 Token 认证头,统一处理后端返回格式
✅ 基于「请求计数」的智能全局 Loading,多请求合并 Loading 无闪烁,请求完成自动关闭
✅ 重复请求自动拦截,防止用户高频点击导致的接口重复调用,支持开关配置
✅ 基于 Axios 官方推荐的 AbortController 实现请求取消(替代废弃的 CancelToken),支持单/全量取消请求
✅ 可配置的请求缓存策略,支持缓存有效期、强制刷新缓存,自动清理过期缓存,减少无效请求
✅ Token 过期无感刷新,结合请求队列,刷新 Token 后自动重试排队请求,用户无感知,无需手动刷新页面
✅ 统一的异常处理体系,区分 HTTP 错误、业务错误、网络错误、重复请求、请求取消等场景,统一提示文案
✅ 完整的 RESTful 风格请求方法封装:GET/POST/PUT/DELETE + 专属文件上传方法 upload
✅ 支持 Pinia 用户状态持久化,Token/用户信息自动缓存到 localStorage,页面刷新不丢失
✅ 环境变量动态配置请求根地址,无缝适配开发/测试/生产多环境切换
✅ 内存友好设计,自动清理过期缓存、无用请求控制器,无内存泄漏风险
二、核心封装类:HttpRequest 详细说明
2.1 类的核心设计
HttpRequest 是本次封装的核心类,采用类封装+单例模式设计:
- 内部创建独立的 Axios 实例,避免全局污染,支持创建多个实例适配不同接口域名
- 所有通用逻辑(拦截器、Loading、缓存、取消请求)均封装为私有方法,对外不可见
- 对外暴露简洁的公共请求方法(
get/post/put/delete/upload),业务侧直接调用 - 所有配置项均有默认值,业务侧按需传入即可,无需关心内部实现
2.2 核心自定义配置项 IRequestConfig
封装扩展了 Axios 原生配置,新增了前端业务必备的自定义配置项,所有请求方法均可传入该配置,用于控制当前请求的行为,所有配置项均为可选,未传则使用默认值。
配置项完整定义
typescript
interface IRequestConfig {
showLoading: boolean; // 是否显示全局加载中动画,默认 true
showToast: boolean; // 是否显示错误提示弹窗,默认 true
cancelable: boolean; // 当前请求是否可被取消,默认 true
preventDuplicate: boolean; // 是否拦截重复请求,默认 true
requestId: string; // 请求唯一标识,自动生成,无需手动配置
cache: number; // 缓存有效期(毫秒),0/负数=不缓存,默认 0
forceRefresh: boolean; // 是否强制刷新缓存,跳过缓存校验,默认 false
}配置项说明
| 配置项 | 类型 | 默认值 | 核心作用 | 使用场景 |
|---|---|---|---|---|
| showLoading | boolean | true | 控制当前请求是否触发全局 Loading | 列表查询/表单提交等需要加载状态的场景,无需加载则设为 false |
| showToast | boolean | true | 控制请求失败时是否自动弹出错误提示 | 静默请求(如埋点、状态同步)设为 false,避免弹窗干扰;需要自定义提示也设为 false |
| cancelable | boolean | true | 开启则当前请求可被取消,关闭则不可取消 | 页面切换时需要取消的请求保留 true,核心提交请求可设为 false |
| preventDuplicate | boolean | true | 开启则拦截相同请求的重复调用 | 按钮点击触发的请求保留 true,避免重复提交;实时查询类请求可设为 false |
| cache | number | 0 | 缓存有效期,单位「毫秒」 | 静态数据(字典/配置)、低频更新数据(用户信息)设为 5*60*1000(5 分钟)等 |
| forceRefresh | boolean | false | 强制刷新缓存,无视缓存有效期 | 手动触发刷新数据时设为 true,保证获取最新数据 |
2.3 类内部核心默认配置
以下配置在类内部定义,可直接修改数值适配项目需求,无需改动核心逻辑:
typescript
private requestTimeout: number = 50000; // 普通请求超时时间:50秒
private cancelMaxCount: number = 10; // 最多缓存10个可取消请求控制器,避免内存溢出
private cacheCleanupInterval = 5*60*1000; // 缓存自动清理间隔:5分钟
private defaultRequestConfig: IRequestConfig = { ... }; // 上述自定义配置的默认值三、完整使用指南
3.1 导入方式(两种,按需选择)
封装默认导出全局单例实例,项目中 99% 的场景使用该实例即可;同时导出类本身,支持创建多实例适配不同域名。
typescript
// ✅ 推荐方式:全局导入默认实例,项目中所有请求统一使用该实例
import request from "@/utils/HttpRequest";
// ✅ 按需方式:导入类,创建自定义配置的新实例(适配多域名/不同超时配置)
import { HttpRequest } from "@/utils/HttpRequest";
const customRequest = new HttpRequest({ timeout: 60000 });3.2 基础请求方法(RESTful 完整封装)
所有请求方法均为 异步方法,支持 async/await 语法;同时是 泛型方法,传入泛型即可获得响应数据的精准类型推导,完美适配 TS 开发。
✨ 核心特性:所有请求的返回值,直接是后端响应的
data字段,无需业务侧手动res.data解析,封装类已自动解包!
✅ 1. GET 请求 - request.get<T>(url, params?, config?)
适用于查询数据、列表分页、无修改的请求,参数会自动拼接到 URL 上。
- 参数 1:
url接口地址(必填,无需拼接 baseURL) - 参数 2:
params请求参数(可选,Params 类型) - 参数 3:
config自定义配置(可选,IRequestConfig + Axios 原生配置) - 返回值:
Promise<T>T 为后端返回的 data 数据类型
使用示例
typescript
// ① 基础GET请求:无参数
const res = await request.get("/system/dict/list");
// ② 带参数GET请求:查询指定字典
const params = { dictType: "user_status" };
const res = await request.get("/system/dict/get", params);
// ③ 带泛型+自定义配置:分页查询用户列表,关闭Loading,开启缓存5分钟
import type { UserItem } from "@/models/user";
import type { BasePaginatorResponse } from "@/models/base";
const params = { page: 1, size: 10, keyword: "admin" };
const res = await request.get<BasePaginatorResponse<UserItem>>(
"/system/user/page",
params,
{ showLoading: false, cache: 5 * 60 * 1000 }
);
// res 直接是分页数据,包含 list + paginator
console.log(res.list, res.paginator.total_record);✅ 2. POST 请求 - request.post<T>(url, data?, config?)
适用于提交数据、新增资源、表单提交,参数作为请求体传递,封装默认请求方法为 POST。
- 参数与 GET 一致,区别是第二个参数为
data(请求体) - 所有特性与 GET 一致,支持泛型、自定义配置
使用示例
typescript
// ① 基础POST请求:登录提交
const loginData = { username: "admin", password: "123456" };
await request.post("/auth/login", loginData);
// ② 带泛型+自定义配置:新增用户,关闭重复请求拦截,关闭错误提示
import type { UserInfo } from "@/models/user";
const userData = { name: "测试用户", phone: "13800138000" };
const res = await request.post<UserInfo>("/system/user/add", userData, {
preventDuplicate: false,
showToast: false,
});✅ 3. PUT 请求 - request.put<T>(url, data?, config?)
适用于修改/更新资源,RESTful 规范中 PUT 代表全量更新,用法与 POST 完全一致。
typescript
// 修改用户信息
const userData = { id: 1, name: "修改后的名称", status: 1 };
await request.put("/system/user/update", userData);✅ 4. DELETE 请求 - request.delete<T>(url, data?, config?)
适用于删除资源,支持传参(如批量删除的 id 列表),用法与 POST 一致。
typescript
// 单条删除
await request.delete("/system/user/remove", { id: 1 });
// 批量删除
await request.delete("/system/user/batchRemove", { ids: [1, 2, 3] });3.3 专属文件上传方法 - request.upload<T>(url, formData, config?)
封装了文件上传的所有通用配置,开箱即用,无需手动配置请求头,是项目中上传文件的唯一推荐方式。
核心特性
- 自动设置请求头:
Content-Type: multipart/form-data,适配文件上传格式 - 超时时间默认设置为 2 分钟(120000ms),满足大文件上传需求
- 支持传入自定义配置,覆盖默认配置
- 支持 FormData 多字段传递(文件+业务参数)
使用示例
typescript
// 单文件上传
const uploadFile = async (file: File) => {
const formData = new FormData();
formData.append("file", file); // 文件字段
formData.append("bizType", "avatar"); // 业务参数
const res = await request.upload("/system/file/upload", formData);
return res.fileUrl;
};
// 多文件上传 + 自定义配置:关闭Loading,开启错误提示
const uploadMultiFile = async (files: File[]) => {
const formData = new FormData();
files.forEach((file) => formData.append("files", file));
const res = await request.upload("/system/file/batchUpload", formData, {
showLoading: false,
});
return res.fileUrls;
};3.4 取消所有未完成请求 - request.cancelAbortControllersAll()
封装提供了全局取消请求的方法,用于取消当前所有处于「待完成」状态的可取消请求,是内存优化的必用方法。
核心使用场景
- 页面切换/路由跳转时,取消当前页面的所有未完成请求,避免接口返回后操作已销毁的组件
- 退出登录时,取消所有待完成的请求,防止敏感数据泄露
- 搜索框防抖时,取消上一次的搜索请求,只保留最新的请求
最佳实践:在路由守卫中使用
typescript
// router/index.ts
import { createRouter } from 'vue-router';
import request from '@/utils/HttpRequest';
const router = createRouter({ ... });
// 全局路由守卫:离开当前页时取消所有请求
router.beforeEach((to, from) => {
if (from.path !== to.path) {
request.cancelAbortControllersAll();
}
});
export default router;3.5 自定义配置组合使用(高频实用场景)
业务开发中最常用的就是「组合自定义配置」,满足各类个性化需求,以下是高频组合示例,直接复制使用即可:
typescript
// ✅ 场景1:静态数据查询,开启缓存10分钟,关闭Loading,关闭错误提示
await request.get("/system/config/getAll", null, {
showLoading: false,
showToast: false,
cache: 10 * 60 * 1000,
});
// ✅ 场景2:表单提交,关闭重复请求拦截,关闭缓存,强制提交
await request.post("/system/form/submit", formData, {
preventDuplicate: false,
cache: 0,
});
// ✅ 场景3:手动刷新数据,开启强制刷新缓存,无视缓存有效期
await request.get("/system/data/refresh", params, {
forceRefresh: true,
});
// ✅ 场景4:静默请求(埋点),关闭所有提示和Loading,关闭重复请求拦截
await request.post("/system/log/record", logData, {
showLoading: false,
showToast: false,
preventDuplicate: false,
});四、高级功能详解
4.1 Token 自动注入 + 过期无感刷新
这是本次封装的核心能力之一,完美解决了「Token 过期后用户操作无感知」的问题,也是中后台系统的必备功能,所有逻辑均已封装完成,业务侧无需写任何代码,开箱即用。
完整执行流程
- Token 自动注入:请求拦截器中自动从 Pinia 的
useUserStore读取 Token,注入到请求头Authorization中,业务侧无需手动配置 - 过期判断:响应拦截器检测到后端返回的
code为ResponseCode.AUTH_EXPIRED = '0200002'时,判定为 Token 过期 - 防并发刷新:通过
isRefreshingToken标记是否正在刷新 Token,避免多次调用刷新接口 - 请求队列缓存:Token 过期时,将所有正在发起的请求存入
refreshQueue队列,保持请求的 Promise 处于 pending 状态 - 刷新处理:调用 Pinia 中的
refreshToken()方法刷新 Token,刷新成功后:- 更新 Pinia 中的 Token
- 自动遍历队列,重新执行所有排队的请求
- 请求成功后,将结果返回给业务侧,用户无感知
- 刷新失败:若刷新 Token 失败(如 refreshToken 也过期),则清空队列,调用
logout()方法退出登录,重置用户状态
关键依赖
该功能依赖 Pinia 的 useUserStore 中必须实现的 refreshToken() 方法
4.2 重复请求拦截机制
解决的问题
用户短时间内多次点击按钮发起相同请求,导致后端重复处理、前端接收重复数据、页面状态异常的问题。
实现原理
- 每个请求通过
generateRequestId生成唯一标识,生成规则:请求方法(大写):接口地址:排序后的参数字符串 - 排序参数的目的:保证
{a:1,b:2}和{b:2,a:1}是同一个请求,避免参数顺序不同导致的重复请求 - 维护
pendingRequestsSet 集合,存储正在处理中的请求 ID - 请求发起前,检查 ID 是否存在于集合中,存在则抛出「正在处理中,请勿重复操作」异常,拦截请求
- 请求完成(成功/失败)后,自动从集合中移除该请求 ID
4.3 智能缓存策略
解决的问题
对高频调用、数据更新频率低的接口(如字典数据、系统配置、用户基础信息)进行缓存,减少接口请求次数,提升页面加载速度,降低服务器压力。
核心规则(完全自动,无需业务侧干预)
- 只有当
cache > 0时,才会开启缓存 - 请求发起前,先检查缓存是否存在且未过期,存在则直接返回缓存数据,不发起真实请求
- 请求成功后,自动将响应数据存入缓存,有效期为配置的
cache时长 - 缓存过期后,自动删除缓存并重新发起请求
- 开启
forceRefresh: true时,跳过缓存校验,强制重新请求并更新缓存 - 封装会启动 5 分钟定时器,自动清理所有过期缓存,避免内存溢出
4.4 统一错误处理体系
封装对所有可能的异常场景进行了统一捕获和分类处理,返回标准化的错误提示,所有错误均会被 Promise.reject,业务侧可按需手动捕获处理。
错误类型全覆盖
- 重复请求错误:提示「正在处理中,请勿重复操作」
- 请求取消错误:静默失败,不弹出任何提示
- HTTP 状态码错误:400(参数错误)、401(未授权)、403(拒绝访问)、404(资源不存在)、5xx(服务器错误)
- 网络错误:请求已发送但无响应,提示「网络连接失败,请检查网络设置」
- 业务错误:后端返回的非成功业务码(如
code!=='0'),提示后端返回的message信息 - 配置错误:请求配置错误,提示「请求配置错误」
五、最佳实践
✅ 5.1 接口层抽离
将所有接口请求抽离到独立的 @/api 目录下,按业务模块划分文件,业务组件中只调用接口方法,不直接写请求逻辑,优点:统一管理、复用性高、便于维护、修改接口时无需改动业务组件。
示例:@/api/system/user.ts
typescript
import request from "@/utils/HttpRequest";
import type { Params } from "@/models/base";
import type { BasePaginatorResponse } from "@/models/base";
import type { UserItem, UserInfo } from "@/models/user";
// 用户分页查询
export const getUserPageApi = (params: Params) => {
return request.get<BasePaginatorResponse<UserItem>>(
"/system/user/page",
params
);
};
// 新增用户
export const addUserApi = (data: Params) => {
return request.post<UserInfo>("/system/user/add", data);
};
// 修改用户
export const updateUserApi = (data: Params) => {
return request.put<UserInfo>("/system/user/update", data);
};
// 删除用户
export const deleteUserApi = (data: Params) => {
return request.delete("/system/user/remove", data);
};组件中使用
typescript
import { getUserPageApi } from "@/api/system/user";
const getList = async () => {
const res = await getUserPageApi({ page: 1, size: 10 });
userList.value = res.list;
total.value = res.paginator.total_record;
};✅ 5.2 合理使用缓存
- 静态数据(字典、系统配置、省市县数据):设置缓存时长
10*60*1000(10 分钟) - 低频更新数据(用户信息、角色权限):设置缓存时长
5*60*1000(5 分钟) - 高频更新数据(实时列表、统计数据):关闭缓存(
cache:0) - 手动刷新数据时,开启
forceRefresh: true
✅ 5.3 路由守卫中取消请求
在全局路由守卫中调用 request.cancelAbortControllersAll(),页面切换时取消所有未完成请求,避免内存泄漏和无效的接口回调。
✅ 5.4 自定义错误处理
对需要个性化错误提示的请求,关闭 showToast: false,手动捕获异常并处理:
typescript
try {
await request.post("/system/user/add", data, { showToast: false });
Message.success("新增用户成功!");
} catch (error) {
Message.error("新增失败:用户名已存在");
}六、注意事项
- ⚠️ 响应状态码是字符串类型:你的
ResponseCode中SUCCESS = '0'、AUTH_EXPIRED = '0200002',均为字符串,封装中已做字符串匹配,请勿修改为数字,否则 Token 刷新等逻辑失效。 - ⚠️ Pinia 持久化配置:用户状态已配置持久化到 localStorage,页面刷新后 Token 不会丢失,无需手动处理。
- ⚠️ 请求 ID 自动生成:无需手动配置
requestId,封装会自动生成,保证唯一性。 - ⚠️ DELETE 请求传参:封装中 DELETE 请求的参数是放在
data中的,符合 RESTful 规范,无需拼接到 URL。 - ⚠️ FormData 处理:封装已对 FormData 做了特殊处理,上传文件时直接传入即可,无需序列化。
- ⚠️ 缓存是内存缓存:缓存基于前端内存,页面刷新后缓存会清空,适合短期缓存,不适合持久化数据。
- ⚠️ Token 刷新依赖:必须保证
useUserStore中的refreshToken()方法返回 Promise,否则 Token 过期后无法自动刷新。
七、总结
本次封装是一套企业级、标准化、无侵入式的 Vue3+TS Axios 请求解决方案,整合了前端开发中所有高频的请求层能力,所有逻辑均已封装完成,业务侧只需关注「调用接口」本身,无需重复开发通用逻辑。
封装的核心优势是:强类型约束保证开发质量、功能全覆盖满足所有业务场景、极简调用方式提升开发效率、灵活配置支持个性化需求,完全适配中后台管理系统的开发规范,可直接在生产环境中使用。
完整代码
ts
// src/utils/HttpRequest.ts
import axios from "axios";
import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import { Message, type MessageReturn } from "@arco-design/web-vue";
import type { BaseResponse, Params } from "@/models/base";
import { ResponseCode } from "@/constants/responseCode";
import { useUserStore } from "@/stores/user";
interface IRequestConfig {
// 加载状态控制
showLoading: boolean;
// 提示控制
showToast: boolean;
// 可取消性
cancelable: boolean;
// 重复请求处理
preventDuplicate: boolean;
// 请求唯一标识
requestId: string;
// 缓存 - 修改为缓存时间(毫秒),0或负数表示不缓存
cache: number;
// 强制刷新缓存(跳过缓存检查)
forceRefresh: boolean;
}
// 定义缓存项接口
interface CacheItem<T = unknown> {
data: T;
expireTime: number; // 过期时间戳(毫秒)
timestamp: number; // 创建时间戳(毫秒)
}
// 扩展 AxiosRequestConfig 以包含自定义配置
interface CustomAxiosRequestConfig extends AxiosRequestConfig {
requestConfig?: Partial<IRequestConfig>;
}
// 修改队列项类型
interface RefreshQueueItem<T = unknown> {
resolve: (value: T) => void;
reject: (reason?: T) => void;
retryFn: () => Promise<T>;
}
class HttpRequest {
private axiosInstance: AxiosInstance;
private loadingInstance: MessageReturn | null = null;
private requestTimeout: number = 50000; // 请求的超时时间50秒
private pendingRequests: Set<string> = new Set(); // 请求队列,用于防止重复请求
private activeRequestsCount: number = 0; // 当前活跃的请求数量(用于控制loading)
private cancelMaxCount: number = 10; // 取消的最大请求数量
private abortControllers = new Map<string, AbortController>(); // 用于取消请求
private cacheMap = new Map<string, CacheItem>(); // // 缓存存储
private cacheCleanupInterval = 5 * 60 * 1000; // 默认缓存清理间隔(5分钟)
private cacheCleanupTimer: number | null = null; // // 缓存清理定时器
private refreshQueue: Array<RefreshQueueItem> = []; // 刷新队列
// 请求默认配置
private defaultRequestConfig: IRequestConfig = {
showLoading: true,
showToast: true,
cancelable: true,
preventDuplicate: true,
requestId: "",
cache: 0,
forceRefresh: false,
};
private isRefreshingToken: boolean = false; // 是否正在刷新Token
constructor(config?: AxiosRequestConfig) {
// 创建唯一的axios实例
this.axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_HOST,
timeout: this.requestTimeout,
headers: {
"Content-Type": "application/json;charset=UTF-8",
Accept: "application/json",
},
...config,
});
// 配置拦截器
this.configureRequestInterceptors(this.axiosInstance);
this.configureResponseInterceptors(this.axiosInstance);
// 启动缓存清理定时器
this.startCacheCleanup();
}
// 开启loading
private openLoading(showLoading: boolean) {
if (showLoading) {
this.activeRequestsCount++;
if (this.activeRequestsCount === 1) {
this.loadingInstance = Message.loading({
duration: 0,
content: "Loading...",
});
}
}
}
// 关闭loading
private closeLoading(showLoading: boolean) {
if (showLoading) {
this.activeRequestsCount--;
if (this.activeRequestsCount <= 0) {
this.loadingInstance?.close();
this.loadingInstance = null;
this.activeRequestsCount = 0;
}
}
}
// 添加token
private setToken(config: AxiosRequestConfig) {
const userStore = useUserStore();
if (config.headers && userStore.state.token) {
config.headers["Authorization"] = userStore.state.token;
}
}
// ==================== 接口取消方法 ====================
// 收集可取消的请求
private collectCancelableRequest(config: CustomAxiosRequestConfig) {
const requestConfig = config.requestConfig as IRequestConfig;
if (requestConfig.cancelable) {
// 添加取消函数
const controller = new AbortController();
config.signal = controller.signal;
if (this.abortControllers.size >= this.cancelMaxCount) {
const firstKey = this.abortControllers.keys().next().value;
this.abortControllers.delete(firstKey!);
}
this.abortControllers.set(requestConfig.requestId, controller);
}
}
// 取消所有未完成的请求
public cancelAbortControllersAll() {
this.abortControllers.forEach((controller) => controller.abort());
this.abortControllers.clear();
}
// 移除取消的请求
private removeAbortControllers(requestId: string) {
this.abortControllers.delete(requestId);
}
// ==================== 缓存相关方法 ====================
// 获取缓存数据
private getCache<T>(requestId: string): T | undefined {
const cacheItem = this.cacheMap.get(requestId);
if (!cacheItem) {
return undefined;
}
// 检查缓存是否过期
if (Date.now() > cacheItem.expireTime) {
// 缓存已过期,删除并返回 undefined
this.cacheMap.delete(requestId);
return undefined;
}
return cacheItem.data as T;
}
// 设置缓存
private setCache<T>(requestId: string, data: T, cacheTime: number): void {
const now = Date.now();
const cacheItem: CacheItem<T> = {
data,
expireTime: now + cacheTime,
timestamp: now,
};
this.cacheMap.set(requestId, cacheItem);
}
// 启动缓存清理定时器
private startCacheCleanup() {
// 清除已存在的定时器
if (this.cacheCleanupTimer) {
clearInterval(this.cacheCleanupTimer);
}
this.cacheCleanupTimer = setInterval(() => {
this.cleanupExpiredCache();
}, this.cacheCleanupInterval);
}
// 清理过期缓存
private cleanupExpiredCache() {
const now = Date.now();
for (const [requestId, cacheItem] of this.cacheMap.entries()) {
if (now > cacheItem.expireTime) {
this.cacheMap.delete(requestId);
}
}
}
// 配置请求拦截器
private configureRequestInterceptors(instance: AxiosInstance) {
instance.interceptors.request.use(
(config) => {
this.setToken(config);
const requestConfig = (config as CustomAxiosRequestConfig)
.requestConfig as IRequestConfig;
if (requestConfig.preventDuplicate) {
// 如果已经存在相同的请求,抛出错误
if (this.pendingRequests.has(requestConfig.requestId)) {
return Promise.reject(new Error("exception:repeat"));
}
this.pendingRequests.add(requestConfig.requestId);
}
this.openLoading(requestConfig.showLoading);
this.collectCancelableRequest(config);
return config;
},
(error) => {
// 捕获请求拦截器的错误
return Promise.reject(error);
}
);
}
// ==================== 登录过期方法 ====================
// 认证失败,重新登录
private handleAuthenticationExpiry(config: AxiosResponse["config"]) {
/**
* 登录过期后,之前的Promise状态继续保持pending,直到登录成功或者失败
*/
if (this.isRefreshingToken) {
return new Promise((resolve, reject) => {
this.addToRefreshQueue({
resolve,
reject,
retryFn: () => this.request(config),
});
});
} else {
this.isRefreshingToken = true;
return new Promise((resolve, reject) => {
this.addToRefreshQueue({
resolve,
reject,
retryFn: () => this.request(config),
});
const userStore = useUserStore();
userStore.refreshToken().then(
() => {
this.isRefreshingToken = false;
this.processRefreshQueue();
},
(error) => {
// 重新登录失败
reject(error);
this.isRefreshingToken = false;
this.clearRefreshQueue(error);
userStore.logout();
}
);
});
}
}
// 加入刷新队列
private addToRefreshQueue(refreshQueue: RefreshQueueItem) {
this.refreshQueue.push(refreshQueue);
}
// 刷新队列处理
private processRefreshQueue() {
while (this.refreshQueue.length > 0) {
const { retryFn, resolve, reject } =
this.refreshQueue.shift() as RefreshQueueItem;
retryFn().then(resolve, reject);
}
}
// 刷新异常处理
private clearRefreshQueue(error: unknown) {
while (this.refreshQueue.length > 0) {
const { reject } = this.refreshQueue.shift() as RefreshQueueItem;
reject(error);
}
}
// 配置响应拦截器
private configureResponseInterceptors(instance: AxiosInstance) {
instance.interceptors.response.use(
(res: AxiosResponse) => {
const requestConfig = (res.config as CustomAxiosRequestConfig)
.requestConfig as IRequestConfig;
if (`${res.data?.code}` === ResponseCode.SUCCESS) {
return Promise.resolve(res.data.data);
} else if (`${res.data?.code}` === ResponseCode.AUTH_EXPIRED) {
this.finishRequest(requestConfig);
return this.handleAuthenticationExpiry(res.config);
}
if (requestConfig.showToast) {
Message.warning(res.data.message ?? "接口异常");
}
return Promise.reject(res.data);
},
(error: AxiosError) => {
const errorMessage = this.handleResponseError(error);
return Promise.reject(new Error(errorMessage));
}
);
}
// 错误处理
private handleResponseError(error: AxiosError | Error): string {
let showToast: boolean = true;
let errorMessage: string = "请求失败";
if (axios.isCancel(error)) {
showToast = false;
errorMessage = "请求已取消";
} else if (axios.isAxiosError(error)) {
const config = error.config as CustomAxiosRequestConfig;
if (config && config.requestConfig) {
const requestConfig = config.requestConfig as IRequestConfig;
showToast = requestConfig.showToast;
}
if (error?.response) {
const { status, data } = error.response;
const responseData = data as BaseResponse;
switch (status) {
case 400:
errorMessage = responseData?.message || "请求参数错误";
break;
case 401:
errorMessage = "未授权,请重新登录";
break;
case 403:
errorMessage = "拒绝访问";
break;
case 404:
errorMessage = "请求的资源不存在";
break;
case 500:
errorMessage = "服务器内部错误";
case 502:
case 503:
case 504:
errorMessage = "服务器暂时不可用,请稍后重试";
break;
default:
errorMessage = responseData?.message || `请求失败 (${status})`;
}
} else if (error.request) {
errorMessage = "网络连接失败,请检查网络设置";
} else {
errorMessage = error.message || "请求配置错误";
}
} else if (error?.message === "exception:repeat") {
errorMessage = "正在处理中,请勿重复操作";
} else {
errorMessage = typeof error === "string" ? error : "请求异常,请稍后重试";
}
if (showToast) {
Message.warning(errorMessage);
}
return errorMessage;
}
// 生成请求唯一标识
private generateRequestId(axiosConfig: AxiosRequestConfig): string {
const method = (axiosConfig.method || "GET").toUpperCase();
const url = axiosConfig.url || "";
let params = method === "GET" ? axiosConfig.params : axiosConfig.data;
if (params instanceof FormData) {
// FormData 不好序列化,可以使用特殊标识
params = "[FormData]";
} else if (params instanceof URLSearchParams) {
params = Object.fromEntries(params.entries());
}
const paramsStr = params ? this.simpleStableStringify(params) : "";
return `${method}:${url}:${paramsStr}`.toLocaleLowerCase();
}
// 参数序列化
private simpleStableStringify(obj: Params): string {
if (!obj || typeof obj !== "object") {
return JSON.stringify(obj);
}
if (Array.isArray(obj)) {
return `[${obj
.map((item) => this.simpleStableStringify(item))
.join(",")}]`;
}
const sortedKeys = Object.keys(obj).sort();
const parts = sortedKeys.map((key) => {
return `${key}:${this.simpleStableStringify(obj[key] as Params)}`;
});
return `{${parts.join(",")}}`;
}
// 统一的请求收尾清理方法
private finishRequest(requestConfig: IRequestConfig) {
// 移除取消控制器
if (requestConfig.cancelable) {
this.removeAbortControllers(requestConfig.requestId);
}
// 移除重复请求标记
if (requestConfig.preventDuplicate) {
this.pendingRequests.delete(requestConfig.requestId);
}
// 关闭loading
this.closeLoading(requestConfig.showLoading);
}
// ==================== 公共请求方法 ====================
public async request<T>(axiosConfig: CustomAxiosRequestConfig): Promise<T> {
// 设置默认请求方法为 POST
if (!axiosConfig.method) {
axiosConfig.method = "post";
}
// 合并配置
const mergedConfig: CustomAxiosRequestConfig = {
...axiosConfig,
requestConfig: {
...this.defaultRequestConfig,
...axiosConfig.requestConfig,
},
};
const requestConfig = mergedConfig.requestConfig as IRequestConfig;
requestConfig.requestId = this.generateRequestId(mergedConfig);
// 检查缓存
if (requestConfig.cache > 0 && !requestConfig.forceRefresh) {
const cachedData = this.getCache<T>(requestConfig.requestId);
if (cachedData !== undefined) {
// 如果有缓存且未过期,直接返回缓存数据
return Promise.resolve(cachedData);
}
}
try {
const response = await this.axiosInstance.request<T, T>(mergedConfig);
// 如果配置了缓存,存储响应数据
if (requestConfig.cache > 0) {
this.setCache<T>(
requestConfig.requestId,
response,
requestConfig.cache
);
}
return response;
} finally {
this.finishRequest(requestConfig);
}
}
public get<T>(
url: string,
params?: Params,
config?: CustomAxiosRequestConfig
) {
return this.request<T>({
url,
method: "get",
params,
...config,
});
}
public post<T>(
url: string,
data?: Params,
config?: CustomAxiosRequestConfig
) {
return this.request<T>({
url,
method: "post",
data,
...config,
});
}
public put<T>(url: string, data?: Params, config?: CustomAxiosRequestConfig) {
return this.request<T>({
url,
method: "put",
data,
...config,
});
}
public delete<T>(
url: string,
data?: Params,
config?: CustomAxiosRequestConfig
) {
return this.request<T>({
url,
method: "delete",
data,
...config,
});
}
public upload<T>(
url: string,
formData: FormData,
config?: CustomAxiosRequestConfig
) {
return this.request<T>({
url,
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data",
...config?.headers,
},
timeout: 120000, // 上传设置2分钟超时
...config,
});
}
}
// 创建默认实例
const defaultHttpRequest = new HttpRequest();
// 也可以导出类,以便需要时创建新实例
export { HttpRequest };
export default defaultHttpRequest;ts
// src/stores/user.ts
import { loginApi, refreshTokenApi } from "@/api/auth";
import type { Params } from "@/models/base";
import { defineStore } from "pinia";
import { computed, reactive } from "vue";
const defaultState = {
username: "",
token: "",
refreshToken: "",
userId: "",
};
export const useUserStore = defineStore(
"user",
() => {
// State
const state = reactive({
...defaultState,
});
// Getters
const isLogin = computed(() => Boolean(state.token));
// Actions
const login = async (data: Params) => {
const res = await loginApi(data);
Object.assign(state, res);
};
const refreshToken = async () => {
const res = await refreshTokenApi(state.refreshToken);
Object.assign(state, res);
};
const logout = () => {
Object.assign(state, defaultState);
};
return { state, isLogin, login, refreshToken, logout };
},
{
persist: {
storage: localStorage,
key: "user-state",
pick: ["state"],
},
}
);ts
// src/constants/responseCode.ts
// 在常量文件中定义-必须是字符串
export const ResponseCode = {
SUCCESS: "0", // 请求成功
AUTH_EXPIRED: "0200002", // 登录过期
} as const;ts
// src/models/base.ts
// 基础响应数据格式
export type BaseResponse<T = unknown> = {
code: string;
message: string;
data: T;
};
// 基础分页响应数据格式
export type BasePaginatorResponse<T = unknown> = BaseResponse<{
list: Array<T>;
paginator: {
total_page: number;
total_record: number;
current_page: number;
page_size: number;
};
}>;
// 请求参数
export interface Params {
[key: string]: unknown;
}