AbortController 与请求取消
取消请求不只是「省流量」,更是避免竞态:用户快速切换筛选条件时,先发的慢请求不能覆盖后发的快请求。
1. signal 与 abort 的配合
AbortController 创建后把它的 signal 传给请求,需要取消时调 abort()。fetch 在 signal 触发后会 reject 一个 AbortError——它不是真正的错误,是预期行为,要单独识别,不要当成异常上报到监控系统。
const controller = new AbortController()
fetch('/api/list', { signal: controller.signal })
.catch((err) => {
if (err.name === 'AbortError') return // 取消,不上报
report(err)
})
controller.abort() // 取消
2. 搜索联想与页面切换
四类最常见:组件卸载时取消未完成请求、搜索输入连续触发时取消上一次、上传下载里用户点取消、以及超时控制。现代写法里超时不用自己记定时器——截至 2026 年 10 月,AbortSignal.timeout(ms) 与 AbortSignal.any([...]) 在主流浏览器与 Node 已作为基线能力可用:前者直接给一个到点的信号,后者把「超时」和「手动取消」组合成一个信号,任一触发即取消。
// 超时 + 手动取消,组合成一个信号
const ctrl = new AbortController()
const signal = AbortSignal.any([ctrl.signal, AbortSignal.timeout(5000)])
fetch(url, { signal })
一个 signal 可以绑给多个请求,适合「批量取消」——比如切换页面时一次性中断该页面发出的所有请求。
3. 取消是为了防竞态
取消请求最大的价值不是省流量,而是避免竞态:用户快速切换筛选条件时,先发的慢请求可能后到,把后发的快请求结果覆盖掉。取消旧请求是最干净的解法;当请求无法取消(比如已被网关代理、连接建好就不接受中断)时,用「请求序号 + 忽略过期响应」兜底——每次发起记一个自增 id,响应回来时 id 不是最新的就丢弃。两者叠加最稳。
let seq = 0
async function load(keyword) {
const id = ++seq
const res = await fetch(`/api/search?q=${keyword}`)
const data = await res.json()
if (id !== seq) return // 已有更新的请求,丢弃本次
setData(data)
}
三种竞态解法各有边界:能取消时优先取消旧请求(最干净);取消不了用序号忽略过期响应;重复请求用缓存去重(同一参数只发一次)。按接口能不能取消选。
3.1 在 Effect 里怎么收尾
AbortController 应该在 effect 内部创建、在 cleanup 里 abort,既不能放进依赖数组,也不能在渲染阶段创建。StrictMode 下 effect 会执行两次,如果 controller 存在 ref 里复用,第二次执行会直接拿到一个已 abort 的 signal,请求永远发不出去——这是很典型的上线后才发现的问题。
useEffect(() => {
const controller = new AbortController()
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then((data) => {
if (controller.signal.aborted) return
setData(data)
})
.catch((err) => {
if (err.name === 'AbortError') return // 不上报
report(err)
})
return () => controller.abort()
}, [url])
3.2 监听器也要能取消
signal 不止能喂给 fetch,它也是事件监听清理的统一开关。addEventListener 接受 { signal } 选项:把同一个 signal 传进多个监听器,调用一次 abort() 就一次性移除全部,不必逐个 removeEventListener。组件卸载时批量清理点击、滚动、resize 监听特别省心,也避免忘记解绑导致的内存泄漏。
// 组件卸载时一次性清理所有监听,不必逐个 removeEventListener
const controller = new AbortController()
const { signal } = controller
document.addEventListener('click', onClick, { signal })
window.addEventListener('resize', onResize, { signal })
form.addEventListener('submit', onSubmit, { signal })
// 清理:controller.abort() 一次性移除全部三个
同样的信号机制也应该带进你自己写的异步函数。约定「函数接受一个 signal 参数,开始时 throwIfAborted()、在 abort 时 reject」,就能让任意异步逻辑可被取消,而不只是 fetch。signal.throwIfAborted() 在已取消时抛出 signal.reason(默认是 AbortError),没取消则什么也不做——把它插在每一步之间,可以避免取消了还白算后续。
// 自定义异步函数支持取消:先 throwIfAborted,再在 abort 时 reject
async function processItems(items, { signal } = {}) {
signal?.throwIfAborted() // 已取消则立即抛 reason
for (const item of items) {
await saveItem(item, { signal })
signal?.throwIfAborted() // 每步之间再检查,避免白算
}
}
注意 abort 的 reason 才是真正的取消原因:fetch 自己抛的是 AbortError,而你 controller.abort(new Error('用户点击取消')) 自定义的原因存在 signal.reason 里。要拿到精确原因就用 signal.throwIfAborted() 而不是依赖 fetch 抛的错。
3.3 取消后的错误怎么分辨
两点容易误解。第一,取消的是客户端的等待与连接,服务端可能已经处理完了——abort 之后幂等接口不受影响,但非幂等操作(如下单)不能靠前端取消来「撤回」。第二,响应体一旦开始读取,只能停止消费,已下载的字节不会退回;所谓「中断下载」只是不再处理后续数据。
signal 的传播与超时组合的推荐写法见 MDN:AbortController,AbortSignal.any 与 timeout 这两个新能力也在里面。
4. 取消一次流式生成
前面说的取消,省下的是带宽和一次请求。取消一次大模型生成,省下的是钱——而且这两者的失败模式不太一样。
4.1 不取消的话,钱会继续花
用户关掉标签页或点了"停止",如果取消没传到模型供应商,服务端仍在为没人会读的 token 付费。生成可能持续几十秒,成本是实打实的。
所以取消必须是端到端的:浏览器 abort → BFF 感知断连 → 供应商 SDK 取消 → 上游连接关闭。任何一环断了,都在付费生成没人读的内容。
服务端那侧的写法很直接——把入站请求的 signal 传给上游:
const upstream = await fetch(providerUrl, {
method: 'POST',
body: JSON.stringify(payload),
signal: req.signal, // 客户端断开时一并取消
})
各家 SDK 也都有对应的取消入口(比如 OpenAI SDK 的 stream.controller.abort())。关键是显式接上,不要指望框架自动处理。
4.2 取消不等于错误
这是最容易做错的一点。取消是客户端发起的一个动作,不是请求失败。
如果把它们混为一谈,界面上就会出现很荒谬的一幕:用户刚点了"停止",界面立刻弹出一个"出错了,点击重试"。
具体到工具调用,取消也不会自动把工具状态变成错误(见《工具调用的七种状态》)——状态描述的是工具调用本身的结果,中断是另一个维度。取消态要单独渲染,且不给重试入口。
4.3 服务端可能已经做完了一部分
第 3.3 节说过:取消的是客户端的等待与连接,服务端可能已经处理完了。在 AI 场景里这句话的含义更具体——已经生成的 token 已经计费了,取消之后不会退回。
所以"停止"这个操作在计费上是"止损"而不是"退款"。界面上不必强调这点,但内部成本核算时要清楚:一次被取消的生成仍然消耗了从开始到取消时刻的全部 token。
4.4 传播链上的每一环都要检查
排查"点了停止但还在计费"这类问题,按顺序查四跳:浏览器是否真的 abort 了(有没有用 EventSource——它不配合 abort 语义)、BFF 是否感知到断连、SDK 是否取消了、上游连接是否关闭。
全链路的检查清单见《流式链路上的缓冲点》,前端状态怎么组织见《对话界面的状态机》。