Axios

基础知识

什么是 Axios

Axios 是一个基于 PromiseHTTP 客户端 库,它既可以在浏览器环境中使用,也支持在 Node.js 环境下运行。

Axios 本质上是 浏览器 XMLHttpRequestNode.js 原生 http 模块 的轻量封装。它最大的价值不是"发请求",而是统一了前后端环境下的异步交互范式,把回调地狱变成了 Promise 链。

核心特点

  • Promise API :支持 .then() / .catch() 链式调用以及 async/await 语法
  • 跨环境兼容:在浏览器和 Node.js 中使用同一套 API
  • 请求/响应拦截器:在请求发出前或响应返回后插入自定义逻辑
  • 自动 JSON 转换:响应数据默认会自动转换为 JSON 对象
  • 取消请求:支持主动取消正在进行的请求
  • 超时控制:可灵活配置请求超时时间,避免长时间等待。
  • XSRF 防护:内置对跨站请求伪造(XSRF)的防护支持,提升应用安全性。
  • 进度捕获:支持监控文件上传和下载的进度,便于实现进度条等交互功能。

通过 npm 安装

可以通过以下任一包管理器进行安装:

bash 复制代码
npm install axios
# 或
yarn add axios
# 或
pnpm add axios

模块导入

javascript 复制代码
// ES Module
import axios from 'axios';

// CommonJS
const axios = require('axios');

基本用法

请求方法

所有请求都遵循axios.方法名(url, 数据, 配置)的规律,记住这一公式即可

javascript 复制代码
// GET:参数必须放在 config 的 params 里(自动序列化,杜绝手动拼URL)
// axios.get(url[, config]) config可选
await axios.get('/api/users', { params: { page: 1, size: 10 } });

// POST/PUT/PATCH:数据直接放在第二个参数(自动序列化为 JSON)
// axios.post(url[, data[, config]]) data,config可选
await axios.post('/api/users', { name: '张三', age: 18 });
// axios.put(url[, data[, config]])	data,config可选
await axios.put('/api/users/1', { name: '李四' });
// axios.delete(url[, config])
// DELETE 通常无 body,参数放 params,params 必须写在 config 对象里
axios.delete('/api/users', { params: { id: 123, type: 'hard' } }); // 最终请求 URL 为 /api/users?id=123&type=hard
// 或者使用路径参数,直接嵌在 URL 里
await axios.delete('/api/users/1'); 

GET 请求传参务必使用 params,它底层会调用 encodeURIComponent 处理特殊字符;而 POST 默认的 Content-Type 是 application/json,如果你要传 FormData 文件,记得将数据包裹为 new FormData(),Axios 会自动识别并切换头信息。

异步处理

使用 async/await 配合 try/catch

javascript 复制代码
const fetchData = async () => {
  try {
    const { data } = await axios.get('/api/list'); // 解构出 data
    console.log(data);
  } catch (err) {
    // err.response 存在 => 后端返回了错误状态码(如 500)
    // err.request 存在 => 请求发出但没收到回包(超时/断网)
    // 两者都不存在 => 请求压根没发出去(配置错误)
    handleGlobalError(err);
  }
};

进阶功能

实例化

全局默认的 axios 就像是一个"公共水龙头",任何地方修改了配置(如 baseURL 或请求头),都会影响整个项目,极易引发冲突。

实例化的核心价值在于模块化隔离,让你能为不同的后端服务或业务场景,创建各自独立的"专属客户端":

  • 场景 1(多后端服务):请求用户中心用 baseURL: '/user-api',请求商品中心用 baseURL: '/goods-api'。
  • 场景 2(不同鉴权):普通请求带 Token A,上传文件的请求带 Token B 且设置更长的超时时间。

基本语法与配置

  1. 核心语法
    Axios 实例化的基本语法非常简单,核心就是axios.create()方法。它可以接收一个配置对象,用来定义这个实例专属的默认行为。
javascript 复制代码
// 创建实例
const instance = axios.create({
  // 配置项写在这里
});
// 使用实例
instance.get('/users', { timeout: 10000 })
  1. 最常用的核心配置项(全览)
配置项 类型 说明 示例
baseURL string 基础路径,会自动拼接到 url 前面(重点)。 'https://api.example.com'
timeout number 超时时间(毫秒),超过此时间请求报错。 10000 (10秒)
headers object 公共请求头,会携带在每个请求中。 { 'X-Requested-With': 'XMLHttpRequest' }
params object 默认查询参数,会拼接到 URL 问号后面。 { version: 'v1' } → ?version=v1
withCredentials boolean 跨域请求时是否携带 Cookie。 true
responseType string 服务器响应的数据类型。 'json'(默认)、'blob'、'arraybuffer'
transformRequest array 在发送请求前修改请求数据(如加密)。 (data) =\> JSON.stringify(data)
transformResponse array 在拿到响应数据后修改数据(如解密)。 (data) =\> JSON.parse(data)
  1. 使用
  • 标准业务 API
    适用于绝大多数需要登录态的内部管理系统。
javascript 复制代码
// src/utils/request.js
import axios from 'axios';

const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // 基础路径
  timeout: 15000,                                       // 15秒超时
  withCredentials: true,                                // 自动携带 Cookie
  headers: {
    'Content-Type': 'application/json',                 // 默认 JSON 格式
    'X-Requested-With': 'XMLHttpRequest'                // 标识 AJAX 请求
  }
});

export default apiClient;
  • 外部公开 API(下载文件/图片)
  • 对接第三方 CDN 或公开接口,无需携带 Cookie,且需要以二进制流(Blob)形式接收数据。
javascript 复制代码
const downloadClient = axios.create({
  baseURL: 'https://cdn.example.com',
  timeout: 30000,
  withCredentials: false,        // 不携带 Cookie(跨域公开资源)
  responseType: 'blob',          // 关键:让返回数据变成 Blob 对象,方便下载
  headers: {
    'Content-Type': 'application/octet-stream'
  }
});

// 使用
const blob = await downloadClient.get('/images/logo.png')
  • 请求/响应数据预处理
    如果需要对所有请求参数做加密,或对所有响应数据做统一解包,可以使用 transformRequest 和 transformResponse。
javascript 复制代码
const cryptoClient = axios.create({
  baseURL: '/api/secure',
  // 发送前:将 data 转为 JSON 字符串(默认其实就做了,这里演示覆盖规则)
  transformRequest: [(data, headers) => {
    // 假设需要将所有请求数据加密,可以在这里操作
    console.log('发送前加密数据:', data);
    return JSON.stringify(data); 
  }],
  // 接收后:直接取出 data 字段,无需每次手动 .data
  transformResponse: [(rawData) => {
    try {
      const parsed = JSON.parse(rawData);
      return parsed.data; // 直接返回业务数据,省去一层 .data
    } catch (e) {
      return rawData;
    }
  }]
});
  1. 配置的优先级
    如果在具体请求时也传了配置,会按 请求配置 > 实例配置 覆盖。
javascript 复制代码
// 实例配置:实例设置超时 15秒
const instance = axios.create({ timeout: 15000 });

// 请求配置:这个请求单独覆盖为 30秒,只影响这一次
instance.get('/big-data', { timeout: 30000 }); 

特别注意 headers 的合并:它是自定义合并,而非完全覆盖。如果实例设了 'X-Auth': '123',请求时设了 'X-Auth': '456',会覆盖同名键;但如果请求只设了 'Token': 'abc',实例的 X-Auth 依然存在。

拦截器

拦截器(Interceptors)是 Axios 最强大的"中间件"机制。它允许你在请求发出之前响应回来之后 ,在它们到达 then / catch 之前,插入一段全局统一的处理逻辑。

你可以把它想象成机场安检:

  • 请求拦截器:登机前的安检(检查机票 Token、给行李贴标签)。
  • 响应拦截器:落地后的海关检查(统一拆包、处理异常报错)。

请求拦截器 (request)

在请求发出前执行,通常用于注入 Token开启 Loading

语法:axios.interceptors.request.use(fulfilled, rejected, options?)

参数:

  • fulfilled (函数):(config) => config配置对象,用于请求,必须返回对象。
  • rejected (函数,可选): (error) => Promise.reject(error)。错误对象
  • options (对象,可选): { synchronous: boolean, runWhen: function }
    synchronous 同步执行,不等待微任务队列;通常无需设置;
    runWhen 根据运行时的条件,动态地决定一个拦截器是否应该被执行
javascript 复制代码
axios.interceptors.request.use(
  (config) => { 
    // 必须 return config
    return config; 
  },
  (error) => { 
    // 必须 return Promise.reject(error)
    return Promise.reject(error); 
  }
);
javascript 复制代码
// 参数:成功回调(必须返回 config),失败回调(可选)
axios.interceptors.request.use(
  (config) => {
    // 给headers对象添加Authorization 的值
    config.headers.Authorization = 'Bearer token';
    // 核心:必须 return config,否则请求会卡死!
    return config; 
  },
  (error) => {
    // 请求配置出错时(很少发生)
    return Promise.reject(error);
  }
);

响应拦截器 (response)

在请求返回后,进入 then 之前执行,通常用于统一解包数据、关闭 Loading 或统一处理 401 未授权

语法:axios.interceptors.response.use(fulfilled, rejected, options?)

参数:

  • fulfilled (函数):(response) => response 响应对象,用于响应。必须返回对象。
  • rejected (函数,可选): (error) => Promise.reject(error)。错误对象
  • options (对象,可选): { synchronous: boolean, runWhen: function }
javascript 复制代码
axios.interceptors.response.use(
  (response) => { 
    // 必须 return response (或只 return response.data)
    return response; 
  },
  (error) => { 
    // 必须 return Promise.reject(error)
    return Promise.reject(error); 
  }
);
javascript 复制代码
// 参数:成功回调(必须返回 response),失败回调(可选)
axios.interceptors.response.use(
  (response) => {
    // ⚠️ 核心:可以只返回 response.data,省去页面里写 .data 的麻烦
    return response.data; 
  },
  (error) => {
    // 网络错误、超时、状态码非 2xx 都会进这里
    if (error.response?.status === 401) {
      // 跳转到登录页
    }
    return Promise.reject(error);
  }
);

移除拦截器的语法 (.eject())

use() 方法会返回一个 ID(数字),如果你想在后续逻辑中移除该拦截器,使用 .eject():

javascript 复制代码
const interceptorId = axios.interceptors.request.use((config) => config);

// 移除这个拦截器
axios.interceptors.request.eject(interceptorId);

options 参数(可选)

.use() 的第三个参数 options 极少用到,但语法上支持传入一个对象:

javascript 复制代码
axios.interceptors.request.use(
  (config) => config,
  (error) => Promise.reject(error),
  { 
  		synchronous: true, // 用于控制拦截器是同步还是异步执行
  		// 动态地决定一个拦截器是否应该被执行
  		// 返回 true  -> 执行该拦截器(默认)
  		// 返回 false -> 跳过该拦截器
  		runWhen: (config) => {
  			return ture;  // 默认行为
  		}
  	}
);

runWhen 主要用于需要精细化控制拦截器生效范围的场景

  1. 为特定类型的请求添加特定头信息
    比如,你只想为所有 GET 请求添加一个特殊的请求头
javascript 复制代码
function isGetRequest(config) {
  return config.method === 'get';
}

axios.interceptors.request.use(
  function (config) {
    config.headers['X-Special-Header'] = 'only-for-get';
    return config;
  },
  null,
  { runWhen: isGetRequest } // 只有 GET 请求才会执行这个拦截器
);
  1. 仅为特定API端点添加拦截逻辑
    比如,只为 /admin 开头的管理后台接口添加 Token:
javascript 复制代码
function isAdminApi(config) {
  return config.url && config.url.startsWith('/admin');
}

axios.interceptors.request.use(
  function (config) {
    config.headers['Admin-Token'] = 'your-admin-token';
    return config;
  },
  null,
  { runWhen: isAdminApi }
);

使用

取消请求(防"无用回调")

在切换页面或用户频繁点击按钮时,必须取消上一个未完成的请求,防止数据错乱或浪费带宽。

在 Axios 中取消请求,官方现在推荐使用 AbortController 接口。而旧版的 CancelToken 方式从 v0.22.0 起已被弃用。

AbortController

AbortController 是 JavaScript 中用来取消异步操作的工具‌,最常用于中断正在进行的网络请求,避免资源浪费和数据错乱。‌‌‌‌

  • 核心组成‌ :创建 AbortController 实例后,你会得到两个关键东西------signal信号对象用来传递给异步操作,abort() 方法用来发出取消指令
  • 工作流程‌先把 signal 传给支持取消的 API (比如 fetch),需要取消时调用 abort(),操作就会中断并抛出 AbortError 错误。
  • 设计特点‌:控制器和信号分离,一个控制器可以控制多个操作,多次调用 abort() 不会有副作用,一旦取消就无法恢复。‌‌‌‌
javascript 复制代码
// 1. 创建控制器(无参数)
const controller = new AbortController();

// 2. 请求配置参数:传入 signal
axios.get('/api/data', {
  signal: controller.signal // 参数名固定为 signal
});

// 3. 取消方法:调用 abort()
controller.abort(); // 可选参数:reason(取消原因,会放在 error 里)

AbortController 的 signal 可以被复用,传入多个请求,一次 abort() 即可取消所有关联请求。

javascript 复制代码
const controller = new AbortController();
axios.get('/a', { signal: controller.signal });
axios.post('/b', data, { signal: controller.signal });
controller.abort(); // 同时取消 a 和 b

自动超时

AbortSignal 提供了一个 timeout() 方法,可以轻松实现请求超时自动取消。

javascript 复制代码
// 5秒后自动取消请求
axios.get('/api/data', {
  signal: AbortSignal.timeout(5000) // 注意:Node.js 需 17.3+ 版本[reference:14]
});

CancelToken

它是Axios早期提供的HTTP请求取消方案,用于在请求完成前主动中断,避免不必要的资源浪费。

基础使用步骤

  • 调用 axios.CancelToken.source() 生成包含token和cancel方法的对象
  • 发送请求时将token填入配置项的cancelToken属性
  • 需要中断时调用cancel(),配合axios.isCancel()捕获取消异常
javascript 复制代码
const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/api/data', {
  cancelToken: source.token
})
.catch(error => {
  if (axios.isCancel(error)) {
    console.log('请求已取消:', error.message);
  } else {
    // 处理其他错误
  }
});

// 取消请求
source.cancel('操作被用户取消');[reference:10][reference:11]

注意:不要混用 CancelToken 和 AbortController,避免不可预知的行为。

封装一个工程的Axios

一般接口请求封装在./util/request.js文件里

思路

"取消上一次请求"封装在哪里?

封装在请求拦截器 里。

核心:在请求发出去之前就拦截取消,而不是等请求发出去了再处理。

请求拦截器要做的事:

  1. 维护一个"待处理请求"的记录表(Map),key是请求的唯一标识,value是取消函数
  2. 每次请求进来,先检查记录表里有没有"相同标识"的请求
  3. 如果有,说明上一次请求还没完成,直接调用对应的取消函数把它取消掉
  4. 把当前这个新请求的取消函数,登记到记录表里
  5. 请求完成后(无论成功还是失败),在响应拦截器里把它从记录表中删掉
javascript 复制代码
import axios from 'axios';

// 用 Map 存储:请求的唯一标识 -> 对应的取消函数
const pendingRequests = new Map();

// 辅助函数:生成请求的唯一标识
function getRequestKey(config) {
  // 用 方法+URL+参数+请求体 生成一个key
  return [config.method, config.url, JSON.stringify(config.params), JSON.stringify(config.data)].join('&');
}

// 请求拦截器
axios.interceptors.request.use(config => {
  // 1. 生成当前请求的key
  const requestKey = getRequestKey(config);
  
  // 2. 检查这个key是否已经在pendingRequests里
  if (pendingRequests.has(requestKey)) {
    // 有,说明上一次还没完,取消它!
    const cancel = pendingRequests.get(requestKey);
    cancel('重复请求被自动取消');
  }
  
  // 3. 给当前请求注入CancelToken,并登记
  config.cancelToken = new axios.CancelToken(cancelFn => {
    pendingRequests.set(requestKey, cancelFn);
  });
  
  return config;
});

// 响应拦截器(请求完成后清理)
axios.interceptors.response.use(
  response => {
    const requestKey = getRequestKey(response.config);
    pendingRequests.delete(requestKey); // 请求成功,从记录表里删掉
    return response;
  },
  error => {
    if (axios.isCancel(error)) {
      console.log('请求被取消:', error.message);
    } else {
      // 即便是报错了,也记得清理
      const requestKey = getRequestKey(error.config || {});
      pendingRequests.delete(requestKey);
    }
    return Promise.reject(error);
  }
);

怎么优化请求的唯一标识

javascript 复制代码
function getRequestKey(config) {
  return [config.method, config.url, JSON.stringify(config.params), JSON.stringify(config.data)].join('&');
}

对于生成请求的唯一标志的这一段代码,会有个问题,JSON.stringify 对于"不同顺序但内容相同的对象"会生成不同的字符串。

具体场景:

用户第一次点击"导出",传的参数是 { name: '张三', age: 30 }。

用户第二次点击"导出",传的参数是 { age: 30, name: '张三' }。

在业务逻辑上,这显然是同一个请求,应该被取消。但在代码眼里,因为对象键的顺序变了,JSON.stringify 生成了两个不同的key。

这会导致:旧的请求没被取消,两个一模一样的请求先后发出去了。后端承受了不必要的压力,前端也可能因为两次响应回来的时间差导致数据显示错乱。

那怎么改呢?

我们需要一个更聪明的生成key的办法,保证对象内容相同,key就相同。最简单的办法是对参数键进行排序再序列化。

前置知识

序列化语法JSON.stringify(value, replacer)

‌- value‌ (必需):需要被序列化为 JSON 字符串的值。

  • replacer‌ (可选):用于控制序列化过程的参数,可以是‌函数‌或‌数组‌。如果省略或为 null,则对象的所有可枚举属性都会被序列化。

replacer 是一个函数:该函数会在序列化过程中对每个属性进行调用,允许你修改值或过滤属性。

  • 签名‌:function(key, value)
    key:当前属性的键名(根对象时为空字符串 "")。
    value:当前属性的值。
  • 返回值规则‌:
    • 返回一个值:该值将替代原值被序列化。
    • 返回 undefined:该属性将被从结果中‌忽略/删除‌。
    • 返回其他基本类型或对象:正常序列化。
  • 代码:
javascript 复制代码
const user = { name: "Alice", age: 25, password: "123" };
// 过滤掉 password 字段
const json = JSON.stringify(user, (key, value) => {
  if (key === 'password') return undefined;
  return value;
});
// 结果: '{"name":"Alice","age":25}'

replacer 是一个数组:‌作为一个"白名单",只有数组中包含的键名对应的属性才会被序列化。

  • 行为‌:
    • 仅保留数组中指定的属性名。
    • 属性的顺序将按照数组中键名的顺序排列(这在某些场景下可用于固定输出顺序)。
    • 如果数组中的键在对象中不存在,则该键被忽略。
  • ‌示例‌:
javascript 复制代码
const user = { name: "Alice", age: 25, id: 101 };
// 只保留 name 和 id
const json = JSON.stringify(user, ["name", "id"]);
// 结果: '{"name":"Alice","id":101}'

回到我们的原文,生成参数按顺序排序的唯一标志,我们要用到的是replacer 参数排序返回数组

javascript 复制代码
function getRequestKey(config) {
  const params = config.params ? JSON.stringify(config.params, Object.keys(config.params).sort()) : '';
  const data = config.data ? JSON.stringify(config.data, Object.keys(config.data).sort()) : '';
  return [config.method, config.url, params, data].join('&');
}

这样,无论用户传的对象键顺序怎么变,生成的key都是一致的,真正做到"精准取消重复请求"。

如何在切换页面时,把上一个页面所有还在等待的请求一次性全取消掉?

切换页面时,全局拦截器无法知道该操作,但组件知道自己的生命周期。我们可以在"组件离开时"这个时机,用我们上一步的 pendingRequests 来统一取消。另外,我们需要给每个组件也打上一个标签,知道是哪个组件里的请求需要取消。

具体步骤:

  1. 给请求打标签:发请求时,用组件的唯一标识(比如路由路径)给请求分组。
  2. 在组件挂载时记录:把"当前页面标识"存起来,或者提供一个方法,能把请求注册到当前页面下。
  3. 在组件卸载时批量取消:离开页面时,遍历 pendingRequests,把属于这个页面的所有请求全部取消并清理。

这里的关键点是:怎么把"请求"和"页面"关联起来?

我们可以在上一步 getRequestKey 的基础上,再扩展一个 pageId 字段。

request.js

javascript 复制代码
import axios from 'axios';

// 维护一个更丰富的待处理请求表
const pendingRequests = new Map(); // key: requestKey, value: { cancel, pageId }

function getRequestKey(config) {
  const params = config.params ? JSON.stringify(config.params, Object.keys(config.params).sort()) : '';
  const data = config.data ? JSON.stringify(config.data, Object.keys(config.data).sort()) : '';
  return [config.method, config.url, params, data].join('&');
}

// 请求拦截器
axios.interceptors.request.use(config => {
  const requestKey = getRequestKey(config);
  
  // 检查是否有相同的请求在等待
  if (pendingRequests.has(requestKey)) {
    const { cancel } = pendingRequests.get(requestKey);
    cancel('重复请求被取消');
  }
  
  // 给当前请求绑定CancelToken,并记录它属于哪个页面
  config.cancelToken = new axios.CancelToken(cancelFn => {
    // 如果config里没有pageId,就默认是'global'
    const pageId = config.pageId || 'global';
    pendingRequests.set(requestKey, { cancel: cancelFn, pageId });
  });
  
  return config;
});

// 响应拦截器清理(略,同之前)

在组件里使用的工具函数

javascript 复制代码
export function cancelAllRequestsForPage(pageId) {
  for (let [key, value] of pendingRequests) {
    if (value.pageId === pageId) {
      value.cancel('页面切换,请求被取消');
      pendingRequests.delete(key);
    }
  }
}

在Vue组件中使用

javascript 复制代码
import { onBeforeUnmount } from 'vue';
const currentPageId = 'order-list'; // 或用 route.path

// 发请求时,记得带上pageId
axios.get('/api/export', { pageId: currentPageId });

onBeforeUnmount(() => {
  cancelAllRequestsForPage(currentPageId);
});

找出上面代码的"脏活";取消某个特定请求

  1. 上面方案有一个明显的"脏活"需要每个组件都手动做。
  2. 如果同一个页面内,我只想取消某个特定请求(比如导出),而不影响其他正在进行的列表刷新请求,该怎么做?

针对第1点:每个页面手动传 pageId 太麻烦

这会让每个开发者每次调接口时都要记得传 pageId,非常容易漏,维护成本高。

改进思路: 让请求自动绑定当前页面。

我们可以在路由守卫里,把当前页面的标识自动注入到所有请求中,组件完全不用管。

javascript 复制代码
// router.js 路由全局前置守卫
router.beforeEach((to, from, next) => {
  // 给axios全局配置挂载当前页面ID
  axios.defaults.pageId = to.path; // 或者用to.name保证唯一性
  next();
});

然后修改请求拦截器,从 config 上取不到 pageId 时,自动从全局配置读取:

javascript 复制代码
axios.interceptors.request.use(config => {
  const requestKey = getRequestKey(config);
  // ...
  
  config.cancelToken = new axios.CancelToken(cancelFn => {
    // 优先级:请求单独指定 > 当前路由页面
    const pageId = config.pageId || axios.defaults.pageId || 'global';
    pendingRequests.set(requestKey, { cancel: cancelFn, pageId });
  });
  
  return config;
});

这样组件里完全不用关心 pageId,离开页面时,我们甚至可以把清理逻辑也封装成全局自动的:

javascript 复制代码
// 在路由守卫的 from 离开时自动取消
router.beforeEach((to, from, next) => {
  if (from.path) {
    cancelAllRequestsForPage(from.path);
  }
  axios.defaults.pageId = to.path;
  next();
});

针对第2点:细化 pendingRequests 结构,支持取消特定请求

我们可以给请求再增加一个 requestType 标签,比如 'export'、'list-refresh',这样既能按页面批量取消,也能按类型精准取消。

优化后的结构变成:

javascript 复制代码
// pendingRequests 的 value 结构
{
  cancel: Function,
  pageId: string,
  requestType?: string  // 可选标签
}

工具方法里再添加一个按照页面加类型取消

javascript 复制代码
// 1. 按页面取消(已有)
export function cancelAllRequestsForPage(pageId) { ... }

// 2. 按页面+类型取消(新增)
export function cancelRequestsByType(pageId, requestType) {
  for (let [key, value] of pendingRequests) {
    if (value.pageId === pageId && value.requestType === requestType) {
      value.cancel(`取消指定类型请求: ${requestType}`);
      pendingRequests.delete(key);
    }
  }
}

使用时,发起请求时传入 requestType:

javascript 复制代码
// 导出请求
axios.get('/api/export', { requestType: 'export' });
// 列表刷新请求
axios.get('/api/list', { requestType: 'list-refresh' });

// 用户重新点击导出,只取消上一次导出,不影响列表刷新
cancelRequestsByType(currentPageId, 'export');

我们要怎么区分"正常的取消"和"真正的接口错误"?

当我们主动取消了请求,axios会抛出一个 Cancel 错误。在拦截器里,我们要怎么区分"正常的取消"(不需要报错提示)和"真正的接口错误"(需要弹窗提示)?

需要双重判断

  1. 先看是不是 Cancel 类型:用 axios.isCancel(error)。
  2. 再确认是不是我们自己主动取消的:检查 pendingRequests.has(requestKey)。

只有两个条件同时满足,才是"我们主动取消的",应该静默处理,不弹错误提示。其余情况,都按真实错误对待(或者是一些无需处理的边缘情况,但一律不要吞掉)。

为什么需要双重判断?

  • 有些取消不是我们触发的(比如浏览器中断、请求被其它库取消),axios.isCancel 也会为 true。如果只靠这一点就吞掉错误,可能漏掉了需要关注的异常。
  • pendingRequests 是我们自己维护的"主动取消登记表"。只有我们主动调用过 cancel() 的请求,才会在表里有记录。
  • 所以:isCancel === true + 在登记表里 = 我们自己干的,安全吞掉。缺一个条件,就老老实实按错误处理。

优化后的工具函数:把 cancelAllRequestsForPage 里的 delete 删掉,只负责调 cancel()。清理全部交给响应拦截器的 finally。

javascript 复制代码
export function cancelAllRequestsForPage(pageId) {
  for (let [key, value] of pendingRequests) {
    if (value.pageId === pageId) {
      value.cancel('页面切换,请求被取消');
      // 注意:这里不 delete,由拦截器统一清理
    }
  }
}

响应拦截器改为:

javascript 复制代码
// 第1个:处理错误分类(取消、重试、真实错误)
axios.interceptors.response.use(
  response => response, // 成功的不处理,留给第2个统一清理
  error => {
    if (axios.isCancel(error)) {
		  const key = getRequestKey(error.config || {});
		  if (key && pendingRequests.has(key)) {
		    // 是我们主动取消的(重复请求、页面切换等)
		    console.warn('请求已被主动取消:', error.message);
		    return Promise.resolve(); // 阻止错误继续传播
		  }
		  // 不是我们取消的,可能是未知原因,一般忽略
		  // 注意:不要在这里 return Promise.reject,否则会向上抛
		  return Promise.resolve(); // 也可以 resolve,不弹错误
		}
		// 不是取消类错误,就是真实接口错误
		return Promise.reject(error);
  }
);

// 第2个:统一清理(模拟 finally)
// 由于 axios 没有直接的 finally 拦截器,我们可以用响应拦截器的两个回调分别在最后处理
// 更简单的做法:在 error 处理之后,再用一个拦截器统一 delete
axios.interceptors.response.use(
  response => {
  		// 能走到这里,说明要么是真正的成功响应,要么是第1个拦截器吞掉了取消错误
    const key = getRequestKey(response.config);
    pendingRequests.delete(key);
    return response;
  },
  error => {
  	// 真实错误走到这里,也要清理
    const key = getRequestKey(error.config || {});
    pendingRequests.delete(key);
    return Promise.reject(error);
  }
);

执行顺序:

  1. 响应回来 → 进入第 1 个拦截器。

    • 如果是主动取消的错误:第 1 个拦截器返回 Promise.resolve(),错误被修复,流转变成"成功"。
    • 如果是真实错误:第 1 个拦截器返回 Promise.reject(error),继续向下抛。
  2. 进入第 2 个拦截器:

    • 如果第 1 个已经返回成功 → 走第 2 个的成功回调,执行 delete(key)。
    • 如果第 1 个抛出错误 → 走第 2 个的错误回调,同样执行 delete(key)。

这就是为什么第 2 个拦截器充当 finally 的角色:无论前面的错误被吞掉还是继续抛出,第 2 个拦截器的成功或失败回调总有一个会执行,从而完成清理。

智能重试机制

思路:

  1. 重试逻辑应该放在拦截器的哪个环节?
  2. 哪些错误需要重试,哪些绝对不重试?(提示:网络错误、超时、500、403、401)
  3. 重试次数和间隔怎么传递?(是全局统一配置,还是单个请求可以自定义?)
  4. 重试时要不要创建新的 CancelToken?

方案:

  1. 拦截器位置:axios.interceptors.response.use 的第二个参数(错误回调)。
  2. 重试判断:网络错误/超时/5xx → 重试;4xx(参数错、无权限)→ 不重试,直接抛。
  3. 配置方式:全局默认 + 单个请求可覆盖。
  4. CancelToken:每次重试都要创建新的,旧的已失效。
javascript 复制代码
// 全局默认配置
const defaultRetryConfig = {
  retry: 2,           // 重试次数
  retryDelay: 1000,   // 基础延迟(ms),逐次翻倍
  retryCondition: (error) => {
    // 默认:网络错误、超时、5xx 才重试
    if (!error.response) return true;          // 网络错误/超时
    if (error.response.status >= 500) return true; // 服务端错误
    return false;
  }
};

// 响应拦截器 - 成功时不做处理
axios.interceptors.response.use(res => res, async error => {
  const config = error.config;
  
  // 1. 请求本身没有配置重试,或者已经用完了重试次数
  const retryConfig = { ...defaultRetryConfig, ...config.retryConfig };
  const retryCount = config.__retryCount || 0;
  
  // 2. 不满足重试条件:直接抛
  if (!retryConfig.retryCondition(error)) {
    return Promise.reject(error);
  }
  
  // 3. 重试次数用完
  if (retryCount >= retryConfig.retry) {
    return Promise.reject(error);
  }
  
  // 4. 计算延迟
  const delay = retryConfig.retryDelay * Math.pow(2, retryCount);
  
  // 5. 等待后重新发起请求
  await new Promise(resolve => setTimeout(resolve, delay));
  
  // 6. 关键:创建新的 CancelToken
  config.cancelToken = new axios.CancelToken(cancelFn => {
    // 新的取消函数,可以和之前的 pendingRequests 体系对接
    config.__cancelFn = cancelFn;
  });
  
  // 7. 记录重试次数
  config.__retryCount = retryCount + 1;
  
  // 8. 重新发送请求
  return axios(config);
});

补全逻辑

根据上面代码中

  1. 第6步创建了新 CancelToken,但没更新 pendingRequests 里的记录,这会导致什么后果?(和我们上一步的请求取消体系联动)
  2. await new Promise(resolve => setTimeout(...)) 这种写法,如果用户在重试等待期间切换了页面,会发生什么?应该怎么改进?
  3. 第8步 return axios(config),返回的是一个全新的 Promise。如果调用方已经在用 .then/.catch 监听结果,这里直接 return 新 Promise 能正确链式传递吗?

答案:

  1. 会陷入死循环
    1. 第一次请求,pendingRequests 登记了请求A。
    2. 请求A失败,进入重试逻辑。
    3. 重试时创建了新 CancelToken,但 pendingRequests 里还是旧的。
    4. 旧的 CancelToken 已经用过一次了,无法再取消新请求。
    5. 如果此时切换页面调了 cancelAllRequestsForPage,它调的是旧 cancel,新请求完全不受控制,继续飞。
    6. 新请求回来时,finally 拦截器尝试 delete,但 key 对应的还是旧记录,删不掉,造成内存泄漏。

所以:重试时,必须用新的 CancelToken 更新 pendingRequests 中的 cancel 函数。

  1. 页面切换后仍会继续重试
    setTimeout 本身没法被取消,如果用户在重试等待的 4 秒内切走了,这个计时器还在跑,时间一到照样发请求。

改进方案:用 AbortController + 页面标识检查 双重保险。

但更简单的做法是:**在每次重试发起前,检查当前页面是否还是发起请求的那个页面。**如果不是,直接终止重试。

javascript 复制代码
// 重试等待前检查
const currentPageId = axios.defaults.pageId;
await new Promise(resolve => setTimeout(resolve, delay));
// 等待后再次检查
if (axios.defaults.pageId !== currentPageId) {
  return Promise.reject({ __canceledByNavigation: true });
}
  1. return axios(config) 能正确链式传递

因为这段代码在响应拦截器的错误回调里,返回了一个 Promise。axios 的拦截器机制会把这个 Promise 放到原来的调用链上。调用方的 .then/.catch 会等这个新 Promise 的结果,拿到的是最终重试成功或失败的值。

完整代码

请求完整代码

src/utils/request/

├── index.js # 创建 axios 实例,注册拦截器,默认导出

├── config.js # 默认配置(baseURL、超时、重试配置等)

├── helpers.js # 工具函数(getRequestKey、sortStringify)

├── pendingManager.js # pendingRequests Map 及取消函数

├── interceptors.js # 请求拦截器、响应拦截器(onFulfilled、onRejected)

└── routerGuard.js # 路由守卫注册

config.js - 纯配置,不依赖任何运行时逻辑

javascript 复制代码
export const defaultRetryConfig = {
  retry: 2,
  retryDelay: 1000,
  retryCondition: (error) => {
    if (!error.response) return true;
    if (error.response.status >= 500) return true;
    return false;
  },
};

helpers.js - 纯函数,方便单元测试

javascript 复制代码
export function sortStringify(obj) {
  if (!obj || Object.keys(obj).length === 0) return '';
  const sortedKeys = Object.keys(obj).sort();
  const sortedObj = {};
  sortedKeys.forEach(key => { sortedObj[key] = obj[key]; });
  return JSON.stringify(sortedObj);
}

export function getRequestKey(config) {
  const { method, url, params, data } = config;
  return [method, url, sortStringify(params), sortStringify(data)].join('&');
}

pendingManager.js - 管理 pendingRequests Map

javascript 复制代码
import { getRequestKey } from './helpers';

const pendingRequests = new Map();

export function addPending(config, cancelFn, pageId, requestType) {
  const key = getRequestKey(config);
  pendingRequests.set(key, { cancel: cancelFn, pageId, requestType });
}

export function removePending(config) {
  const key = getRequestKey(config);
  pendingRequests.delete(key);
}

export function cancelAndRemovePending(config, reason) {
  const key = getRequestKey(config);
  if (pendingRequests.has(key)) {
    pendingRequests.get(key).cancel(reason);
    pendingRequests.delete(key);
  }
}

export function cancelAllRequestsForPage(pageId) {
  for (const [key, info] of pendingRequests) {
    if (info.pageId === pageId) {
      info.cancel('页面切换,请求自动取消');
      // 不 delete,由拦截器统一清理
    }
  }
}

export function cancelRequestsByType(pageId, requestType) {
  for (const [key, info] of pendingRequests) {
    if (info.pageId === pageId && info.requestType === requestType) {
      info.cancel(`取消类型为 ${requestType} 的请求`);
    }
  }
}

export function hasPending(config) {
  return pendingRequests.has(getRequestKey(config));
}

interceptors.js - 拦截器逻辑

javascript 复制代码
import axios from 'axios';
import { getRequestKey } from './helpers';
import { defaultRetryConfig } from './config';
import {
  addPending,
  removePending,
  hasPending,
  cancelAndRemovePending,
} from './pendingManager';

export function createRequestInterceptor(service) {
  return (config) => {
    // 1. 重复请求取消
    if (hasPending(config)) {
      cancelAndRemovePending(config, '重复请求被自动取消');
    }

    // 2. 创建 CancelToken 并登记
    config.cancelToken = new axios.CancelToken((cancelFn) => {
      const pageId = config.pageId || service.defaults.pageId || 'global';
      addPending(config, cancelFn, pageId, config.requestType);
    });

    // 3. 记录页面
    config.__pageId = service.defaults.pageId;
    return config;
  };
}

export function createFulfilledInterceptor() {
  return (response) => {
    removePending(response.config);
    return response;
  };
}

export function createRejectedInterceptor(service) {
  return async (error) => {
    const config = error.config;

    // 主动取消 → 静默
    if (axios.isCancel(error)) {
      const key = config ? getRequestKey(config) : null;
      if (key) removePending(config);
      return Promise.resolve();
    }

    if (!config) return Promise.reject(error);

    // 重试逻辑
    const retryConfig = { ...defaultRetryConfig, ...config.retryConfig };
    const retryCount = config.__retryCount || 0;

    if (!retryConfig.retryCondition(error) || retryCount >= retryConfig.retry) {
      return Promise.reject(error);
    }

    const delay = retryConfig.retryDelay * Math.pow(2, retryCount);
    const originPageId = config.__pageId;
    await new Promise(resolve => setTimeout(resolve, delay));

    if (service.defaults.pageId !== originPageId) {
      removePending(config);
      return Promise.reject({ __canceledByNavigation: true, message: '页面已切换,重试终止' });
    }

    // 清理旧记录,创建新 CancelToken,重新登记
    removePending(config);
    config.cancelToken = new axios.CancelToken((cancelFn) => {
      addPending(config, cancelFn, originPageId, config.requestType);
    });
    config.__retryCount = retryCount + 1;

    return service(config);
  };
}

routerGuard.js - 路由守卫

javascript 复制代码
import { cancelAllRequestsForPage } from './pendingManager';

export function setupRouterGuard(router, service) {
  if (!router) return;
  router.beforeEach((to, from, next) => {
    if (from && from.path) {
      cancelAllRequestsForPage(from.path);
    }
    service.defaults.pageId = to.path;
    next();
  });
}

index.js - 入口,组装导出

javascript 复制代码
import axios from 'axios';
import router from '@/router';
import { setupRouterGuard } from './routerGuard';
import {
  createRequestInterceptor,
  createFulfilledInterceptor,
  createRejectedInterceptor,
} from './interceptors';

const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 10000,
});

// 注册路由守卫
setupRouterGuard(router, service);

// 注册拦截器
service.interceptors.request.use(
  createRequestInterceptor(service),
  error => Promise.reject(error)
);
service.interceptors.response.use(
  createFulfilledInterceptor(),
  createRejectedInterceptor(service)
);

export { cancelAllRequestsForPage, cancelRequestsByType } from './pendingManager';
export default service;

使用

  1. 普通请求(零额外操作)
    api接口文件
javascript 复制代码
// src/api/order.js
import request from '@/utils/request';

// 获取订单列表
export function getOrderList(params) {
  return request({
    url: '/api/orders',
    method: 'get',
    params,
  });
}

在组件里调用:

javascript 复制代码
import { getOrderList } from '@/api/order';

// 在 vue 组件里
async fetchData() {
  try {
    const res = await getOrderList({ page: 1, size: 20 });
    this.list = res.data;
  } catch (error) {
    // 这里只会收到真正的错误(网络错、500等)
    // 被取消的请求不会走到这里
    console.error('获取订单列表失败', error);
  }
}

自动生效的能力:

  • 如果用户快速切换页面,该页面的请求会被自动取消,catch 不会触发。
  • 如果用户在同一个页面里连续点击多次,旧的请求会被自动取消,只保留最新的。
  1. 带精准取消分类的请求(如导出)
    如果希望在同一个页面内,能精准地只取消某一类请求(比如只取消导出,不影响列表刷新),就在请求时加一个 requestType 参数:
javascript 复制代码
// src/api/order.js
import request, { cancelRequestsByType } from '@/utils/request';

// 导出订单(归类为 'export')
export function exportOrders(params) {
  return request({
    url: '/api/orders/export',
    method: 'get',
    params,
    responseType: 'blob',
    requestType: 'export',  // 标记请求类型
  });
}

// 手动取消导出请求(比如用户点击了"取消导出"按钮)
export function cancelExport() {
  // 这里假设当前页面路由的 path 是 /order/list
  cancelRequestsByType('/order/list', 'export');
}
  1. 自定义重试配置
    有些接口(如上传)可能想调大重试次数或延迟:
javascript 复制代码
export function uploadFile(formData) {
  return request({
    url: '/api/upload',
    method: 'post',
    data: formData,
    headers: { 'Content-Type': 'multipart/form-data' },
    retryConfig: {
      retry: 3,        // 最多重试 3 次
      retryDelay: 2000 // 基础延迟 2 秒
    }
  });
}