Vue 工程中 Axios 二次封装:统一请求拦截、响应处理、取消请求的新旧两种方案
Vue 工程中 Axios 二次封装:统一请求拦截、响应处理、取消请求的新旧两种方案
一、前言
在 Vue 项目中,我们通常不会直接在页面里到处写 axios.get()、axios.post(),而是会对 Axios 做一层统一封装,用来处理:
- 统一
baseURL - 统一请求超时时间
- 统一请求头,例如 Token
- 统一响应数据格式
- 统一错误提示
- 页面切换时取消请求
- 搜索框连续输入时取消上一次请求
- 防止重复请求
Axios 官方从 v0.22.0 开始支持通过 AbortController 取消请求,这是现在推荐的方式;旧的 CancelToken 方式已经被官方标记为 deprecated,后续新项目不建议继续使用。
参考文档:
- Axios Cancellation 官方文档:https://axios-http.com/docs/cancellation
- Axios Interceptors 官方文档:https://axios-http.com/docs/interceptors
本文会给出两套完整封装:
- 新方案:
AbortController取消请求 - 旧方案:
CancelToken取消请求
二、安装 Axios
npm install axios
或者:
pnpm add axios
三、推荐项目目录结构
src
├── api
│ └── user.js
├── utils
│ ├── request.js # 推荐:AbortController 版本
│ └── request-cancel-token.js # 旧项目:CancelToken 版本
├── views
│ └── UserSearch.vue
└── router
└── index.js
四、理解 baseURL 的作用
baseURL 可以理解为:给所有接口地址统一加一个公共前缀。
例如:
const service = axios.create({
baseURL: "https://api.example.com"
});
后面调用:
service.get("/users");
service.post("/login");
实际请求地址分别是:
https://api.example.com/users
https://api.example.com/login
4.1 Vite 项目中常见写法
.env.development:
VITE_API_BASE_URL=/api
request.js:
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL
});
接口调用:
service.get("/user/list");
浏览器实际请求地址:
/api/user/list
如果当前前端运行在:
http://localhost:5173
完整地址就是:
http://localhost:5173/api/user/list
4.2 baseURL 和 Vite proxy 的关系
开发环境中,/api 通常会配合 Vite 代理使用:
export default {
server: {
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, "")
}
}
}
};
完整流程:
axios 写:
request.get("/user/list")
baseURL 拼接后:
/api/user/list
浏览器实际请求:
http://localhost:5173/api/user/list
Vite 代理转发到:
http://localhost:3000/user/list
如果不写 rewrite,那么后端收到的就是:
http://localhost:3000/api/user/list
所以是否需要 rewrite,取决于后端接口本身有没有 /api 这一层路径。
五、新方案:使用 AbortController 封装 Axios
AbortController 是目前推荐使用的取消请求方式。
适合场景:
- Vue 3 新项目
- Axios
0.22.0+ - Axios 1.x 项目
- 搜索框取消上一次请求
- 页面切换取消未完成请求
- 防止重复请求
5.1 新建环境变量
在项目根目录创建 .env.development:
VITE_API_BASE_URL=/api
生产环境可以创建 .env.production:
VITE_API_BASE_URL=https://api.example.com
如果你不是 Vite,而是 Vue CLI,可以改成:
VUE_APP_API_BASE_URL=/api
Vite 中读取方式是:
import.meta.env.VITE_API_BASE_URL
Vue CLI 中读取方式是:
process.env.VUE_APP_API_BASE_URL
5.2 Axios 封装文件:src/utils/request.js
import axios from "axios";
/**
* pendingMap 用来保存当前正在进行中的请求
* key: 请求唯一标识
* value: AbortController 实例
*/
const pendingMap = new Map();
/**
* 生成请求唯一 key
* 默认根据 method + url + params + data 判断是否是同一个请求
*/
function getPendingKey(config) {
const { method, url, params, data } = config;
return [
method,
url,
JSON.stringify(params || {}),
JSON.stringify(data || {}),
].join("&");
}
/**
* 判断是否是取消请求导致的错误
*/
export function isCancelError(error) {
return (
axios.isCancel(error) ||
error?.code === "ERR_CANCELED" ||
error?.name === "CanceledError"
);
}
/**
* 添加请求到 pendingMap
*/
function addPending(config) {
// cancelable === false 表示该请求不需要加入取消队列
if (config.cancelable === false) return;
const pendingKey = config.cancelKey || getPendingKey(config);
config.pendingKey = pendingKey;
// 如果已经存在相同请求,先取消上一次请求
if (pendingMap.has(pendingKey)) {
const controller = pendingMap.get(pendingKey);
controller.abort();
pendingMap.delete(pendingKey);
}
const controller = new AbortController();
config.signal = controller.signal;
pendingMap.set(pendingKey, controller);
}
/**
* 请求完成后,从 pendingMap 中移除
*/
function removePending(config) {
if (!config) return;
const pendingKey = config.pendingKey || config.cancelKey || getPendingKey(config);
if (pendingMap.has(pendingKey)) {
pendingMap.delete(pendingKey);
}
}
/**
* 根据 cancelKey 手动取消某一个请求
*/
export function cancelRequest(cancelKey) {
if (!cancelKey) return;
if (pendingMap.has(cancelKey)) {
const controller = pendingMap.get(cancelKey);
controller.abort();
pendingMap.delete(cancelKey);
}
}
/**
* 取消所有正在进行中的请求
*/
export function cancelAllRequest() {
pendingMap.forEach((controller) => {
controller.abort();
});
pendingMap.clear();
}
/**
* 创建 Axios 实例
*/
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "",
timeout: 15000,
headers: {
"Content-Type": "application/json;charset=UTF-8",
},
});
/**
* 请求拦截器
*/
service.interceptors.request.use(
(config) => {
/**
* 例如从 localStorage 中获取 token
* 实际项目中可以换成 Pinia / Vuex
*/
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
/**
* 加入请求队列
* 如果有重复请求,会取消上一次
*/
addPending(config);
return config;
},
(error) => {
return Promise.reject(error);
}
);
/**
* 响应拦截器
*/
service.interceptors.response.use(
(response) => {
removePending(response.config);
/**
* 这里根据你的后端格式调整
* 常见格式:
* {
* code: 200,
* data: {},
* message: "success"
* }
*/
const res = response.data;
return res;
},
(error) => {
removePending(error.config);
if (isCancelError(error)) {
console.log("请求已取消:", error.message || error);
return Promise.reject(error);
}
if (error.response) {
const status = error.response.status;
switch (status) {
case 401:
console.error("登录已过期,请重新登录");
// 可在这里跳转登录页
// router.push("/login");
break;
case 403:
console.error("没有权限访问该资源");
break;
case 404:
console.error("请求地址不存在");
break;
case 500:
console.error("服务器内部错误");
break;
default:
console.error(`请求错误,状态码:${status}`);
}
} else if (error.request) {
console.error("服务器无响应或网络异常");
} else {
console.error("请求配置错误:", error.message);
}
return Promise.reject(error);
}
);
/**
* 统一请求方法
*/
function request(config) {
return service(config);
}
export default request;
六、接口模块封装
新建 src/api/user.js:
import request from "@/utils/request";
/**
* 获取用户列表
*/
export function getUserList(params) {
return request({
url: "/users",
method: "get",
params,
});
}
/**
* 搜索用户
*
* 这里重点看 cancelKey
* 多次调用 searchUser 时,只要上一次请求还没完成,就会被取消
*/
export function searchUser(keyword) {
return request({
url: "/users/search",
method: "get",
params: {
keyword,
},
cancelKey: "search-user",
});
}
/**
* 新增用户
*/
export function createUser(data) {
return request({
url: "/users",
method: "post",
data,
});
}
/**
* 获取用户详情
*/
export function getUserDetail(id) {
return request({
url: `/users/${id}`,
method: "get",
});
}
七、Vue 页面中使用:搜索时取消上一次请求
新建 src/views/UserSearch.vue:
<template>
<div class="page">
<h2>用户搜索</h2>
<input
v-model="keyword"
class="search-input"
placeholder="请输入用户名"
@input="handleSearch"
/>
<div v-if="loading" class="loading">
加载中...
</div>
<ul v-else class="list">
<li v-for="item in userList" :key="item.id">
{{ item.name }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref, onBeforeUnmount } from "vue";
import { searchUser } from "@/api/user";
import { cancelRequest, isCancelError } from "@/utils/request";
const keyword = ref("");
const loading = ref(false);
const userList = ref([]);
let timer = null;
/**
* 搜索防抖
*/
function handleSearch() {
clearTimeout(timer);
timer = setTimeout(() => {
doSearch();
}, 300);
}
/**
* 执行搜索
*/
async function doSearch() {
if (!keyword.value.trim()) {
userList.value = [];
return;
}
loading.value = true;
try {
const res = await searchUser(keyword.value);
/**
* 根据后端返回格式自行调整
* 假设后端返回:
* {
* code: 200,
* data: []
* }
*/
userList.value = res.data || [];
} catch (error) {
if (isCancelError(error)) {
console.log("上一次搜索请求已取消");
return;
}
console.error("搜索失败:", error);
} finally {
loading.value = false;
}
}
/**
* 页面销毁时,取消当前搜索请求
*/
onBeforeUnmount(() => {
cancelRequest("search-user");
});
</script>
<style scoped>
.page {
padding: 24px;
}
.search-input {
width: 280px;
height: 36px;
padding: 0 12px;
border: 1px solid #ccc;
border-radius: 6px;
}
.loading {
margin-top: 16px;
color: #409eff;
}
.list {
margin-top: 16px;
padding-left: 20px;
}
</style>
八、页面切换时取消所有请求
有些页面请求很多,如果用户还没等请求完成就切换路由,旧请求可能还会继续返回,导致:
- 控制台报错
- 页面数据错乱
- 内存浪费
- 旧页面请求影响新页面
可以在路由守卫中统一取消请求。
src/router/index.js:
import { createRouter, createWebHistory } from "vue-router";
import { cancelAllRequest } from "@/utils/request";
const routes = [
{
path: "/",
component: () => import("@/views/UserSearch.vue"),
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to, from, next) => {
/**
* 每次切换页面前,取消所有未完成请求
*/
cancelAllRequest();
next();
});
export default router;
注意:如果你的项目里有一些请求不希望被路由切换取消,可以在请求时加上:
request({
url: "/important-api",
method: "get",
cancelable: false,
});
九、旧方案:CancelToken 封装 Axios
CancelToken 是 Axios 老版本常见的取消请求方案。
虽然现在不推荐新项目继续使用,但很多老项目还在用,所以这里也给一份完整封装。
9.1 旧版封装文件:src/utils/request-cancel-token.js
import axios from "axios";
/**
* pendingMap 用来保存当前正在进行中的请求
* key: 请求唯一标识
* value: cancel 函数
*/
const pendingMap = new Map();
function getPendingKey(config) {
const { method, url, params, data } = config;
return [
method,
url,
JSON.stringify(params || {}),
JSON.stringify(data || {}),
].join("&");
}
export function isCancelError(error) {
return axios.isCancel(error);
}
/**
* 添加 pending 请求
*/
function addPending(config) {
if (config.cancelable === false) return;
const pendingKey = config.cancelKey || getPendingKey(config);
config.pendingKey = pendingKey;
/**
* 如果已经存在相同请求,取消上一次请求
*/
if (pendingMap.has(pendingKey)) {
const cancel = pendingMap.get(pendingKey);
cancel(`重复请求已取消:${pendingKey}`);
pendingMap.delete(pendingKey);
}
config.cancelToken = new axios.CancelToken((cancel) => {
pendingMap.set(pendingKey, cancel);
});
}
/**
* 请求完成后移除 pending
*/
function removePending(config) {
if (!config) return;
const pendingKey = config.pendingKey || config.cancelKey || getPendingKey(config);
if (pendingMap.has(pendingKey)) {
pendingMap.delete(pendingKey);
}
}
/**
* 手动取消某个请求
*/
export function cancelRequest(cancelKey) {
if (!cancelKey) return;
if (pendingMap.has(cancelKey)) {
const cancel = pendingMap.get(cancelKey);
cancel(`请求已手动取消:${cancelKey}`);
pendingMap.delete(cancelKey);
}
}
/**
* 取消所有请求
*/
export function cancelAllRequest() {
pendingMap.forEach((cancel, key) => {
cancel(`请求已取消:${key}`);
});
pendingMap.clear();
}
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "",
timeout: 15000,
headers: {
"Content-Type": "application/json;charset=UTF-8",
},
});
service.interceptors.request.use(
(config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
addPending(config);
return config;
},
(error) => {
return Promise.reject(error);
}
);
service.interceptors.response.use(
(response) => {
removePending(response.config);
return response.data;
},
(error) => {
removePending(error.config);
if (axios.isCancel(error)) {
console.log("请求已取消:", error.message);
return Promise.reject(error);
}
if (error.response) {
console.error("请求错误:", error.response.status);
} else {
console.error("网络错误或服务无响应");
}
return Promise.reject(error);
}
);
function request(config) {
return service(config);
}
export default request;
十、新旧方案对比
| 对比项 | AbortController | CancelToken |
|---|---|---|
| 推荐程度 | 推荐 | 不推荐 |
| Axios 支持版本 | 0.22.0+ |
老版本支持 |
| 是否官方推荐 | 是 | 否 |
| 是否废弃 | 否 | 是 |
| 写法 | signal |
cancelToken |
| 适合项目 | Vue 3 / 新项目 / Axios 1.x | 老项目维护 |
| 迁移建议 | 优先使用 | 逐步替换 |
十一、常见使用场景
11.1 搜索框取消上一次请求
核心配置:
export function searchUser(keyword) {
return request({
url: "/users/search",
method: "get",
params: {
keyword,
},
cancelKey: "search-user",
});
}
因为每次搜索都使用同一个 cancelKey,所以下一次请求发出时,会自动取消上一次未完成的搜索请求。
11.2 防止按钮重复提交
例如新增用户接口:
export function createUser(data) {
return request({
url: "/users",
method: "post",
data,
cancelKey: "create-user",
});
}
如果用户连续点击提交按钮,上一个未完成的提交请求会被取消。
不过对于真实业务,更推荐按钮层面也加 loading 锁:
<template>
<button :disabled="submitLoading" @click="handleSubmit">
{{ submitLoading ? "提交中..." : "提交" }}
</button>
</template>
<script setup>
import { ref } from "vue";
import { createUser } from "@/api/user";
const submitLoading = ref(false);
async function handleSubmit() {
if (submitLoading.value) return;
submitLoading.value = true;
try {
await createUser({
name: "Tom",
age: 18,
});
console.log("提交成功");
} catch (error) {
console.error("提交失败", error);
} finally {
submitLoading.value = false;
}
}
</script>
11.3 页面销毁时取消请求
import { onBeforeUnmount } from "vue";
import { cancelRequest } from "@/utils/request";
onBeforeUnmount(() => {
cancelRequest("search-user");
});
11.4 路由切换取消所有请求
import { cancelAllRequest } from "@/utils/request";
router.beforeEach((to, from, next) => {
cancelAllRequest();
next();
});
十二、常见问题
12.1 timeout 和取消请求有什么区别?
timeout 主要用于处理响应超时;取消请求则是主动中断当前请求,例如页面切换、搜索输入变化、重复提交等。
实际项目中建议两者结合使用:
const service = axios.create({
baseURL: "/api",
timeout: 15000,
});
timeout 负责防止接口长期不响应,AbortController 负责主动取消无意义请求。
12.2 为什么请求已经取消了,后端还是收到了请求?
前端取消请求,本质是浏览器端不再等待这个请求结果,或者中断当前连接。
但如果请求已经到达服务端,后端可能仍然会继续执行对应逻辑。所以对于支付、创建订单、删除数据这类关键接口,不能只依赖前端取消请求,还需要后端做幂等处理。
12.3 为什么搜索时旧数据还会覆盖新数据?
通常有两个原因:
第一,没有取消上一次请求。
第二,请求返回顺序不可控。例如第一次请求比较慢,第二次请求比较快,如果不取消第一次请求,第一次请求可能最后返回,从而覆盖第二次结果。
解决方式就是使用固定 cancelKey:
cancelKey: "search-user"
12.4 为什么不建议新项目使用 CancelToken?
因为 CancelToken 已经被 Axios 官方标记为 deprecated,官方推荐使用 AbortController。
12.5 VITE_API_BASE_URL=/api 是什么意思?
它的意思是:给 Axios 的所有请求统一拼接 /api 前缀。
例如:
VITE_API_BASE_URL=/api
request.get("/user/list");
最终浏览器请求:
/api/user/list
如果搭配 Vite proxy:
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, "")
}
}
那么后端最终收到:
http://localhost:3000/user/list
十三、最终推荐方案
新项目建议直接使用:
AbortController + Axios 实例 + 请求拦截器 + 响应拦截器 + cancelKey
核心使用方式:
request({
url: "/users/search",
method: "get",
params: {
keyword: "Tom",
},
cancelKey: "search-user",
});
手动取消:
cancelRequest("search-user");
取消全部:
cancelAllRequest();
不参与取消队列:
request({
url: "/important-api",
method: "get",
cancelable: false,
});
十四、总结
本文完成了 Vue 工程中 Axios 的完整二次封装,并分别实现了两种取消请求方案:
- 新方案:
AbortController - 旧方案:
CancelToken
对于现在的 Vue 项目,优先使用 AbortController。它写法更标准,也符合 Axios 官方推荐。CancelToken 只建议在老项目维护阶段继续使用,新项目不建议再引入。
实际开发中,取消请求最常见的价值是:
- 搜索框连续输入时取消上一次请求
- 页面切换时取消未完成请求
- 防止重复请求
- 减少无意义网络消耗
- 避免旧请求覆盖新数据
直接复制上面的 src/utils/request.js、src/api/user.js 和 UserSearch.vue,就可以在 Vue 项目中开箱即用。
更多推荐
所有评论(0)