Vue3项目里,Axios怎么用才不算搬砖式套模板?还要兼顾拦截、统一处理、Token刷新这些常用需求
很多人第一次接触Vue3+Axios,可能会直接在每个组件里import axios,然后写重复的请求URL前缀、错误提示、Loading状态,这样写出来的代码既乱又难维护,稍微改个接口地址或者添加个全局参数,就得翻遍所有组件修改——这就是典型的“搬砖式套模板”,今天咱们就从基础配置、分层封装、常见痛点解决三个维度,一步步搭一套能用、好用、能扩展的Vue3 Axios架构。
第一步:基础配置,把重复的公共部分先拎出来
首先得理清楚,一个成熟的前端请求里,哪些是100%不会变或者很少变的?请求URL的基础前缀baseURL肯定算一个,毕竟一般开发环境、测试环境、生产环境的接口地址不一样,不能每次上线前手动替换;超时时间timeout也很重要,避免网络差的时候页面一直死等;默认的请求头,比如Content-Type,大多数RESTful接口都是application/json,除非上传文件这种特殊情况,也可以先统一设置。
这些公共配置,单独放一个文件会更清晰,比如在src目录下建个utils文件夹,里面加个request.js作为Axios的主入口,安装axios和配套的类型提示库@types/axios(如果用TypeScript的话,强烈建议加上,IDE的补全和报错会很方便),可以用npm或者pnpm:
// 这是JavaScript版本的基础配置,TypeScript的话后面会提
import axios from 'axios';
// 先创建一个axios实例,避免污染全局的axios
const instance = axios.create({
// 基础前缀,开发环境可以用.env.development里的VITE_APP_API_BASE_URL(如果用Vite脚手架)
// 或者Vue CLI的话是process.env.VUE_APP_API_BASE_URL
baseURL: import.meta.env.VITE_APP_API_BASE_URL || 'http://localhost:3000/api',
// 超时时间,一般设10-30秒就行,看项目接口响应速度
timeout: 15000,
// 默认请求头
headers: {
'Content-Type': 'application/json;charset=utf-8'
}
});
这里提一下,用环境变量管理基础前缀是必须的,因为不同环境的接口地址切换不需要改代码,Vite和Vue CLI的环境变量规则不一样,要注意前缀:Vite的环境变量必须以VITE_开头,Vue CLI必须以VUE_APP_开头,这样才能在前端代码里通过import.meta.env或者process.env访问到,同时避免把后端敏感的环境变量泄露出来。
第二步:分层封装,不是简单的加个拦截器
很多人觉得Axios封装就是加两个拦截器——请求拦截和响应拦截,但如果只这么做,还是不够灵活,比如有的接口需要特殊的超时时间,有的不需要Loading,有的不需要错误提示,有的甚至要跳过Token校验,这时候就需要在拦截器的基础上,再加一层“请求方法封装”,给每个请求加配置参数,满足个性化需求。
先写请求和响应拦截器
请求拦截器主要做两件事:一是在请求发送前给Header加Token(如果有的话,Token一般存到localStorage或者sessionStorage,或者Pinia/Vuex的状态管理里),二是开启全局Loading(如果需要的话)。
响应拦截器就复杂一点了,首先要处理HTTP状态码,比如200是正常响应,401是未授权(需要跳转登录页或者刷新Token),403是禁止访问,404是接口不存在,500是服务器内部错误;然后还要处理业务状态码,因为很多后端不会直接把错误抛在HTTP状态码里,而是会在响应体里加个code字段,比如code=200是成功,code=400是参数错误,code=401是Token过期;最后还要关闭全局Loading,并且统一处理成功和失败的返回值,让组件里的代码不用再判断HTTP状态码,直接拿业务数据或者错误信息就行。
这里要注意的是,Pinia/Vuex的状态管理不能直接在request.js里import useStore然后调用,因为Pinia的store是在Vue应用挂载之后才初始化的,而request.js可能会在应用挂载之前就被其他文件引用,这时候直接调用useStore会报错,解决办法是在请求发送前,通过window.PINIA(Pinia的全局实例)来获取store,或者把store的实例保存到一个全局变量里。
响应拦截器里的全局Loading,也可以用Element Plus、Ant Design Vue这类UI库的Loading服务,或者自己写一个简单的Loading组件,通过Pinia/Vuex来控制显示和隐藏。
再加一层请求方法封装
这一步是为了满足个性化需求,我们可以封装get、post、put、delete这四个常用的请求方法,每个方法接受三个参数:url(接口路径)、data(请求参数)、config(个性化配置),个性化配置可以包括:不需要Loading、不需要错误提示、不需要Token、特殊的超时时间、特殊的请求头等等。
比如我们可以给config加一个noLoading字段,如果是true的话,就不开启全局Loading;加一个noErrorToast字段,如果是true的话,就不显示全局错误提示;加一个noToken字段,如果是true的话,就不在Header里加Token。
// 继续写request.js,假设我们用的是Element Plus的ElMessage和ElLoading
import { ElMessage, ElLoading } from 'element-plus';
import { useUserStore } from '@/stores/user'; // TypeScript里先import,不过JavaScript里后面可以通过window.__PINIA__获取
// 全局Loading的实例
let loadingInstance = null;
// Loading的计数器,避免多个请求同时触发时,一个请求结束就关闭Loading
let loadingCount = 0;
// 开启Loading
const startLoading = () => {
if (loadingCount === 0) {
loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(0, 0, 0, 0.7)'
});
}
loadingCount++;
};
// 关闭Loading
const endLoading = () => {
loadingCount--;
if (loadingCount <= 0) {
loadingInstance?.close();
}
};
// 请求拦截器
instance.interceptors.request.use(
(config) => {
// 获取个性化配置
const customConfig = config.custom || {};
// 如果没有设置noLoading,就开启Loading
if (!customConfig.noLoading) {
startLoading();
}
// 获取Token,这里用window.__PINIA__获取userStore,TypeScript的话要类型断言
const pinia = window.__PINIA__;
if (pinia) {
const userStore = useUserStore(pinia);
const token = userStore.token;
// 如果有Token,并且没有设置noToken,就加到Header里
if (token && !customConfig.noToken) {
config.headers.Authorization = `Bearer ${token}`;
}
}
// 如果有特殊的超时时间,就覆盖默认的
if (customConfig.timeout) {
config.timeout = customConfig.timeout;
}
// 如果有特殊的请求头,就合并
if (customConfig.headers) {
config.headers = { ...config.headers, ...customConfig.headers };
}
return config;
},
(error) => {
// 请求发送失败,关闭Loading
endLoading();
return Promise.reject(error);
}
);
// 响应拦截器
instance.interceptors.response.use(
(response) => {
// 先关闭Loading
endLoading();
// 获取响应数据
const res = response.data;
// 获取个性化配置
const customConfig = response.config.custom || {};
// 先判断业务状态码,这里假设后端返回的code=200是成功
if (res.code === 200) {
// 成功的话,直接返回业务数据
return res.data || true;
} else if (res.code === 401) {
// Token过期或者未授权,先清空用户信息,然后跳转登录页
const pinia = window.__PINIA__;
if (pinia) {
const userStore = useUserStore(pinia);
userStore.logout();
}
// 如果是单页应用,这里用router.push('/login'),router也要通过全局变量获取,比如window.__VUE_ROUTER__
const router = window.__VUE_ROUTER__;
if (router) {
router.push('/login');
}
// 显示错误提示,除非设置了noErrorToast
if (!customConfig.noErrorToast) {
ElMessage.error(res.message || '登录已过期,请重新登录');
}
return Promise.reject(res);
} else {
// 其他业务错误,显示错误提示
if (!customConfig.noErrorToast) {
ElMessage.error(res.message || '请求失败,请稍后重试');
}
return Promise.reject(res);
}
},
(error) => {
// HTTP错误,关闭Loading
endLoading();
// 获取个性化配置
const customConfig = error.config?.custom || {};
// 判断HTTP状态码
let message = '';
if (error.response) {
switch (error.response.status) {
case 400:
message = '请求参数错误';
break;
case 403:
message = '禁止访问';
break;
case 404:
message = '接口不存在';
break;
case 500:
message = '服务器内部错误';
break;
case 502:
message = '网关错误';
break;
case 503:
message = '服务器暂时不可用';
break;
case 504:
message = '网关超时';
break;
default:
message = `请求失败,状态码:${error.response.status}`;
}
} else if (error.request) {
// 请求已发送,但没有收到响应
message = '网络连接失败,请检查网络';
} else {
// 请求配置出错
message = error.message || '请求配置出错';
}
// 显示错误提示,除非设置了noErrorToast
if (!customConfig.noErrorToast) {
ElMessage.error(message);
}
return Promise.reject(error);
}
);
// 封装请求方法
const request = {
get: (url, params = {}, custom = {}) => {
return instance.get(url, {
params,
custom
});
},
post: (url, data = {}, custom = {}) => {
return instance.post(url, data, {
custom
});
},
put: (url, data = {}, custom = {}) => {
return instance.put(url, data, {
custom
});
},
delete: (url, params = {}, custom = {}) => {
return instance.delete(url, {
params,
custom
});
},
// 上传文件的方法,单独封装,因为Content-Type要改成multipart/form-data
upload: (url, formData, custom = {}) => {
return instance.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
custom
});
}
};
// 导出请求方法
export default request;
这里还有一个小技巧,就是Loading的计数器,避免多个请求同时触发时,一个请求结束就关闭Loading,比如我们同时请求A、B、C三个接口,loadingCount会从0变成1、2、3,当A结束时,loadingCount变成2,不关闭Loading,当B结束时变成1,也不关闭,只有当C结束时变成0,才会关闭Loading。
第三步:常见痛点解决,比如Token刷新、接口取消、跨域
刚才的封装已经能满足大部分日常需求了,但还有几个常见的痛点需要解决:比如Token过期了,能不能不用用户重新登录,直接刷新Token?比如用户快速点击两次提交按钮,能不能取消第一次请求?比如开发环境下的跨域问题怎么解决?
Token刷新的实现
Token刷新的逻辑是这样的:当接口返回401(Token过期)时,我们先检查有没有正在刷新的Token请求,如果有的话,就把后续的请求都缓存起来,等Token刷新成功后,再重新发送这些缓存的请求;如果没有正在刷新的Token请求,就发起刷新Token的请求,刷新成功后,更新Pinia/Vuex里的Token和刷新Token,然后清空缓存的请求,重新发送,刷新失败的话,就清空缓存的请求,清空用户信息,跳转登录页。
要实现这个逻辑,我们需要加几个变量:比如isRefreshing(是否正在刷新Token),requestQueue(缓存的请求队列),然后在响应拦截器的401分支里处理。
// 在request.js的开头加变量
import { ElMessage, ElLoading } from 'element-plus';
import { useUserStore } from '@/stores/user';
let loadingInstance = null;
let loadingCount = 0;
let isRefreshing = false; // 是否正在刷新Token
let requestQueue = []; // 缓存的请求队列,每个元素是一个函数,接收新的Token作为参数
// 响应拦截器的401分支修改一下
} else if (res.code === 401) {
const customConfig = response.config.custom || {};
// 如果是刷新Token的接口返回401,说明刷新Token也过期了,直接跳转登录页
if (response.config.url === '/api/auth/refresh') {
const pinia = window.__PINIA__;
if (pinia) {
const userStore = useUserStore(pinia);
userStore.logout();
}
const router = window.__VUE_ROUTER__;
if (router) {
router.push('/login');
}
if (!customConfig.noErrorToast) {
ElMessage.error('登录已过期,请重新登录');
}
isRefreshing = false;
requestQueue = [];
return Promise.reject(res);
}
// 如果没有正在刷新的Token请求,就发起刷新
if (!isRefreshing) {
isRefreshing = true;
const pinia = window.__PINIA__;
if (pinia) {
const userStore = useUserStore(pinia);
const refreshToken = userStore.refreshToken;
// 发起刷新Token的请求,这里要跳过Token校验,并且不显示错误提示,不开启Loading
request.post('/api/auth/refresh', { refreshToken }, { noToken: true, noErrorToast: true, noLoading: true })
.then((newToken) => {
// 刷新成功,更新Token
userStore.setToken(newToken);
// 重新发送缓存的请求
requestQueue.forEach((callback) => callback(newToken));
// 清空缓存的请求
requestQueue = [];
})
.catch(() => {
// 刷新失败,清空缓存的请求,清空用户信息,跳转登录页
requestQueue = [];
userStore.logout();
const router = window.__VUE_ROUTER__;
if (router) {
router.push('/login');
}
if (!customConfig.noErrorToast) {
ElMessage.error('登录已过期,请重新登录');
}
})
.finally(() => {
isRefreshing = false;
});
}
}
// 把当前请求缓存起来,返回一个Promise
return new Promise((resolve) => {
requestQueue.push((newToken) => {
// 更新当前请求的Authorization头
response.config.headers.Authorization = `Bearer ${newToken}`;
// 重新发送当前请求
resolve(instance(response.config));
});
});
这里要注意的是,刷新Token的接口返回的应该是新的Token和新的刷新Token,这样Token可以无限刷新(只要刷新Token不过期),刷新Token的请求要跳过Token校验,并且不显示错误提示,不开启Loading,避免和其他请求冲突。
接口取消的实现
接口取消的逻辑是这样的:每个请求都生成一个CancelToken,然后把CancelToken的cancel函数保存到一个Map里,Map的key可以是请求的URL+方法+参数的组合,也可以是接口的唯一标识,当需要取消请求时,就从Map里找到对应的cancel函数,调用它,然后从Map里删除。
比如我们要实现用户快速点击两次提交按钮,取消第一次请求,就可以在提交按钮的点击事件里,先取消上次的请求,然后再发起新的请求。
// 在request.js的开头加一个Map
import axios, { CancelToken } from 'axios'; // 注意要导入CancelToken
let loadingInstance = null;
let loadingCount = 0;
let isRefreshing = false;
let requestQueue = [];
const cancelTokenMap = new Map(); // 保存cancel函数的Map
// 请求拦截器里修改,生成CancelToken
instance.interceptors.request.use(
(config) => {
const customConfig = config.custom || {};
// 如果没有设置noLoading,就开启Loading
if (!customConfig.noLoading) {
startLoading();
}
// 获取唯一标识,默认是URL+方法
const cancelKey = customConfig.cancelKey || `${config.method}-${config.url}`;
// 如果Map里已经有这个cancelKey,就取消上次的请求
if (cancelTokenMap.has(cancelKey)) {
const cancel = cancelTokenMap.get(cancelKey);
cancel('重复请求,已取消');
}
// 生成新的CancelToken
config.cancelToken = new CancelToken((cancel) => {
cancelTokenMap.set(cancelKey, cancel);
});
// 其他代码不变
const pinia = window.__PINIA__;
if (pinia) {
const userStore = useUserStore(pinia);
const token = userStore.token;
if (token && !customConfig.noToken) {
config.headers.Authorization = `Bearer ${token}`;
}
}
if (customConfig.timeout) {
config.timeout = customConfig.timeout;
}
if (customConfig.headers) {
config.headers = { ...config.headers, ...customConfig.headers };
}
return config;
},
(error) => {
endLoading();
return Promise.reject(error);
}
);
// 响应拦截器里修改,请求成功或失败后,从Map里删除cancelKey
instance.interceptors.response.use(
(response) => {
endLoading();
const customConfig = response.config.custom || {};
const cancelKey = customConfig.cancelKey || `${response.config.method}-${response.config.url}`;
cancelTokenMap.delete(cancelKey);
// 其他代码不变
const res = response.data;
if (res.code === 200) {
return res.data || true;
} else if (res.code === 401) {
// 401的处理不变
} else {
if (!customConfig.noErrorToast) {
ElMessage.error(res.message || '请求失败,请稍后重试');
}
return Promise.reject(res);
}
},
(error) => {
endLoading();
const customConfig = error.config?.custom || {};
// 如果是取消的请求,不显示错误提示
if (axios.isCancel(error)) {
return Promise.reject(error);
}
const cancelKey = customConfig?.cancelKey || `${error.config?.method}-${error.config?.url}`;
if (cancelKey) {
cancelTokenMap.delete(cancelKey);
}
// 其他代码不变
let message = '';
if (error.response) {
// ...
} else if (error.request) {
// ...
} else {
// ...
}
if (!customConfig.noErrorToast) {
ElMessage.error(message);
}
return Promise.reject(error);
}
);
// 封装一个手动取消请求的方法
request.cancel = (cancelKey) => {
if (cancelTokenMap.has(cancelKey)) {
const cancel = cancelTokenMap.get(cancelKey);
cancel('手动取消请求');
cancelTokenMap.delete(cancelKey);
}
};
// 封装一个取消所有请求的方法
request.cancelAll = () => {
cancelTokenMap.forEach((cancel) => {
cancel('页面卸载,取消所有请求');
});
cancelTokenMap.clear();
};
这样,在组件里使用的时候,就可以通过设置cancelKey来取消重复请求,或者手动调用request.cancel(cancelKey)来取消指定请求,或者在页面卸载的onUnmounted钩子函数里调用request.cancelAll()来取消所有请求,避免内存泄漏。
开发环境下的跨域问题解决
跨域是浏览器的同源策略导致的,同源策略要求协议、域名、端口都相同,否则就会被浏览器拦截,开发环境下的跨域问题,可以通过Vite或者Vue CLI的代理功能来解决。
如果用的是Vite,在vite.config.js里加一个proxy配置:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
open: true,
proxy: {
// 当请求的路径以/api开头时,就代理到http://localhost:3000
'/api': {
target: 'http://localhost:3000',
changeOrigin: true, // 改变请求的origin为target的origin,避免后端因为origin不一致而拒绝请求
rewrite: (path) => path.replace(/^\/api/, '') // 如果后端的接口路径没有/api前缀,就把/api去掉
}
}
}
});
如果用的是Vue CLI,在vue.config.js里加一个devServer.proxy配置:
module.exports = {
devServer: {
port: 8080,
open: true,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}
}
}
};
这里要注意的是,代理功能只在开发环境下有效,生产环境下的跨域问题,需要后端配置CORS(跨域资源共享),或者用Nginx反向代理。
第四步:TypeScript版本的优化
如果用的是TypeScript,可以给Axios的配置、响应数据、个性化配置都加类型提示,这样IDE的补全和报错会更友好,代码也更规范。
定义响应数据的类型:
// src/types/api.ts
// 基础响应类型
export interface BaseResponse<T = any> {
code: number;
message: string;
data: T;
}
// 用户信息类型
export interface UserInfo {
id: number;
username: string;
avatar: string;
}
// 登录请求参数类型
export interface LoginParams {
username: string;
password: string;
}
// 登录响应数据类型
export interface LoginData {
token: string;
refreshToken: string;
userInfo: UserInfo;
}
给Axios的配置加custom字段的类型:
// src/utils/request.ts
import axios, { AxiosRequestConfig, CancelToken } from 'axios';
import { ElMessage, ElLoading } from 'element-plus';
import { useUserStore } from '@/stores/user';
import type { BaseResponse } from '@/types/api';
// 定义个性化配置的类型
interface CustomConfig {
noLoading?: boolean;
noErrorToast?: boolean;
noToken?: boolean;
timeout?: number;
headers?: Record<string, string>;
cancelKey?: string;
}
// 扩展AxiosRequestConfig,添加custom字段
interface RequestConfig extends AxiosRequestConfig {
custom?: CustomConfig;
}
// 扩展AxiosInstance,使用RequestConfig
const instance = axios.create({
baseURL: import.meta.env.VITE_APP_API_BASE_URL || 'http://localhost:3000/api',
timeout: 15000,
headers: {
'Content-Type': 'application/json;charset=utf-8'
}
}) as unknown as {
// 扩展instance的request方法,使用RequestConfig
request<T = any>(config: RequestConfig): Promise<T>;
get<T = any>(url: string, config?: RequestConfig): Promise<T>;
post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T>;
put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T>;
delete<T = any>(url: string, config?: RequestConfig): Promise<T>;
};
// 其他代码和JavaScript版本差不多,只是要加类型断言
// 比如获取Pinia和userStore的时候
const pinia = window.__PINIA__ as any;
if (pinia) {
const userStore = useUserStore(pinia);
// ...
}
// 比如获取router的时候
const router = window.__VUE_ROUTER__ as any;
if (router) {
router.push('/login');
}
在组件里使用的时候,就可以指定响应数据的类型:
// src/views/Login.vue
<script setup lang="ts">
import { ref } from 'vue';
import request from '@/utils/request';
import type { LoginParams, LoginData, BaseResponse } from '@/types/api';
const username = ref('');
const password = ref('');
const handleLogin = async () => {
try {
// 指定响应数据的类型为LoginData
const data = await request.post<LoginData>('/api/auth/login', {
username: username.value,
password: password.value
} as LoginParams);
console.log(data);
// 这里的data会有类型提示,比如data.token、data.refreshToken、data.userInfo
} catch (error) {
console.error(error);
}
};
</script>
今天咱们从基础配置、分层封装、常见痛点解决三个维度,搭了一套能用、好用、能扩展的Vue3 Axios架构,这套架构不仅解决了搬砖式套模板的问题,还兼顾了拦截、统一处理、Token刷新、接口取消、跨域这些常用需求,不管是Vue3新手还是初中级开发,都可以直接拿来用,或者根据自己的项目需求进行修改。
Axios的封装没有绝对的标准答案,不同的项目有不同的需求,比如有的项目需要签名校验,有的项目需要加密传输,这些都可以在这套架构的基础上进行扩展,希望这篇文章能帮到大家,如果有什么问题或者建议,欢迎在评论区留言讨论。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


