你大概率遇到过这些 UI 问题:搜索框打字太快,结果列表一会儿跳来跳去;路由切换后,上个页面的请求回来了把状态又写了一遍;或者用户连点两次提交,后端被打了两次还把前端状态搞乱。

这些问题背后的共同点就是:旧请求不该再影响当前界面。最直接的处理方式就是取消它(或者至少忽略它的结果)。下面按 Axios 的两套方案讲清楚怎么做,再给一些更贴近业务的封装写法。

技术实现方案对比

1. 传统 CancelToken 方案

适用版本: Axios < 0.22
实现原理: Axios 自己的一套取消令牌(CancelToken),本质是通过 token 把“取消信号”传进请求链路里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建令牌源
const source = axios.CancelToken.source()

// 发送请求
axios.get('/api', {
cancelToken: source.token
}).catch(err => {
if (axios.isCancel(err)) {
console.log('取消原因:', err.message)
}
})

// 取消请求
source.cancel('用户主动取消')

技术特点

  • 令牌实例维护独立 Promise 状态
  • 取消时触发 Promise 链式反应
  • 需要手动管理引用关系

2. 现代 AbortController 方案

适用版本: Axios >= 0.22
实现原理: 直接用浏览器标准的 AbortController/AbortSignal,和 fetch 是同一套思路。

1
2
3
4
5
6
7
8
9
10
11
12
const controller = new AbortController()

axios.get('/api', {
signal: controller.signal
}).catch(err => {
if (err.name === 'CanceledError') {
console.log('请求被取消了')
}
})

// 终止请求
controller.abort()

技术特点

  • 原生支持 EventTarget 事件机制
  • 与 Fetch API 共享中断逻辑
  • 生态更统一(很多库也直接支持 signal)

核心机制解析

1. 中断时机与效果

很多人第一次用“取消请求”会期待:网络包也会立刻停下来。实际情况要分阶段看:

阶段 行为表现 网络影响
请求未发出 阻止请求发送 无网络流量
请求已发送未响应 标记为取消状态 响应数据被丢弃
请求已完成 不产生影响 正常处理响应

2. 内存管理机制

“内存泄漏”通常不是 CancelToken 自己导致的,而是你把 controller/source 存起来,却忘了清理。比如做请求去重时用 Map 缓存控制器,最后没有在 finally 里删掉 key。

CancelToken 常见坑

1
2
3
4
5
6
7
8
// 错误示例:未清理的令牌引用
const tokens = new Map()

function createRequest() {
const source = axios.CancelToken.source()
tokens.set('key', source)
// 忘记删除将导致内存泄漏
}

AbortController 的正确姿势:关键不在“把 controller 置空”,而在“请求结束就把它从容器里移除”。你下面看到的 smartRequest 就是这个思路。

生产环境最佳实践

下面这几段写法我自己更常用,原因很简单:它们更贴近真实业务问题(重复请求、竞态覆盖、并发太多)。

1. 请求去重封装

业务场景:在复杂表单提交、实时搜索等高频触发请求的场景中,需要防止重复请求造成的资源浪费和状态混乱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 智能请求封装:自动取消相同请求
* @param {Object} config - Axios请求配置
* @returns {Promise} 带取消控制的请求实例
*/
const requestPool = new Map(); // 使用Map保存活跃请求控制器

function smartRequest(config) {
// 生成唯一请求标识:方法+URL+参数哈希
const paramHash = JSON.stringify(config.params || config.data);
const method = (config.method || 'get').toLowerCase();
const requestKey = `${method}-${config.url}-${paramHash}`;

// 终止重复请求
if (requestPool.has(requestKey)) {
const oldController = requestPool.get(requestKey);
oldController.abort(); // 直接取消旧请求
requestPool.delete(requestKey); // 清理旧请求记录
}

// 创建新控制器
const controller = new AbortController();
requestPool.set(requestKey, controller); // 注册到请求池

// 发起请求并自动清理
return axios({
...config,
signal: controller.signal
})
.finally(() => {
// 无论成功失败都清理请求记录
requestPool.delete(requestKey);
})
.catch(err => {
if (err.name === 'CanceledError') {
console.warn('请求被取消:', err.message);
}
throw err;
});
}

关键技术点

  1. 请求指纹算法:通过方法、URL和参数生成唯一标识
  2. 双保险机制:新请求触发旧请求终止,保证唯一性
  3. 自动垃圾回收:finally阶段清理请求记录,防止内存泄漏

2. 竞态条件处理(版本控制)

典型场景:分页快速切换、标签页切换时,确保最终呈现的数据与最后一次操作一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
let requestVersion = 0; // 全局请求版本计数器
let latestController = null;

async function fetchData(params) {
const currentVersion = ++requestVersion; // 原子操作递增版本号

try {
// 有新请求就把旧请求取消掉(可选,但很实用)
if (latestController) latestController.abort();
const controller = new AbortController();
latestController = controller;

const response = await axios.get('/api/data', {
params,
signal: controller.signal
});

// 版本校验(关键防御逻辑)
if (currentVersion !== requestVersion) {
console.log('过期响应已忽略');
return null; // 主动丢弃过期结果
}

return response.data;
} catch (err) {
if (err.name === 'CanceledError') {
console.log('请求被新的一次操作取消');
return null;
}
throw err;
}
}

防御策略

  1. 原子版本号:通过闭包维护全局版本状态
  2. 双保险机制:AbortController + 版本号校验
  3. 过期响应拦截:在业务逻辑层进行二次验证

性能优化策略进阶指南

1. 智能请求调度器(并发控制)

技术痛点:有些页面会一口气打十几个接口(初始化、埋点、推荐、A/B……),弱网 + 低端机时很容易“排队卡死”。给并发加个上限,体验会稳很多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class RequestScheduler {
constructor(maxConcurrent = 3) {
this.queue = [];
this.activeCount = 0;
this.maxConcurrent = maxConcurrent;
}

add(requestFn) {
return new Promise((resolve, reject) => {
// 封装请求以控制执行时机
const execute = () => {
this.activeCount++;
requestFn()
.then(resolve)
.catch(reject)
.finally(() => {
this.activeCount--;
this.runNext();
});
};

// 队列管理
if (this.activeCount < this.maxConcurrent) {
execute();
} else {
this.queue.push(execute);
}
});
}

runNext() {
if (this.queue.length > 0 && this.activeCount < this.maxConcurrent) {
const next = this.queue.shift();
next();
}
}
}

const scheduler = new RequestScheduler(3);

async function controlledRequest(url) {
return scheduler.add(() => axios.get(url));
}

2. 自适应超时控制(动态计算)

“超时配多少”也挺玄学:接口有快有慢、网络有好有坏。一个简单的思路是:根据最近一段时间的响应耗时做个滑动平均,然后动态算一个超时阈值(比如平均的 2 倍)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const responseTimeStats = { avg: 1000, alpha: 0.2 };

function adaptiveTimeoutRequest(config) {
const MAX_TIMEOUT = 15_000;
const baseTimeout = Math.min(MAX_TIMEOUT, Math.round(responseTimeStats.avg * 2)); // 基线为平均2倍
const controller = new AbortController();
const startTime = performance.now();

// 超时计时器
const timeoutId = setTimeout(() => {
controller.abort();
}, baseTimeout);

return axios({
...config,
signal: controller.signal
})
.then(response => {
// 记录成功响应时间
const latency = performance.now() - startTime;
responseTimeStats.avg = responseTimeStats.alpha * latency + (1 - responseTimeStats.alpha) * responseTimeStats.avg;
return response;
})
.catch(err => {
if (err.name === 'CanceledError') {
console.warn(`请求超时(阈值 ${baseTimeout}ms)`);
}
throw err;
})
.finally(() => {
clearTimeout(timeoutId);
});
}

动态调整策略

  1. 初始超时:先给个经验值(比如 1000ms)
  2. 成功响应:用 EMA 更新平均耗时
  3. 超时阈值:按平均耗时的 2 倍/3 倍算(看业务容忍度)
  4. 兜底:一定要保留一个最大超时上限,别让极端慢请求把阈值越拉越大

Happy Coding!