做后台管理系统的时候,最怕用户点了“列表刷新”又立刻点了别的菜单。结果先前发的请求慢三分,等你回来的时候数据已经错位了。以前解决这个问题,要么用一个全局的AbortController不停abort,要么给每个请求外面包一层带超时标志的Promise。都不太优雅。
最近接触了 AbortSignal.any() 和 AbortSignal.timeout(),发现这两个API组合起来是真的好使。今天用实际例子把它们讲透。
先说老办法烦在哪儿
假设页面有两个请求,一个用来加载用户列表,一个用来加载菜单。当用户的点击行为导致你希望同时取消这两个请求时,你得自己维护一个数组:
const controllers = [];
function request(url) {
let controller = new AbortController();
controllers.push(controller);
return fetch(url, { signal: controller.signal });
}
// 取消所有
function cancelAll() {
controllers.forEach(c => c.abort());
controllers.length = 0;
}
这样暂时能跑,但代码不灵活。比如其中某个请求还要和内部一个“30秒超时信号”组合,那你就得自己写一个复杂工具函数。而且如果请求已经完成了,controller还留在数组里造成内存泄漏。
认识一下AbortSignal.timeout()
AbortSignal.timeout(ms) 是一个直接返回“会在指定毫秒后自动触发abort”的信号。不需要你自己 setInterval 手动调用 abort。
const signal = AbortSignal.timeout(5000);
fetch('https://jsonplaceholder.typicode.com/todos/1', { signal })
.then(res => res.json())
.catch(err => {
if (err.name === 'TimeoutError') {
console.log('请求超时了');
} else {
console.log('请求失败', err);
}
});
和手动版对比,少写了一堆定时器释放代码。而且timeout信号被触发后,自动进入aborted状态,之后如果给另一个请求也用这个signal,它也会立刻中止。
AbortSignal.any():把多个信号捏成一个
关键角色来了。AbortSignal.any([signal1, signal2, ...]) 会返回一个新的信号。这个新信号只要任何一个传入的信号abort了,它也跟着abort。说人话:你拿着这个组合信号去请求,任何一个来源说“别干了”,请求就停。
应用场景:列表请求既想要“手动点取消”按钮去中止,又想要5秒超时自动派单。以前你得把两个条件手动判断:
let controller = new AbortController();
let timeoutId = setTimeout(() => controller.abort(new DOMException('超过时间', 'TimeoutError')), 5000);
document.querySelector('#cancelBtn').onclick = () => controller.abort();
fetch(url, { signal: controller.signal })
.finally(() => clearTimeout(timeoutId));
现在写成这样就好:
const cancelSignal = new AbortController().signal; // 之后通过外部controlling.abort()触发
const timeoutSignal = AbortSignal.timeout(5000);
const comboSignal = AbortSignal.any([cancelSignal, timeoutSignal]);
fetch(url, { signal: comboSignal })
.then(res => res.json())
.catch(err => {
if (err.name === 'AbortError') {
// 这里可能是取消按钮触发,也可能是超时触发
// 想区分具体来源可以检查 timeoutSignal.aborted
if (timeoutSignal.aborted) {
console.log('超时导致');
} else {
console.log('手动取消导致');
}
}
});
注意,我用 new AbortController().signal 创建了一个永远不会被abort的信号? 上面这个例子为了演示,cancelSignal实际上没有对应的controller可以abort。这里应该写成:
const cancelController = new AbortController();
const cancelSignal = cancelController.signal;
const timeoutSignal = AbortSignal.timeout(5000);
const combined = AbortSignal.any([cancelSignal, timeoutSignal]);
fetch(url, { signal: combined });
// 用户点击取消时
cancelController.abort();
完整案例:一个带手动取消和自动超时的商品列表
我实现了一个常见的后台列表逻辑:进入页面后加载商品数据。加载过程中,用户点“停止加载”按钮会中断列表请求;同时5秒内未加载完成也会自动终止。为了模拟慢网络,我用一个延迟2000ms的mock接口。
<button id="cancelBtn">停止加载</button>
<div id="status">请求未发送</div>
<ul id="list"></ul>
<script>
const cancelBtn = document.getElementById('cancelBtn');
const statusEl = document.getElementById('status');
const listEl = document.getElementById('list');
function fetchProducts() {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
resolve([
{ id: 1, name: '机械键盘' },
{ id: 2, name: '降噪耳机' },
{ id: 3, name: '便携屏' }
]);
}, 3000); // 3秒才返回,模拟慢网络
// 但我们要加上一个可在1秒后手动取消
// 这样既能演示取消,又能演示超时
});
}
// 我们改造fetchProducts,让它支持signal
function fetchProductsWithSignal(signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
resolve([
{ id: 1, name: '机械键盘' },
{ id: 2, name: '降噪耳机' },
{ id: 3, name: '便携屏' }
]);
}, 3000);
// signal中止时清除定时器并reject
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('操作被中止', 'AbortError'));
}, { once: true });
});
}
const cancelController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000);
const combinedSignal = AbortSignal.any([cancelController.signal, timeoutSignal]);
cancelBtn.addEventListener('click', () => {
cancelController.abort();
statusEl.textContent = '你手动中止了请求';
});
statusEl.textContent = '请求发送中';
listEl.innerHTML = '';
fetchProductsWithSignal(combinedSignal)
.then(data => {
if (combinedSignal.aborted) return;
statusEl.textContent = '加载成功';
listEl.innerHTML = data.map(item => `<li>${item.name}</li>`).join('');
})
.catch(err => {
if (combinedSignal.aborted) {
if (timeoutSignal.aborted) {
statusEl.textContent = '请求超时,已自动取消';
} else if (cancelController.signal.aborted) {
statusEl.textContent = '你主动取消了,不再处理结果';
}
} else {
statusEl.textContent = '请求失败:' + err.message;
}
});
</script>
这个例子里,我模拟了一个需要3秒返回的接口。你如果点了“停止加载”按钮,请求立即取消,最终列表也不会出现数据。如果放任不管,5秒后超时信号会触发,同样是清掉定时器reject。因为我在then里加了 if (combinedSignal.aborted) return;,即便定时器已经被清掉导致resolve无效,也额外加了一层屏障。
真实fetch请求里的最佳实践
如果真是fetch而不是promise包裹,代码更短:
function loadUserList() {
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(8000);
const combined = AbortSignal.any([userController.signal, timeoutSignal]);
fetch('/api/users', { signal: combined })
.then(response => response.json())
.then(list => renderList(list))
.catch(error => {
if (timeoutSignal.aborted) {
message.error('网络超时,请稍后重试');
} else if (userController.signal.aborted) {
// 用户主动取消,不提示
} else {
message.error('请求失败');
}
});
// 返回一个cancel函数,方便组件卸载时调用
return () => userController.abort();
}
// 在React/Vue的效果函数里:
const cancel = loadUserList();
onCleanup(cancel);
这种写法和之前相比,好处就是组合信号是一次性创建的,不会丢。你不用把多个controller保存在模块级数组里,每个请求自己管理自己的“取消来源”,但又能共享同一个“总取消触发器”。
兼容你的React/Vue组件
React 18以上的useEffect里,我们可以直接用这个模式来取消副作用。例如加载用户列表并支持页面卸载时取消所有未完成请求:
import { useEffect } from 'react';
function UserList() {
useEffect(() => {
const controller = new AbortController();
const timeoutSignal = AbortSignal.timeout(10000);
const signal = AbortSignal.any([controller.signal, timeoutSignal]);
fetch('/api/users', { signal })
.then(r => r.json())
.then(setUsers)
.catch(e => {
if (e.name === 'AbortError') return; // 被取消,不算错误
console.error(e);
});
return () => controller.abort(); // 清理时取消
}, []);
}
这里即使没有“手动取消”按钮,AbortSignal.timeout也给请求加了一道保险。以前你需要写一个useTimeoutRef然后手动clearTimeout,现在一个signal全搞定。
踩坑:AbortSignal.any的信号不能被直接复用
注意,AbortSignal.any返回的是一个全新的信号,不是某个原始信号。所以如果你把它用在fetch里,然后对它调用 signal.aborted,这个值会在任一上游信号aborted后变成true。但是——如果你想给一个详情页面同时发多个fetch,共享同一个abort事件,可以让这两个fetch都用同一个由AbortSignal.any创建出来的信号:
const cancelController = new AbortController();
const commonSignal = AbortSignal.any([cancelController.signal, AbortSignal.timeout(5000)]);
fetch(url1, { signal: commonSignal });
fetch(url2, { signal: commonSignal });
这样点击一个取消按钮两个请求都会断,十分好用。
兼容性到哪里了
- Chrome / Edge:从116开始支持AbortSignal.any
- Firefox:117开始支持
- Safari:17.0开始支持
- Node.js:20.3.0开始支持
如果你的项目还要兼容Chrome 115及之前的老浏览器,可以用一个简单的polyfill模拟:遍历signals数组,在任意一个触发‘abort’事件时调用新的abort。网上有很多现成实现,直接装上就可以。
实际感受
用了AbortSignal.any之后,原来在代码里到处找controller列表的回头路结束了。现在每个页面就只管当前页面需要的中止信号,再用timeout做兜底,通过any把两个信号合并。思路更加清晰,自己写起来也痛快。
代码是写给同事维护的,能不能少几个全局变量就少几个。以后再有“给我并行发几个请求,其中一个取消全部取消”的需求,一行AbortSignal.any就直接解决,值得一试。

