上个月做了个数据可视化大屏,页面里要同时请求十几个接口。用户在大屏间切换时,旧页面的请求还在飞,结果后端日志里堆满了无用调用,前端也时不时报“setState on unmounted component”。一开始我用一个布尔变量标记是否已卸载,在请求回调里判断一下。但后来发现根本拦不住已发出的请求,只是不处理结果而已,网络照走。
后来研究了一下 AbortController,发现这东西才是正解。不仅能真正取消网络请求,还能用来打断任何正在运行的异步任务。这篇文章就当是我的学习笔记,把几个实用的套路面整理下来。
先弄明白 AbortController 是什么
AbortController 是一个浏览器原生对象,它支持通过 signal 去“命令”一个异步操作停止。你可以把它想象成一个遥控器,它有 abort() 按钮。按一下,所有接在同一个 signal 上的任务都会收到“终止”信号。
最简单的用法是配合 fetch。
const controller = new AbortController();
const signal = controller.signal;
console.log(signal.aborted); // false
// 触发取消
controller.abort();
console.log(signal.aborted); // true
当你调用 abort() 后,signal 的状态就变成 aborted,而且会触发一个 abort 事件。如果你把 signal 传给 fetch,fetch 就会在收到 abort 信号时立即中断请求。
用 AbortController 取消一个正在进行的 fetch
const controller = new AbortController();
fetch('https://api.example.com/large-data', { signal: controller.signal })
.then(res => res.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求被取消了');
} else {
console.error('其他错误', err);
}
});
// 比如用户点击了“取消”按钮
button.addEventListener('click', () => controller.abort());
如果请求在未完成时被 abort,fetch 得到的 error 会是 DOMException,并且 name 属性为 `AbortError`。所以你在 catch 里可以专门判断一下。
实现 fetch 超时(老套路升级版)
以前我们实现超时,要么用 Promise.race 搭配 setTimeout,但即便超时了,底层请求还在跑。正确的方式就是 setTimeout 里调用 abort。
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('/api/data', { signal: controller.signal });
const data = await res.json();
clearTimeout(timeoutId);
console.log(data);
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
console.warn('请求超时,已中止');
} else {
console.error('网络错误', err);
}
}
其实这个套路已经不算新鲜,但它和 Promise.race 最大的区别是:abort() 真正告诉浏览器“这个请求别要了”,节省了网络资源。
不止能取消 fetch,还能取消任意 Promise
AbortController 不是只给 fetch 用的,任何你自己写的异步函数都能配合它实现“可取消”。关键点在于监听 signal 的 abort 事件,然后在事件回调里 reject。
举个例子,我写了一个模拟延迟任务的 sleep 函数,支持取消:
function cancellableSleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('Sleep cancelled', 'AbortError'));
return;
}
const timer = setTimeout(() => {
resolve('睡醒了');
cleanup();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(new DOMException('Sleep cancelled', 'AbortError'));
cleanup();
}
function cleanup() {
signal.removeEventListener('abort', onAbort);
}
signal.addEventListener('abort', onAbort);
});
}
这段代码就是用 AbortController 让一个普通的 setTimeout 变得可中断。当外部调用 controller.abort(),就会触发 abort 事件,然后我们清除定时器,并让 Promise reject。
有了这个基础,你就可以把任何异步操作包进去。比如一个大文件读取、一个动画循环、一个递归查询等。
实战:可取消的请求队列
我写了一个简单的请求队列,当用户点击“取消全部”时,所有还没完成的请求都会被中断。
class RequestQueue {
constructor() {
this.controllers = new Set();
}
add(fetchPromise) {
const controller = new AbortController();
this.controllers.add(controller);
// 把 signal 传给 fetchPromise,但 fetchPromise 必须支持接受 signal
// 这里用闭包把 controller 和返回的 promise 关联起来
const wrapped = fetchPromise(controller.signal);
wrapped.finally(() => this.controllers.delete(controller));
return wrapped;
}
cancelAll() {
this.controllers.forEach(c => c.abort());
this.controllers.clear();
}
}
使用的时候:
const queue = new RequestQueue();
function fetchData(url) {
return queue.add(signal => fetch(url, { signal }).then(res => res.json()));
}
// 发起几个请求
fetchData('/api/users').then(users => console.log(users));
fetchData('/api/posts').then(posts => console.log(posts));
// 用户切换页面,取消所有
queue.cancelAll();
这个队列里的 cancelAll() 一执行,所有 fetch 都会中止,并且 catch 里会收到 AbortError。你再也不用担心旧页面请求影响新页面了。
搭配 streams 实现“可取消的流式读取”
有一种高级玩法是把 AbortController 和 fetch 返回的 Response.body 的 ReadableStream 结合起来,实现中断下载。举个例子:
const controller = new AbortController();
const res = await fetch('/big-file.zip', { signal: controller.signal });
const reader = res.body.getReader();
async function readLoop() {
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log('chunk length:', value.length);
}
}
// 取消读取
controller.abort();
这里 fetch 的 signal 中止后,reader.read() 也会抛错(一个 TypeError 或 AbortError),从而退出循环。这种场景在文件上传下载进度条并允许取消时特别有用。
注意的几个坑
- signal.aborted 是同步判断的:如果你在创建 Promise 的时候就发现 signal 已经 aborted,应该立即 reject,而不是继续等。我在
cancellableSleep里已经做了这个检查。 - addEventListener 后要记得 remove,否则会内存泄漏:我上面的 sleep 在 resolve/reject 后调用 cleanup 移除监听。
- 多次调用 abort() 是幂等的:无论调用多少次,signal.aborted 始终是 true,不会重复触发事件。
- fetch 对 abort 的响应不是即时的:理论上说,调用 abort() 后,浏览器会尽快终止网络请求,但不可能真的立刻断开 TCP 连接。所以不要依赖“取消后立即就没有流量”这种假设。
一个完整的可取消上传模拟案例
为了让你看到真实效果,我写一个小 demo:一个“开始上传”按钮和一个“取消”按钮。点击开始后,每隔 200ms 模拟进度增加,当进度达到 100% 时自动完成,如果中途点击取消,则停止更新并提示。
<button id="startBtn">开始上传</button>
<button id="cancelBtn">取消</button>
<progress id="progress" value="0" max="100"></progress>
<script>
const controller = new AbortController();
const progress = document.getElementById('progress');
function upload() {
return new Promise((resolve, reject) => {
if (controller.signal.aborted) {
reject(new DOMException('Upload cancelled', 'AbortError'));
return;
}
let percent = 0;
const timer = setInterval(() => {
percent += Math.floor(Math.random() * 10) + 1;
if (percent >= 100) {
clearInterval(timer);
resolve('上传成功');
} else {
progress.value = percent;
}
}, 200);
function onAbort() {
clearInterval(timer);
reject(new DOMException('Upload cancelled', 'AbortError'));
progress.value = 0;
}
controller.signal.addEventListener('abort', onAbort);
});
}
document.getElementById('startBtn').addEventListener('click', () => {
upload()
.then(msg => alert(msg))
.catch(err => {
if (err.name === 'AbortError') {
console.log('上传被取消');
}
});
});
document.getElementById('cancelBtn').addEventListener('click', () => controller.abort());
</script>
注意,这个 demo 里的 controller 在开始上传时创建,如果第一次取消后,controller 已经 aborted,第二次点击开始就直接 reject。所以在真实场景里,每次开始上传前都要 new 一个新的 AbortController。
代替 onbeforeunload 清理旧请求的实践
以前我习惯在 beforeunload 里发一个信号,现在不用了。直接在页面切换逻辑里调用 controller.abort() 即可。如果你用的是 Vue/React,可以在组件卸载时执行 abort。
// Vue 3 中
onUnmounted(() => controller.abort());
React 的 useEffect 清理函数同样适用。AbortController 已经成了我 100% 的项目标配。
总结
AbortController 其实不是什么新鲜玩意,但很多人对它只停留在“配合 fetch 用”的层面。把它想成一个通用的“取消信号”,你就能在任意异步逻辑里用好它。通过监听 abort 事件,你可以随时打断循环、清除定时器、终止请求,这让复杂的前端交互变得非常可控。
如果你也被“请求已发但页面已跳转”之类的问题折磨过,不妨把 AbortController 用起来。代码量不多,收益却很直观。

