JavaScript / Fetch / 浏览器 API
AbortController 实战:取消过期请求,终结前端竞态问题
搜索框连续输入“r”“re”“react”,前端会发出三次请求。网络顺序不可控,最早发出的请求可能最后返回,于是页面把“react”的结果又覆盖成“r”的结果。组件已经卸载,请求仍在下载;用户点击取消,界面变了,网络却没有停。
这些问题不能只靠一个 loading 布尔值解决。AbortController 提供了标准的取消信号,可以中止 Fetch 请求、响应体读取和其他支持 AbortSignal 的异步操作。更重要的是,它能把“这个结果已经没有意义”写进代码生命周期。
一、最小取消示例
AbortController 产生一个 signal,把它交给 fetch,需要取消时调用 abort():
1 | const controller = new AbortController(); |
取消通常会让 Fetch Promise 以 AbortError 拒绝。它不是网络故障,也不应该弹出“服务异常”;但也不能用宽泛 catch 把所有错误都当成取消,否则真正的 500、解析失败和代码错误都会被吞掉。
二、搜索联想只保留最新请求
每次输入时先取消上一轮请求:
1 | let activeController = null; |
这里同时保留身份检查。正常情况下,旧 Fetch 会被取消;但请求结束后还可能存在自定义异步转换、缓存读取等不支持 signal 的步骤。controller !== activeController 可以阻止旧流程在后续阶段写回 UI。
防抖和取消不是二选一:防抖减少请求数量,取消保证已经发出的过期请求不会继续影响结果。搜索场景通常两者都需要。
三、每个 Controller 只能代表一次生命周期
signal 一旦 aborted,就会一直保持取消状态。不要把同一个 controller 保存为全局单例反复使用:
1 | const controller = new AbortController(); |
正确模型是“一次可取消操作或一组共同生命周期的操作,对应一个 controller”。重试时创建新的实例。
如果一个页面切换需要同时请求用户、权限和偏好,可以共享同一 signal:
1 | const controller = new AbortController(); |
这样离开页面时能一起取消;如果三个请求应独立重试,就应使用不同 controller。
四、在 React effect 中清理请求
依赖变化或组件卸载时取消上一轮操作:
1 | import { useEffect, useState } from 'react'; |
当 userId 变化,React 会先运行旧 effect 的清理,再启动新请求。取消不是为了消除某条框架警告,而是释放不再需要的网络与解析工作,并避免旧结果覆盖新状态。
五、超时取消
现代环境可以使用 AbortSignal.timeout() 创建超时信号:
1 | async function loadWithTimeout(url) { |
如果项目浏览器基线还不支持,可以用 controller 和定时器实现,并确保清理定时器:
1 | async function fetchWithTimeout(url, timeoutMs) { |
超时值应来自业务期望,不是越短越好。上传、报表生成和普通查询的合理等待时间完全不同。
六、响应返回后仍然可以被取消
Fetch Promise resolve 只说明响应头已经可用,不代表响应体已经全部读取。如果在 response.json() 前取消,读取响应体仍可能以 AbortError 结束。MDN 明确说明 AbortController 能中止 Fetch 请求、响应体消费和流。
1 | const response = await fetch(url, { signal }); |
对于很大的 JSON、流式响应和文件下载,这个边界尤其重要。
七、取消前端请求不等于回滚服务端
浏览器停止等待响应,不代表服务端一定停止处理。POST 已经到达服务器后,即使客户端 abort,数据库写入仍可能完成。因此:
- 创建订单、支付等操作需要幂等键;
- 取消按钮不能被当成服务端事务回滚;
- 重试写操作前应查询最终状态或使用请求 ID 去重;
- 服务端若支持协作式取消,需要单独设计协议。
AbortController 管理的是客户端异步生命周期,不是分布式事务。
八、常见错误
- 把 controller 全局复用,第一次取消后所有请求都立即失败。
catch中无条件忽略错误,把服务器故障误当成用户取消。- 取消 Fetch 后仍让旧的异步转换更新页面。
- 只做防抖,不处理已经发出的慢请求。
- 组件卸载时只设置
isMounted,请求和响应体仍继续消耗资源。 - 以为取消 POST 就能撤销服务端操作。
九、行动清单
- 哪些请求会因为路由、搜索词或组件参数变化而过期?
- 一组请求应该共同取消,还是分别重试?
- AbortError、超时、HTTP 错误和解析错误是否分开处理?
- 取消后是否还有不支持 signal 的异步步骤会写回 UI?
- 写操作是否具备服务端幂等与最终状态查询?
取消不是异常流程,而是前端并发模型的一部分。只要用户可以改变主意、路由可以切换、参数可以变化,就会产生“结果已经没有意义”的时刻。为每次操作建立明确的 signal,并在生命周期结束时取消,竞态问题才不会靠运气消失。
参考资料
- MDN:AbortController — https://developer.mozilla.org/en-US/docs/Web/API/AbortController
- MDN:Using the Fetch API - Canceling a request — https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#canceling_a_request