你大概率遇到过这些 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
|
const requestPool = new Map();
function smartRequest(config) { 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; }); }
|
关键技术点:
- 请求指纹算法:通过方法、URL和参数生成唯一标识
- 双保险机制:新请求触发旧请求终止,保证唯一性
- 自动垃圾回收: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; } }
|
防御策略:
- 原子版本号:通过闭包维护全局版本状态
- 双保险机制:AbortController + 版本号校验
- 过期响应拦截:在业务逻辑层进行二次验证
性能优化策略进阶指南
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)); 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); }); }
|
动态调整策略:
- 初始超时:先给个经验值(比如 1000ms)
- 成功响应:用 EMA 更新平均耗时
- 超时阈值:按平均耗时的 2 倍/3 倍算(看业务容忍度)
- 兜底:一定要保留一个最大超时上限,别让极端慢请求把阈值越拉越大
Happy Coding!