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

本文会给出两套完整封装:

  1. 新方案:AbortController 取消请求
  2. 旧方案: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.jssrc/api/user.jsUserSearch.vue,就可以在 Vue 项目中开箱即用。

更多推荐