基础知识
什么是 Axios
Axios 是一个基于 Promise 的 HTTP 客户端 库,它既可以在浏览器环境中使用,也支持在 Node.js 环境下运行。
Axios 本质上是 浏览器 XMLHttpRequest 和 Node.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 且设置更长的超时时间。
基本语法与配置
- 核心语法
Axios 实例化的基本语法非常简单,核心就是axios.create()方法。它可以接收一个配置对象,用来定义这个实例专属的默认行为。
javascript
// 创建实例
const instance = axios.create({
// 配置项写在这里
});
// 使用实例
instance.get('/users', { timeout: 10000 })
- 最常用的核心配置项(全览)
| 配置项 | 类型 | 说明 | 示例 |
|---|---|---|---|
| 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) |
- 使用
- 标准业务 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;
}
}]
});
- 配置的优先级
如果在具体请求时也传了配置,会按 请求配置 > 实例配置 覆盖。
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 主要用于需要精细化控制拦截器生效范围的场景
- 为特定类型的请求添加特定头信息
比如,你只想为所有 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 请求才会执行这个拦截器
);
- 仅为特定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文件里
思路
"取消上一次请求"封装在哪里?
封装在请求拦截器 里。
核心:在请求发出去之前就拦截取消,而不是等请求发出去了再处理。
请求拦截器要做的事:
- 维护一个"待处理请求"的记录表(Map),key是请求的唯一标识,value是取消函数
- 每次请求进来,先检查记录表里有没有"相同标识"的请求
- 如果有,说明上一次请求还没完成,直接调用对应的取消函数把它取消掉
- 把当前这个新请求的取消函数,登记到记录表里
- 请求完成后(无论成功还是失败),在响应拦截器里把它从记录表中删掉
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 来统一取消。另外,我们需要给每个组件也打上一个标签,知道是哪个组件里的请求需要取消。
具体步骤:
- 给请求打标签:发请求时,用组件的唯一标识(比如路由路径)给请求分组。
- 在组件挂载时记录:把"当前页面标识"存起来,或者提供一个方法,能把请求注册到当前页面下。
- 在组件卸载时批量取消:离开页面时,遍历 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点:每个页面手动传 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 错误。在拦截器里,我们要怎么区分"正常的取消"(不需要报错提示)和"真正的接口错误"(需要弹窗提示)?
需要双重判断
- 先看是不是 Cancel 类型:用 axios.isCancel(error)。
- 再确认是不是我们自己主动取消的:检查 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 个拦截器返回 Promise.resolve(),错误被修复,流转变成"成功"。
- 如果是真实错误:第 1 个拦截器返回 Promise.reject(error),继续向下抛。
-
进入第 2 个拦截器:
- 如果第 1 个已经返回成功 → 走第 2 个的成功回调,执行 delete(key)。
- 如果第 1 个抛出错误 → 走第 2 个的错误回调,同样执行 delete(key)。
这就是为什么第 2 个拦截器充当 finally 的角色:无论前面的错误被吞掉还是继续抛出,第 2 个拦截器的成功或失败回调总有一个会执行,从而完成清理。
智能重试机制
思路:
- 重试逻辑应该放在拦截器的哪个环节?
- 哪些错误需要重试,哪些绝对不重试?(提示:网络错误、超时、500、403、401)
- 重试次数和间隔怎么传递?(是全局统一配置,还是单个请求可以自定义?)
- 重试时要不要创建新的 CancelToken?
方案:
- 拦截器位置:axios.interceptors.response.use 的第二个参数(错误回调)。
- 重试判断:网络错误/超时/5xx → 重试;4xx(参数错、无权限)→ 不重试,直接抛。
- 配置方式:全局默认 + 单个请求可覆盖。
- 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);
});
补全逻辑
根据上面代码中
- 第6步创建了新 CancelToken,但没更新 pendingRequests 里的记录,这会导致什么后果?(和我们上一步的请求取消体系联动)
- await new Promise(resolve => setTimeout(...)) 这种写法,如果用户在重试等待期间切换了页面,会发生什么?应该怎么改进?
- 第8步 return axios(config),返回的是一个全新的 Promise。如果调用方已经在用 .then/.catch 监听结果,这里直接 return 新 Promise 能正确链式传递吗?
答案:
- 会陷入死循环
- 第一次请求,pendingRequests 登记了请求A。
- 请求A失败,进入重试逻辑。
- 重试时创建了新 CancelToken,但 pendingRequests 里还是旧的。
- 旧的 CancelToken 已经用过一次了,无法再取消新请求。
- 如果此时切换页面调了 cancelAllRequestsForPage,它调的是旧 cancel,新请求完全不受控制,继续飞。
- 新请求回来时,finally 拦截器尝试 delete,但 key 对应的还是旧记录,删不掉,造成内存泄漏。
所以:重试时,必须用新的 CancelToken 更新 pendingRequests 中的 cancel 函数。
- 页面切换后仍会继续重试
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 });
}
- 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;
使用
- 普通请求(零额外操作)
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 不会触发。
- 如果用户在同一个页面里连续点击多次,旧的请求会被自动取消,只保留最新的。
- 带精准取消分类的请求(如导出)
如果希望在同一个页面内,能精准地只取消某一类请求(比如只取消导出,不影响列表刷新),就在请求时加一个 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');
}
- 自定义重试配置
有些接口(如上传)可能想调大重试次数或延迟:
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 秒
}
});
}