你有没有遇到过这样的场景:用户快速切换页面时,前一个页面的请求还在路上,等它返回后结果被错误地填充到了当前页面;或者一个耗时操作已经不再需要了,但因为没有取消机制,它还在后台占用资源。这类问题本质上是同一个原因——原生 Promise 没有取消能力。
好消息是,现代浏览器提供了 AbortController 和 AbortSignal 这套原生 API,专门用来解决异步操作的取消问题。一开始大家主要用它来取消 fetch 请求,但它的用法远不止于此。这篇文章会带你从基础用法出发,一步步封装出一个可以取消任意异步任务、支持超时和重试的工具模块。所有代码都可以直接在浏览器里跑,不需要任何框架。
先理解两个核心对象:AbortController 与 AbortSignal
整个机制的核心思想非常简单:有一个“控制器”(AbortController),它能发出一个“信号”(AbortSignal)。任何监听这个信号的异步任务,都能在控制器调用 abort() 时收到通知,然后主动终止自己的工作。
代码长这样:
// 创建一个控制器
const controller = new AbortController();
// 获取它对应的信号
const signal = controller.signal;
// 将这个信号传给某个支持 AbortSignal 的API,比如 fetch
fetch('/api/data', { signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求被取消了');
} else {
console.error('请求失败', err);
}
});
// 在需要的时候取消
controller.abort();
上面这个例子中,调用 abort() 后,fetch 内部会抛出一个 AbortError(注意不是异常,而是一个名字为 ‘AbortError’ 的 DOMException)。我们可以通过 err.name 来区分它是被取消还是真的网络错误。
这里有一个容易被忽略的点:AbortController 是一次性的。一旦你调用了 abort(),这个信号就永远处于“已中止”状态。如果你需要重复发起可取消的请求,就得在每次操作前创建新的 AbortController 实例。
不只是fetch:让任何异步函数变得可取消
AbortSignal 真正强大的地方在于,你可以把它塞进任何异步逻辑里。它提供了一个 aborted 属性(只读布尔值)和一个 throwIfAborted() 方法,以及一个 onabort 事件监听器。利用这些,我们可以把普通的 Promise 包装成“可取消的 Promise”。
下面这个函数可以包装任意返回 Promise 的工厂函数,使其支持 AbortSignal:
/**
* 创建一个可取消的异步任务
* @param {AsyncFunction} taskFn 一个返回Promise的异步函数,会接收signal作为参数
* @param {AbortSignal} signal 外部传入的信号
* @returns {Promise} 包装后的Promise,signal取消时会reject AbortError
*/
function createCancellableTask(taskFn, signal) {
return new Promise((resolve, reject) => {
// 如果信号在创建任务前就已经被中止了,直接拒绝
if (signal.aborted) {
return reject(new DOMException('任务已被取消', 'AbortError'));
}
// 监听取消事件
const onAbort = () => {
reject(new DOMException('任务已被取消', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
// 执行原始任务,传递signal让它内部可以检查取消状态
taskFn(signal)
.then(resolve, reject)
.finally(() => {
// 清理监听器,避免内存泄漏
signal.removeEventListener('abort', onAbort);
});
});
}
使用示例:
const controller = new AbortController();
const task = createCancellableTask(async (signal) => {
for (let i = 0; i < 10; i++) {
// 在循环中检查取消状态
signal.throwIfAborted();
await new Promise(resolve => setTimeout(resolve, 500));
console.log('处理步骤', i + 1);
}
return '完成';
}, controller.signal);
task
.then(result => console.log(result))
.catch(err => {
if (err.name === 'AbortError') {
console.log('任务中途取消');
}
});
// 2秒后取消
setTimeout(() => controller.abort(), 2000);
运行这段代码,你会看到控制台只打印了前几次步骤,然后就被取消了。关键是 signal.throwIfAborted() 这个调用——如果此时 signal 已经被中止,它会立刻抛出一个 AbortError,从而终止循环。如果你的异步任务里有比较长的计算过程,建议在关键节点上主动调用这个方法,保证取消能被及时响应。
封装一个带超时和取消的请求函数
光有取消还不够,实际开发中我们经常需要“一定时间内没响应就自动取消”,也就是超时控制。我们可以把超时当作一种特殊的取消:由定时器触发 controller.abort()。
下面这个封装函数同时支持外部取消和内部超时:
/**
* 发起一个带超时和取消功能的fetch请求
* @param {string} url 请求地址
* @param {object} options 请求配置,可包含 timeout(毫秒)和 signal(外部AbortSignal)
* @returns {Promise} 响应数据
*/
async function fetchWithTimeout(url, options = {}) {
const { timeout = 10000, signal: externalSignal, ...fetchOptions } = options;
// 每次请求创建独立的AbortController
const controller = new AbortController();
const signal = controller.signal;
// 如果有外部signal,需要让外部取消也能触发内部取消
if (externalSignal) {
if (externalSignal.aborted) {
throw new DOMException('请求已被外部取消', 'AbortError');
}
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
// 超时定时器
const timeoutId = setTimeout(() => {
controller.abort();
}, timeout);
try {
const response = await fetch(url, { ...fetchOptions, signal });
// 请求成功,清除超时定时器
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
// 如果是AbortError,判断是否因为超时引起
if (err.name === 'AbortError') {
if (signal.aborted && !externalSignal?.aborted) {
throw new DOMException(`请求超时 (${timeout}ms)`, 'TimeoutError');
}
throw err; // 外部取消直接抛出AbortError
}
throw err;
}
}
这里做了一层判断:如果 signal.aborted 为真且外部 signal 没有被取消,说明是超时触发的取消,我们抛出一个自定义的 TimeoutError 以便调用方区分超时和主动取消。
调用方式:
// 常规超时控制
fetchWithTimeout('/api/slow-endpoint', { timeout: 5000 })
.then(data => console.log('数据', data))
.catch(err => {
if (err.name === 'TimeoutError') {
console.warn('请求超时,请稍后重试');
} else if (err.name === 'AbortError') {
console.log('请求被取消');
}
});
// 结合外部取消(比如用户离开当前视图)
const pageController = new AbortController();
fetchWithTimeout('/api/data', { timeout: 8000, signal: pageController.signal });
// 页面卸载时取消所有请求
window.addEventListener('beforeunload', () => {
pageController.abort();
});
在单页应用中,这个模式非常实用。你可以在页面或组件的 effect 中创建一个 AbortController,在 cleanup 时调用 abort(),这样该页面所有异步操作都会自动清理,不会遗留到下一个页面。
添加重试逻辑:失败自动重试,但取消和超时立即停止
超时之后我们通常不想立即失败,而是给个机会重试。但要注意:如果重试过程中用户主动取消了,或者外部 signal 被中止了,重试应该立刻停止,而不是固执地重试完所有次数。
下面的函数在 fetchWithTimeout 基础上增加了最大重试次数和重试间隔:
/**
* 带重试的请求
* @param {string} url
* @param {object} options 包含 retries(重试次数)、retryDelay(间隔ms)、超时和signal
*/
async function fetchWithRetry(url, options = {}) {
const { retries = 2, retryDelay = 1000, ...restOptions } = options;
let lastError;
for (let i = 0; i <= retries; i++) {
try {
return await fetchWithTimeout(url, restOptions);
} catch (err) {
lastError = err;
// 如果是主动取消(AbortError)或是不该重试的错误,直接抛
if (err.name === 'AbortError') throw err;
if (i < retries) {
console.log(`第${i + 1}次尝试失败,${retryDelay}ms后重试...`, err.message);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
throw lastError;
}
注意这里的判断:一旦捕获到 AbortError(无论来自外部取消还是超时),我们直接停止重试并抛出。因为你不可能在用户明确要取消的时候还继续重试——这不符合用户预期。
使用这个函数与 fetchWithTimeout 类似,只是多了两个参数:
const data = await fetchWithRetry('/api/risky-endpoint', {
timeout: 3000,
retries: 3,
retryDelay: 1500,
signal: someController.signal
});
更复杂的场景:管理多个并发可取消任务
有时候你需要在页面上同时运行多个独立的异步操作,希望在页面销毁时一次性全部取消。如果一个一个去 abort 会很麻烦。我们可以写一个简单的任务管理器,统一派发 AbortSignal:
class TaskManager {
constructor() {
this.controller = new AbortController();
}
get signal() {
return this.controller.signal;
}
// 注册一个任务,返回一个包装后的Promise
run(taskFn) {
return createCancellableTask(taskFn, this.signal);
}
// 取消所有任务
abort() {
this.controller.abort();
}
// 销毁当前管理器,创建新的控制器(用于页面重新进入时重置)
reset() {
this.controller = new AbortController();
}
}
在页面组件中,你可以这样做:
// 初始化
const manager = new TaskManager();
// 同时启动多个任务
const task1 = manager.run(async (signal) => {
const res = await fetchWithTimeout('/api/user', { signal });
return res;
});
const task2 = manager.run(async (signal) => {
const res = await fetchWithRetry('/api/config', { signal, retries: 1 });
return res;
});
// 等待所有任务完成
Promise.all([task1, task2]).then(([user, config]) => {
console.log('数据加载完成', user, config);
}).catch(err => {
if (err.name === 'AbortError') {
console.log('任务管理器取消了所有操作');
}
});
// 页面卸载时取消全部
window.addEventListener('beforeunload', () => manager.abort());
这个管理器特别适合单页应用中的路由级数据加载。进入新路由时创建一个新的 TaskManager,离开时调用 abort(),干净利落。
处理事件监听器中的取消:避免内存泄漏的常用模式
除了网络请求和计算密集型任务,AbortSignal 还能用来清理事件监听器。addEventListener 的第三个参数现在支持传入一个 signal,一旦 signal 被中止,监听器会自动移除。
这个特性在需要临时监听某些事件时格外有用。比如你实现一个“拖拽”功能,在 mousedown 时给 document 添加 mousemove 和 mouseup 监听,拖拽结束后需要手动移除它们。用 AbortSignal 可以简化:
function handleDragStart(startEvent) {
const controller = new AbortController();
const { signal } = controller;
let lastX = startEvent.clientX;
let lastY = startEvent.clientY;
document.addEventListener('mousemove', (e) => {
const deltaX = e.clientX - lastX;
const deltaY = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
// 执行拖拽移动逻辑
console.log('移动', deltaX, deltaY);
}, { signal });
document.addEventListener('mouseup', () => {
controller.abort(); // 两个监听器会同时移除
console.log('拖拽结束');
}, { signal, once: true }); // mouseup本身只需要触发一次
}
这样做的好处是不需要保存监听器引用再手动移除,代码更简洁,不容易因为忘记移除而导致内存泄漏。
一个完整的实战场景:搜索框防抖 + 请求取消 + 重试
我们来综合运用以上技巧,实现一个常见的搜索输入功能:
- 用户输入时做防抖处理
- 每次新搜索时取消上一次未完成的请求
- 请求失败或超时时自动重试一次
- 组件销毁时取消所有进行中的操作
下面这段代码模拟了在单页组件中可能的样子(纯 JS 版本,方便直接测试):
function createSearchHandler() {
let currentController = null;
let debounceTimer = null;
async function search(query) {
// 取消上一次请求
if (currentController) {
currentController.abort();
}
// 创建新的控制器用于本次操作
currentController = new AbortController();
const { signal } = currentController;
try {
const result = await fetchWithRetry(`/api/search?q=${encodeURIComponent(query)}`, {
timeout: 5000,
retries: 1,
retryDelay: 800,
signal
});
console.log('搜索结果', result);
return result;
} catch (err) {
if (err.name === 'AbortError') {
console.log('搜索被新请求取代');
} else {
console.error('搜索失败', err);
}
}
}
// 返回一个带防抖的搜索函数
return function debouncedSearch(query) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => search(query), 300);
};
}
// 使用
const searchInput = document.getElementById('search-input');
const handleSearch = createSearchHandler();
searchInput.addEventListener('input', (e) => {
handleSearch(e.target.value);
});
核心点在于,每次新的搜索到来时,我们通过 currentController.abort() 取消上一次请求,这样前一个请求如果还在进行中就会被中止,避免了“输入结束但显示的是上一次结果”的经典 Bug。防抖和取消组合在一起,让搜索体验丝滑很多。
注意事项和常见陷阱
在实际项目中使用这套模式时,有几件事值得留意:
- AbortError 的区分:不同的浏览器对 AbortError 的
name属性处理一致,但如果你在 Node.js 环境(如18版本之前)可能没有全局的 AbortController,可以用abort-controller这个 polyfill。 - 与 Promise.race 的关系:很多人直接用
Promise.race实现超时,但它做不到真正的取消——底层请求仍然在占用资源,只是结果被忽略了。AbortController 可以真正中断网络请求,这一点在资源敏感的场景下很重要。 - 信号传递的边界:如果你的项目里用了第三方库并且它支持 AbortSignal,直接把 signal 传进去就好(比如 axios 从 0.22.0 开始支持)。如果不支持,就需要用
createCancellableTask包装。 - 不要忘记清理:始终在合适的时机(比如组件卸载、页面销毁)调用
abort(),否则可能会有未完成的异步任务在后台继续执行,浪费资源甚至引发状态错乱。

