AbortController 实战:优雅取消 fetch、批量解绑事件与解决搜索竞态
从一个真实问题说起
搜索框每敲一个字就发一次请求,用户从「华」一路打到「华为云」,网络稍慢时,先发的慢响应可能比后发的快响应更晚到达,结果把新结果覆盖成旧结果;单页应用里切走页面后,还没回来的请求再去 setState,控制台立刻抛出对已卸载组件的更新警告。这两类问题的根子是同一件事:请求发出去之后,我们缺少一个可靠的「撤销」手段。
过去常见做法是加一个 let cancelled = false 标志位,在回调里判断后再决定要不要渲染。它能挡住渲染,但请求本身仍在跑,连接和流量都白耗。AbortController 则是从浏览器网络层真正中止请求,配合 AbortSignal 还能统一清理事件监听与定时器。本文按场景把用法串一遍,最后列出几个容易踩的坑。
一、最小可用写法
创建一个 controller,把它的 signal 传给 fetch,需要时调用 abort()。被中止的请求会以 AbortError 进入 catch,这属于预期行为,不要当成网络错误上报。
const controller = new AbortController();
const { signal } = controller;
fetch('/api/slow-report', { signal })
.then(res => res.json())
.then(data => render(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求被主动取消,不是失败');
} else {
console.error('真实错误', err);
}
});
// 某个时刻决定不再需要这次请求
controller.abort();
注意区分两种「失败」:AbortError 是我们主动放弃,应该静默处理;其他异常才是需要提示用户或重试的真实错误。把两者混在一起,会让人误以为取消操作出了问题。
二、超时与「多条件任一取消」
不必自己写 setTimeout 再去 abort,AbortSignal.timeout(ms) 可以直接生成超时信号。若既想支持用户手动取消、又想保留超时兜底,用 AbortSignal.any() 把多个信号合并,其中任意一个中止就会触发。
// 5 秒还没返回就自动取消
const res = await fetch('/api/heavy', {
signal: AbortSignal.timeout(5000)
});
// 手动取消 + 超时,任一触发即中止
const userCtrl = new AbortController();
cancelBtn.onclick = () => userCtrl.abort();
const signal = AbortSignal.any([
userCtrl.signal,
AbortSignal.timeout(8000)
]);
await fetch('/api/heavy', { signal });
合并信号的好处是取消逻辑只有一个入口:下游代码只认一个 signal,不用关心它究竟是被按钮、超时还是别的条件触发的。
三、场景一:竞态搜索
每次发起新请求前先中止上一次,就能保证只有最后一次请求会真正写进页面。这比在响应里比对「请求序号」更彻底——旧请求在传输层就被掐断了。
let ctrl = null;
async function search(keyword) {
// 新请求发起前,先取消上一次还没回来的请求
if (ctrl) ctrl.abort();
ctrl = new AbortController();
try {
const list = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
signal: ctrl.signal
}).then(r => r.json());
renderList(list); // 只有最后一次请求会走到这里
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
}
顺带一提,如果仅仅想减少请求频率,去抖(debounce)依然有用;但去抖解决的是「少发」,AbortController 解决的是「已发的旧请求别回来捣乱」,两者是互补关系,不冲突。
四、场景二:事件监听的批量清理
很多人不知道 addEventListener 的第三个参数支持传 signal。把同一个 signal 挂到一组监听上,最后调用一次 abort() 就能全部移除,不必为每个回调单独保存函数引用再逐个 removeEventListener。
const ctrl = new AbortController();
window.addEventListener('resize', onResize, { signal: ctrl.signal });
document.addEventListener('visibilitychange', onVisible, { signal: ctrl.signal });
window.addEventListener('scroll', onScroll, { signal: ctrl.signal, passive: true });
// 一次性移除上面所有监听,不用再逐个保存函数引用
ctrl.abort();
这对临时弹出层、拖拽交互、编辑器插件这类「进入时挂一堆监听、退出时要清干净」的场景特别省事,也顺带避免了忘记解绑造成的内存泄漏。
五、场景三:组件卸载时的收尾
在 React 的 useEffect 里,把 controller 放在清理函数中 abort,是最常见的落地方式。Vue 的 onUnmounted、原生页面里的 pagehide 同理。
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/user/${id}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setUser)
.catch(e => { if (e.name !== 'AbortError') setError(e); });
// 组件卸载或 id 变化时自动取消,避免对已卸载组件 setState
return () => ctrl.abort();
}, [id]);
依赖变化时(比如列表项的 id 变了),React 会先执行上一轮的清理函数再跑新 effect,于是旧请求被自动取消,不需要额外写判断。
六、几个容易踩的坑
- signal 是一次性的:一旦 abort 过,这个 controller 的 signal 就永久处于
aborted状态,不能复用。要重试必须新建 controller; - abort 只取消客户端:它中止的是浏览器侧的请求,服务端可能已经在处理甚至已经执行完写操作。涉及下单、扣款这类有副作用的接口,别指望 abort 能「回滚」,要靠幂等键和业务层校验;
- 用 reason 区分取消来源:
abort(reason)可以带上自定义原因,配合signal.reason与signal.aborted,能清楚地区分「用户主动取消」「超时」「页面卸载」; - 不是所有 API 都吃 signal:
fetch、部分addEventListener原生支持,但老式 XHR、第三方 SDK 未必。接入前先确认,必要时用它们自己的取消方法包一层; - 别吞掉真实异常:
catch里只忽略AbortError,其他错误要照常抛出或上报,否则线上问题会被悄悄掩盖。
const ctrl = new AbortController();
ctrl.signal.addEventListener('abort', () => {
console.log('取消原因:', ctrl.signal.reason);
});
// 可以带上自定义原因,便于区分「用户取消」和「超时」
ctrl.abort(new DOMException('用户切换了页面', 'AbortError'));
// 判断一个 signal 是否已经中止,避免白发起请求
if (ctrl.signal.aborted) return;
小结
AbortController + AbortSignal 提供的是一套统一的「取消」协议:网络请求、事件监听、超时控制都能挂到同一个信号上。把竞态搜索、组件卸载清理、批量解绑这三件事交给它之后,代码里那些零散的标志位和手动解绑基本可以删掉了。现代浏览器对它的支持已经很完整,是前端工程里投入很小、收益很明确的一处改造。
本文为原创实践记录,示例代码均可直接运行,欢迎交流更稳妥的边界处理方式。
- 点赞
- 收藏
- 关注作者
评论(0)