取消异步请求方案深度解析
一句话概括
通过 AbortController 信号机制优雅地中断浏览器中的异步请求,是现代前端处理请求取消、避免内存泄漏和竞态条件的最佳实践。
背景
在前端开发中,异步请求是无处不在的。但你是否遇到过以下场景:
搜索联想——用户的噩梦。 用户在搜索框输入 “react”,你立即发起了请求 A。随后用户继续输入 “react hook”,又发起了请求 B。但请求 A 可能比请求 B 更晚返回,导致搜索结果先显示 “react”,随后被 “react hook” 覆盖——用户看到的是滞后的数据,体验极差。
页面切换——内存泄漏的温床。 用户在列表页点击了某个项目,页面跳转到详情页,但列表页发起的请求还在后台运行并等待回调。当响应返回时,你的 React 组件已经被卸载,setState 会抛出 Cannot update unmounted component 错误,同时造成不必要的内存占用。
重复请求——资源的浪费。 用户反复点击按钮、刷新页面、同一个接口被多次调用——这些都会对服务器造成不必要的压力,也浪费了用户的带宽。
文件上传 / 大文件下载——用户取消后的空耗。 用户选择了一个大文件开始上传,中途后悔了点了取消,但请求还在悄悄发送直到完成,浪费了大量时间和流量。
解决这些问题的核心,就是请求取消机制。本文将系统讲解三种取消方案的原理、用法与最佳实践,重点剖析浏览器原生提供的 AbortController API,以及它在现代前端框架中的实际应用。
概念与定义
AbortController
AbortController 是浏览器原生提供的 Web API(自 DOM 标准引入),用于中止一个或多个 Web 请求(包括 fetch 请求及其他支持 AbortSignal 的异步操作)。它是一个简单的对象,包含:
controller.signal:返回一个AbortSignal对象实例,该对象可以传递给支持取消的 API(如 fetch)。controller.abort():调用此方法会触发 signal 的 aborted 状态,所有监听该 signal 的操作将被中断。
AbortSignal
AbortSignal 是一个对象,表示一个可选的取消信号。AbortController 实例的 .signal 属性就是 AbortSignal。你可以:
- 通过
signal.aborted属性判断是否已中止。 - 通过
signal.addEventListener('abort', callback)监听中止事件。 - 将
signal传递给fetch()等 API,当 signal 中止时,对应的请求会被中断。
请求取消的语义
需要特别注意的是,取消请求的语义因场景而异:
- fetch 取消:浏览器会终止网络连接(尽可能),请求不再等待响应,但服务器端可能已经处理了该请求(幂等性问题需注意)。
- axios 取消:底层也是基于 AbortController(新版)或 CancelToken(旧版),效果与 fetch 类似。
- Promise 取消:JavaScript 原生 Promise 没有取消机制,需要通过包装或额外的控制变量来实现”可取消”的异步行为。
最小示例
以下是一个完整可运行的示例,展示如何使用 AbortController 取消一个 fetch 请求:
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
// 创建 AbortController 实例
const controller = new AbortController();
const { signal } = controller;
// 发起带取消信号的 fetch 请求
const fetchWithCancel = async (url) => {
try {
const response = await fetch(url, { signal });
const data = await response.json();
console.log('请求成功:', data);
return data;
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求已被取消');
} else {
console.error('其他错误:', error);
}
throw error;
}
};
// 立即发起请求(2秒后自动取消作为演示)
const promise = fetchWithCancel('https://jsonplaceholder.typicode.com/posts/1');
// 模拟:1.5秒后取消请求
setTimeout(() => {
controller.abort();
console.log('已调用 abort()');
}, 1500);
运行结果:请求将在 1.5 秒时被中断,控制台输出 “请求已被取消”。这就是 AbortController 的最小工作模型。
核心知识点拆解
1. AbortController API 详解
构造函数
1
const controller = new AbortController();
AbortController 构造函数不接受任何参数,创建一个新的控制器实例。
signal 属性
1
2
3
const { signal } = controller;
console.log(signal instanceof AbortSignal); // true
console.log(signal.aborted); // false(初始状态)
AbortSignal 对象本身是一个事件目标(EventTarget),可以监听 abort 事件:
1
2
3
4
5
6
7
8
9
signal.addEventListener('abort', () => {
console.log('Signal 已中止!');
// 执行清理逻辑:取消定时器、停止播放、释放资源等
});
// 也可以使用 onabort 快捷方式
signal.onabort = () => {
console.log('通过 onabort 回调处理取消');
};
abort() 方法
1
2
3
4
5
controller.abort();
// 调用后:
// 1. signal.aborted === true
// 2. 所有监听 'abort' 事件的回调被执行
// 3. 传递给 fetch 等 API 的 signal 会触发中止
abort() 方法还支持传入一个可选的reason参数(现代浏览器支持),用于传递取消原因:
1
2
3
4
5
controller.abort('用户主动取消');
// 在 abort 事件中可以通过 signal.reason 获取该值
signal.addEventListener('abort', () => {
console.log('取消原因:', signal.reason); // "用户主动取消"
});
组合多个信号
AbortSignal 还支持通过 AbortSignal.any() 组合多个信号(现代浏览器支持):
1
2
3
// 任一信号中止,整个组合信号就中止
const combinedSignal = AbortSignal.any([signal1, signal2]);
fetch(url, { signal: combinedSignal });
2. axios 取消请求机制
CancelToken(旧方案,已废弃)
axios 早期使用 CancelToken 来取消请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 旧版 axios 取消请求方式(v0.22 之前)
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get('/api/data', {
cancelToken: source.token
}).catch(err => {
if (axios.isCancel(err)) {
console.log('请求已取消', err.message);
}
});
// 取消请求
source.cancel('Operation canceled by the user.');
问题:CancelToken 是 axios 自己实现的一套机制,与原生 AbortController 不兼容,迁移成本高。axios 在 v0.22 之后已废弃此方案。
AbortController(新方案,推荐)
新版 axios(v0.22+)原生支持 AbortController:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 新版 axios 取消请求方式(推荐)
const controller = new AbortController();
axios.get('/api/data', {
signal: controller.signal
}).catch(err => {
if (axios.isCancel(err)) {
console.log('请求已取消');
} else if (err.name === 'AbortError') {
console.log('通过 AbortController 取消');
}
});
// 取消请求
controller.abort();
批量取消
在大型应用中,可能需要同时取消多个请求。可以使用 AbortController 的组合能力:
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
// 管理多个请求的控制器
class RequestManager {
constructor() {
this.controllers = new Map();
}
// 添加请求并返回 controller
addRequest(id) {
const controller = new AbortController();
this.controllers.set(id, controller);
return controller;
}
// 取消单个请求
cancelRequest(id) {
const controller = this.controllers.get(id);
if (controller) {
controller.abort();
this.controllers.delete(id);
}
}
// 取消所有请求
cancelAll() {
this.controllers.forEach(controller => controller.abort());
this.controllers.clear();
}
}
const manager = new RequestManager();
// 发起多个请求
const ctrl1 = manager.addRequest('fetch-list');
const ctrl2 = manager.addRequest('fetch-detail');
// 页面切换时取消所有请求
manager.cancelAll();
3. Promise 取消思路
为什么 Promise 不能被取消?
这是一个经典的误解。Promise 代表的是一个异步操作的最终结果,它是一次性的、不可变的状态容器:
- Promise 有三种状态:
pending(进行中)、fulfilled(已成功)、rejected(已失败)。 - 状态一旦变更,就不可逆转。
- Promise 规范(ES6+)从未定义”取消”操作。
这与 Promise 的设计哲学一致:Promise 代表的是”承诺的结果”,你不能撤销一个已经发生的承诺。
通过包装实现可取消的 Promise
虽然没有原生的取消机制,但可以通过额外逻辑模拟取消行为:
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
// 方法一:使用控制变量
function cancellablePromise(executor) {
let isCancelled = false;
const promise = new Promise((resolve, reject) => {
executor(
(value) => {
if (!isCancelled) resolve(value);
},
(error) => {
if (!isCancelled) reject(error);
}
);
});
return {
promise,
cancel() {
isCancelled = true;
}
};
}
// 使用示例
const { promise, cancel } = cancellablePromise((resolve, reject) => {
const timer = setTimeout(() => resolve('完成'), 3000);
// 返回清理函数
return () => clearTimeout(timer);
});
promise.then(console.log).catch(console.error);
cancel(); // 3秒后不会输出任何内容(被取消了)
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
// 方法二:基于 AbortSignal 的取消 Promise
function promiseWithSignal(promise, signal) {
return new Promise((resolve, reject) => {
// 监听 abort 事件
const abortHandler = () => {
reject(new DOMException('Aborted', 'AbortError'));
};
if (signal?.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
signal?.addEventListener('abort', abortHandler, { once: true });
// 包装原始 promise
promise
.then((value) => {
signal?.removeEventListener('abort', abortHandler);
resolve(value);
})
.catch((error) => {
signal?.removeEventListener('abort', abortHandler);
reject(error);
});
});
}
// 使用示例
const controller = new AbortController();
const dataPromise = promiseWithSignal(
fetch('https://api.example.com/data').then(r => r.json()),
controller.signal
);
// 取消
controller.abort(); // dataPromise 会被 reject,reason 是 AbortError
4. 取消请求的最佳实践
组件卸载时取消
在 React 中,组件卸载时取消请求是避免内存泄漏的关键:
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
import { useEffect, useRef } from 'react';
function UserProfile({ userId }) {
const abortControllerRef = useRef(null);
useEffect(() => {
// 每次 userId 变化时,创建新的 controller
abortControllerRef.current = new AbortController();
fetch(`/api/users/${userId}`, {
signal: abortControllerRef.current.signal
})
.then(r => r.json())
.then(data => console.log(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error('请求错误:', err);
}
});
// 清理函数:组件卸载时自动取消
return () => {
abortControllerRef.current?.abort();
};
}, [userId]);
return <div>用户资料</div>;
}
路由切换时取消
在单页应用(SPA)中,路由切换不等同于组件卸载(如果使用了 React Router 的 <Outlet> 或 Vue 的 keep-alive,组件可能复用)。因此需要在路由守卫中处理:
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
// React Router v6 路由级别请求管理
import { useEffect } from 'react';
import { useNavigate, useNavigationType } from 'react-router-dom';
function useCancelOnNavigation() {
const abortControllerRef = useRef(new AbortController());
const navigate = useNavigate();
useEffect(() => {
const controller = abortControllerRef.current;
// 监听路由变化事件
const handleRouteChange = () => {
// 先取消上一个路由的所有请求
controller.abort();
// 创建新的 controller 供下一个路由使用
abortControllerRef.current = new AbortController();
};
// 在 navigation 事件前触发
return () => {
controller.abort();
};
}, [navigate]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Vue Router 路由守卫取消请求
import axios from 'axios';
const controllerMap = new Map();
router.beforeEach((to, from, next) => {
// 取消上一个路由的所有请求
controllerMap.forEach(controller => controller.abort());
controllerMap.clear();
// 为新路由创建 controller
const controller = new AbortController();
controllerMap.set(to.fullPath, controller);
// 将 controller 挂载到 axios 全局
axios.defaults.signal = controller.signal;
next();
});
竞态请求处理
当同一个请求可能被多次触发时(如搜索联想),需要确保只处理最后一次请求的响应:
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
44
45
46
// 方案一:请求 ID 管理(debounce 配合)
function createRequestManager() {
let currentController = null;
let requestId = 0;
return {
async fetch(url, options = {}) {
// 取消上一次请求
if (currentController) {
currentController.abort();
}
// 创建新请求
currentController = new AbortController();
const thisRequestId = ++requestId;
try {
const response = await fetch(url, {
...options,
signal: currentController.signal
});
// 检查请求是否过期(用户可能已经发起了更新的请求)
if (thisRequestId !== requestId) {
console.log('请求已过期,忽略结果');
return;
}
const data = await response.json();
return data;
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求被后续请求取消');
}
throw error;
}
},
cancelAll() {
if (currentController) {
currentController.abort();
currentController = null;
}
}
};
}
实战案例
React 搜索联想组件
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import { useState, useEffect, useRef, useCallback } from 'react';
function SearchAutocomplete({ onSelect }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const abortControllerRef = useRef(null);
const debounceRef = useRef(null);
const search = useCallback(async (searchQuery) => {
// 取消上一次未完成的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (!searchQuery.trim()) {
setResults([]);
setLoading(false);
return;
}
// 创建新的 AbortController
abortControllerRef.current = new AbortController();
setLoading(true);
try {
const response = await fetch(
`https://api.example.com/search?q=${encodeURIComponent(searchQuery)}`,
{ signal: abortControllerRef.current.signal }
);
if (!response.ok) throw new Error('请求失败');
const data = await response.json();
setResults(data.results || []);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('搜索失败:', error);
setResults([]);
}
} finally {
setLoading(false);
}
}, []);
const handleInputChange = (e) => {
const value = e.target.value;
setQuery(value);
// 防抖:停止输入 300ms 后才发起请求
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
search(value);
}, 300);
};
// 组件卸载时清理
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
return (
<div className="search-autocomplete">
<input
type="text"
value={query}
onChange={handleInputChange}
placeholder="搜索..."
/>
{loading && <span className="loading">加载中...</span>}
<ul className="results">
{results.map((item) => (
<li key={item.id} onClick={() => onSelect(item)}>
{item.name}
</li>
))}
</ul>
</div>
);
}
Vue 路由切换取消
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
// useFetch.js - Vue Composition API 请求管理 hook
import { ref, onUnmounted } from 'vue';
import axios from 'axios';
export function useFetch() {
const abortController = ref(null);
const request = async (url, options = {}) => {
// 取消上一次请求(如果存在)
if (abortController.value) {
abortController.value.abort();
}
abortController.value = new AbortController();
try {
const response = await axios.get(url, {
signal: abortController.value.signal,
...options
});
return response.data;
} catch (error) {
if (axios.isCancel(error) || error.name === 'AbortError') {
console.log('请求已被取消');
return null;
}
throw error;
}
};
const cancel = () => {
abortController.value?.abort();
};
// 组件卸载时自动取消
onUnmounted(() => {
cancel();
});
return { request, cancel };
}
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
// 在 Vue 组件中使用
// UserList.vue
import { defineComponent, ref } from 'vue';
import { useFetch } from '@/composables/useFetch';
export default defineComponent({
setup() {
const users = ref([]);
const { request, cancel } = useFetch();
const loadUsers = async () => {
const data = await request('/api/users');
if (data) users.value = data;
};
// 路由切换时(beforeRouteLeave)可以手动取消
// beforeRouteLeave(() => {
// cancel();
// });
loadUsers();
return { users };
}
});
重复请求自动取消
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// RequestDeduplicator - 请求去重与自动取消工具
class RequestDeduplicator {
constructor() {
this.pendingControllers = new Map();
}
// 生成请求的唯一 key
generateKey(url, options = {}) {
return `${url}:${JSON.stringify(options)}`;
}
async request(url, options = {}) {
const key = this.generateKey(url, options);
// 如果存在相同请求,先取消旧的
if (this.pendingControllers.has(key)) {
const oldController = this.pendingControllers.get(key);
oldController.abort(`[去重] 取消旧的 ${key} 请求`);
console.log(`取消重复请求: ${key}`);
}
// 创建新请求的 controller
const controller = new AbortController();
this.pendingControllers.set(key, controller);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
// 请求完成后移除记录
this.pendingControllers.delete(key);
return response;
} catch (error) {
this.pendingControllers.delete(key);
if (error.name === 'AbortError') {
// 区分"主动取消"和"去重取消"
if (error.message.startsWith('[去重]')) {
console.log('重复请求被新请求替代');
}
return null;
}
throw error;
}
}
// 取消所有请求
cancelAll() {
this.pendingControllers.forEach(controller => controller.abort());
this.pendingControllers.clear();
}
}
const deduplicator = new RequestDeduplicator();
// 多次调用同一个接口,只有最新的会生效
deduplicator.request('/api/users'); // 旧请求
deduplicator.request('/api/users'); // 旧请求被取消
deduplicator.request('/api/users'); // 这个是最终生效的
底层原理
AbortController 在浏览器中的实现原理
AbortController 和 AbortSignal 是浏览器 DOM 标准的一部分,其实现位于浏览器的底层 C++ 代码中。以 Chromium 为例:
对象模型:
1
2
3
4
5
6
7
AbortController
└── signal: AbortSignal
├── aborted: boolean
├── reason: any
└── eventTarget (继承自 EventTarget)
AbortSignal 继承自 EventTarget,支持 addEventListener/removeEventListener
核心状态机:
1
2
3
4
5
6
7
8
9
[Ready]
│
│ controller.abort() 被调用
▼
[Aborting] ──→ signal.aborted = true
│
│ 所有监听 'abort' 事件被触发
▼
[Aborted]
信号传递机制:
当 controller.abort() 被调用时,浏览器内部会执行以下步骤:
- 将
signal.aborted标记为true - 设置
signal.reason为传入的 reason(默认是 “AbortError” DOMException) - 同步触发
signal上的abort事件(调用所有注册的abort监听器) - 对于正在进行中的
fetch请求,浏览器会尝试通过关闭底层 TCP 连接来终止网络通信
fetch 取消的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
fetch(url, { signal })
│
├─→ 检查 signal.aborted(发起请求前)
│ └─→ 若是,直接抛出 AbortError
│
└─→ 发起网络请求(HTTP 请求已发送)
│
├─→ signal.addEventListener('abort', handler)
│ └─→ 浏览器关闭 TCP 连接
│
└─→ 请求被中止
│
└─→ fetch Promise 以 AbortError reject
关键点:HTTP 请求一旦发出,服务器可能已经处理了该请求。取消只是客户端停止等待响应。如果请求是幂等的(GET、PUT、DELETE),重新发起请求通常是安全的;如果是非幂等的(POST),需要后端配合做幂等处理。
取消后资源释放
正确取消后,以下资源会被释放:
| 资源类型 | 释放情况 | 说明 |
|---|---|---|
| TCP 连接 | ✅ 浏览器关闭连接 | 不再等待响应 |
| 内存(响应数据) | ✅ 不再分配 | 未完成的数据传输被中断 |
| 定时器 | ❌ 需要手动清理 | setTimeout/setInterval 不受 AbortController 影响 |
| 流(ReadableStream) | ✅ 自动取消 | fetch 的 body 流会被取消 |
| DOM 更新 | ❌ 需要检查 | 需确保组件已卸载或做了状态守卫 |
| WebSocket 连接 | ❌ 需手动关闭 | AbortController 不支持 WebSocket 取消 |
| 文件句柄 | ❌ 需手动释放 | 如 FileReader、MediaStreamTrack |
重要提醒:AbortController 不会自动取消 setTimeout、setInterval、WebSocket 等不受支持的操作。如果你需要在取消请求时同时清理定时器,应该在 abort 事件处理函数中手动清理:
1
2
3
4
5
6
7
8
9
const controller = new AbortController();
const timer = setTimeout(() => console.log('done'), 5000);
controller.signal.addEventListener('abort', () => {
clearTimeout(timer); // 手动清理定时器
console.log('请求取消,清理定时器');
});
setTimeout(() => controller.abort(), 1000);
高频面试题解析
Q1: AbortController 的工作原理是什么?
答:AbortController 是浏览器原生提供的 Web API,用于中止一个或多个 Web 请求或异步操作。其核心由两部分组成:
AbortController:控制器对象,提供 signal 属性和 abort() 方法。
1
2
const controller = new AbortController();
const { signal } = controller;
AbortSignal:信号对象,继承自 EventTarget。当 controller.abort() 被调用时,signal 的 aborted 属性变为 true,并同步触发 abort 事件。
1
2
3
4
5
6
// 工作流程:
// 1. 创建 controller,得到 signal
// 2. 将 signal 传递给 fetch 等支持取消的 API
// 3. API 内部监听 signal 的 abort 事件
// 4. 调用 controller.abort() 触发中止
// 5. fetch 等 API 收到信号,停止等待并 reject AbortError
浏览器内部通过事件分发机制实现:当 abort() 被调用时,浏览器会将 aborted 标志设为 true,然后同步触发所有注册在 AbortSignal 上的 abort 事件监听器。对于 fetch,浏览器还会在底层关闭对应的 TCP 连接,确保请求被真正中止。
Q2: 如何在 React 组件卸载时取消所有未完成的请求?
答:核心思路是在 useEffect 的清理函数中调用 abort(),并在组件卸载前中止所有未完成的请求。以下是完整的自定义 Hook 方案:
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
44
45
46
47
48
49
50
51
import { useEffect, useRef, useCallback } from 'react';
// useCancelOnUnmount - 组件卸载时自动取消请求的 Hook
function useCancelOnUnmount() {
const abortControllerRef = useRef(null);
// 获取当前的 signal(首次调用时创建 controller)
const getSignal = useCallback(() => {
if (!abortControllerRef.current) {
abortControllerRef.current = new AbortController();
}
return abortControllerRef.current.signal;
}, []);
// 组件卸载时自动取消
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, []);
return { getSignal };
}
// 使用示例
function UserProfile({ userId }) {
const { getSignal } = useCancelOnUnmount();
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: getSignal()
});
const data = await response.json();
console.log('用户数据:', data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('请求失败:', error);
}
}
};
fetchUser();
}, [userId, getSignal]);
return <div>用户资料页面</div>;
}
对于多个请求的管理,可以扩展为请求管理器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 更完整的请求管理 Hook
function useAsyncRequest() {
const controllerRef = useRef(new AbortController());
const requestsRef = useRef([]);
useEffect(() => {
return () => {
controllerRef.current.abort();
};
}, []);
const request = useCallback(async (url, options = {}) => {
const response = await fetch(url, {
...options,
signal: controllerRef.current.signal
});
return response.json();
}, []);
return { request };
}
Q3: Promise 为什么不能被取消?如何实现可取消的 Promise?
答:Promise 的不可取消性是由其设计目标决定的。Promise 代表的是一个异步操作的最终结果,是一种一次性、不可变的状态容器:
pending→fulfilled:一旦成功就不能变回 pendingpending→rejected:一旦失败就不能变回 pending- 状态变更不可逆
这种设计是为了保证 Promise 链的可预测性和一致性。如果 Promise 可以被取消,.then() 链的行为将变得不确定。
实现可取消 Promise 的方法:
方法一:外部取消标志
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
function makeCancellable(promise) {
let isCancelled = false;
const wrappedPromise = new Promise((resolve, reject) => {
promise
.then((value) => {
if (isCancelled) {
reject({ isCancelled: true, value });
} else {
resolve(value);
}
})
.catch((error) => {
if (isCancelled) {
reject({ isCancelled: true, error });
} else {
reject(error);
}
});
});
return {
promise: wrappedPromise,
cancel(reason = 'Cancelled') {
isCancelled = true;
return reason;
},
get cancelled() {
return isCancelled;
}
};
}
// 使用示例
const { promise, cancel } = makeCancellable(
new Promise(resolve => setTimeout(() => resolve('data'), 3000))
);
promise.then(console.log).catch(err => {
if (err.isCancelled) console.log('被取消:', err);
});
setTimeout(() => cancel('用户取消'), 1000);
方法二:基于 AbortSignal 的标准方案(推荐)
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
44
45
46
47
function toCancellable(promise, signal) {
return new Promise((resolve, reject) => {
const rejectIfAborted = () => {
reject(new DOMException('Aborted', 'AbortError'));
};
if (signal?.aborted) {
rejectIfAborted();
return;
}
const abortHandler = () => {
reject(new DOMException('Aborted', 'AbortError'));
};
signal?.addEventListener('abort', abortHandler, { once: true });
promise
.then((value) => {
signal?.removeEventListener('abort', abortHandler);
resolve(value);
})
.catch((error) => {
signal?.removeEventListener('abort', abortHandler);
reject(error);
});
});
}
// 使用示例
const controller = new AbortController();
const dataPromise = toCancellable(
fetch('https://api.example.com/data').then(r => r.json()),
controller.signal
);
dataPromise
.then(console.log)
.catch(err => {
if (err.name === 'AbortError') {
console.log('Promise 已被取消');
}
});
// 取消
controller.abort();
方法三:利用 Promise.race 实现超时取消
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function withTimeout(promise, timeoutMs, timeoutError = new Error('Timeout')) {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(timeoutError), timeoutMs)
);
return Promise.race([promise, timeoutPromise]);
}
// 使用示例:5秒超时
const result = await withTimeout(
fetch('/api/slow-endpoint').then(r => r.json()),
5000,
new Error('请求超时')
);
Q4: 搜索联想场景如何避免竞态问题?
答:搜索联想是最经典的竞态问题场景。用户快速输入 “abc”,可能同时发出三个请求:请求 A(”a”)、请求 B(”ab”)、请求 C(”abc”)。如果请求 A 比请求 C 更晚返回,用户会看到错误的搜索结果。
完整解决方案(结合防抖 + AbortController):
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// SearchManager - 搜索请求竞态处理管理器
class SearchManager {
constructor() {
this.controller = null;
this.requestId = 0;
this.debounceTimer = null;
}
search(query, options = {}) {
const { onSuccess, onError, onLoading, debounceMs = 300 } = options;
// 清除之前的防抖定时器
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// 设置新的防抖定时器
this.debounceTimer = setTimeout(async () => {
await this.executeSearch(query, onSuccess, onError, onLoading);
}, debounceMs);
}
async executeSearch(query, onSuccess, onError, onLoading) {
// 取消上一次请求
if (this.controller) {
this.controller.abort();
}
// 创建新的 controller
this.controller = new AbortController();
const currentRequestId = ++this.requestId;
onLoading?.(true);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: this.controller.signal,
headers: { 'X-Request-Id': currentRequestId }
}
);
// 关键:检查这个请求是否已过时
// 如果用户在这期间输入了新内容,requestId 已经增加
if (currentRequestId !== this.requestId) {
console.log(`请求 ${currentRequestId} 已过期,忽略结果`);
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
onSuccess?.(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log(`请求 ${currentRequestId} 被取消`);
return; // 不触发 error 回调
}
if (currentRequestId === this.requestId) {
// 只有最新的请求才触发 error
onError?.(error);
}
} finally {
if (currentRequestId === this.requestId) {
onLoading?.(false);
}
}
}
cancel() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.controller) {
this.controller.abort();
}
}
}
// 在 React 组件中使用
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const searchManagerRef = useRef(new SearchManager());
useEffect(() => {
return () => {
searchManagerRef.current.cancel();
};
}, []);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
searchManagerRef.current.search(value, {
onSuccess: (data) => setResults(data),
onError: (err) => console.error('搜索失败', err),
onLoading: setIsLoading,
debounceMs: 300
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isLoading && <span>搜索中...</span>}
<ul>
{results.map(r => <li key={r.id}>{r.name}</li>)}
</ul>
</div>
);
}
关键技巧总结:
- 请求 ID 标记:每次发起新请求时递增 ID,结果返回时检查 ID 是否匹配。
- 防抖(Debounce):避免每个字符变化都发请求。
- AbortController 取消:确保旧请求被中断,减少不必要的网络和计算。
- 状态守卫:在 finally 中检查是否是最新请求,再更新 loading 状态。
总结与扩展
核心要点回顾
- AbortController 是浏览器原生标准,是取消 fetch 请求的首选方案,兼容性好、性能优秀。
- axios v0.22+ 原生支持 AbortController,CancelToken 已废弃,应使用
signal参数。 - Promise 本身不可取消,但可以通过包装、状态标志或 AbortSignal 来实现可取消的异步行为。
- 组件卸载 + 路由切换是取消请求的两个关键时机,必须配合
useEffect清理函数或路由守卫来处理。 - 搜索联想是最经典的竞态场景,需要防抖 + 请求 ID 标记 + AbortController 三管齐下。
AbortController 在非请求场景的应用
AbortController 的 signal 并不局限于网络请求,任何支持 AbortSignal 的异步操作都可以被取消:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 取消 Web Animations API 动画
const animation = element.animate([...], { duration: 2000 });
controller.signal.addEventListener('abort', () => {
animation.cancel();
});
// 取消媒体流
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
controller.signal.addEventListener('abort', () => {
stream.getTracks().forEach(track => track.stop());
});
// 取消 async iterator(ReadableStream)
const reader = response.body.getReader();
controller.signal.addEventListener('abort', () => {
reader.cancel();
});
// 取消 EventTarget 上的事件监听(通过 AbortController)
const controller = new AbortController();
element.addEventListener('click', handler, { signal: controller.signal });
// 稍后移除监听
controller.abort();
与 RxJS takeUntil 的对比
RxJS 提供了强大的响应式取消能力,与 AbortController 对比如下:
| 特性 | AbortController | RxJS takeUntil |
|---|---|---|
| 依赖 | 浏览器原生 API | 需要引入 rxjs 库 |
| 适用场景 | 网络请求、EventTarget | 所有 Observable 流 |
| 语法复杂度 | 简单直接 | 需要学习 RxJS 操作符 |
| 组合能力 | AbortSignal.any() | 更多操作符(combineLatest 等) |
| 取消语义 | 中止信号,清理逻辑需手动 | 自动完成(complete 通知) |
| 学习曲线 | 平缓 | 陡峭 |
1
2
3
4
5
6
7
8
9
10
11
// RxJS takeUntil 写法(对比参考)
import { fromEvent, takeUntil } from 'rxjs';
const destroy$ = fromEvent(window, 'beforeunload');
fetch('/api/data')
.then(r => r.json())
.pipe(
takeUntil(destroy$) // 页面卸载时自动取消
)
.subscribe(console.log);
如果你的项目已经使用了 RxJS,takeUntil 是优雅的取消方案;如果项目较轻量,AbortController 是更简洁的选择。
掌握好请求取消机制,不仅能提升用户体验(避免错误数据展示)、优化性能(减少无效网络请求),更是防止内存泄漏和构建健壮前端应用的基础。希望本文能帮助你深入理解这一关键技能,在实际项目中游刃有余。