前端请求取消和竞态:旧请求不能覆盖新页面

请求先发出去的,不一定先回来——浏览器和 Node 都不保证这件事,这是竞态问题的根子。搜索框最容易暴露这个问题:先输入 a,请求 A 发出去;紧接着输入 ab,请求 B 发出去,理论上页面最终应该停在 ab 的结果上,但只要网络抖一下,A 比 B 晚回来,页面就会诡异地跳回 a 的结果。

组里这几年一直用 axios,取消请求的写法是 CancelToken,这是 axios 自己的实现,跟浏览器原生的 fetch 完全不是一回事。最近在给一个内部工具补页面切换时的请求清理逻辑,同时踩了 CancelTokenAbortController 两套写法,发现它们的行为不完全对等:CancelToken 取消后 axios 会把这次调用标记成一个特殊的 cancel 错误,catch 里判断方式和原生 AbortError 不一样,两套代码混在一个项目里,排查起来容易把两种"取消"的语义搞混。这篇把这次踩坑和之前攒的竞态处理方式一起理一遍。

竞态不只发生在搜索框

这个问题最早在搜索框暴露出来,但同样的模式在好几个场景里都能撞见:

切换 tab 加载不同的列表数据,快速点两次 tab,先点的那个 tab 对应的请求如果晚回来,会把内容盖成错的一屏。路由 id 变化也是同样的模式——列表页点进详情 A,还没渲染完又点了详情 B,A 的请求这时候回来,如果没做防护,页面显示的详情和地址栏上的 id 对不上。弹窗打开又关闭、上传中途用户点了取消、组件已经卸载但异步请求还挂着——本质都是同一件事:一个已经过期的请求,仍然有权力修改当前状态

这类 bug 在本地开发环境几乎不出现,因为本地请求快、返回顺序稳定,只有线上网络稍微一抖,或者用户手速比接口快,才会暴露出来。所以不能指望"接口足够快就没事",前端得把请求的生命周期管起来。

用版本号忽略过期结果,最省心

不涉及真正取消网络连接、只是让过期结果失效的做法最简单,也最不容易出错:

1let requestSeq = 0
2
3async function loadList(params) {
4  const seq = ++requestSeq
5  loading.value = true
6
7  try {
8    const data = await api.getList(params)
9
10    if (seq !== requestSeq) return
11    list.value = data
12  } catch (error) {
13    if (seq !== requestSeq) return
14    message.error(error.message || '加载失败')
15  } finally {
16    if (seq === requestSeq) {
17      loading.value = false
18    }
19  }
20}

每发一次请求,序号自增;结果回来的时候比对序号,跟当前最新一次不一致就直接丢弃。哪怕旧请求真的完成了、也真的拿到了数据,只是不写进状态里。这个方法的好处是不依赖任何取消 API,任何请求库都能这么包一层,甚至老项目里用 jQuery 的 $.ajax 也能这么处理。

代价也很明显:网络连接本身没有断,请求还是照常发到服务端、照常消耗一次后端资源。对于列表查询、搜索联想这类轻量接口,多请求几次服务端压力不大,这样处理够用;但如果是上传大文件、触发一次代价很高的后端计算,只忽略前端结果就不够了,得真的把请求砍断。

AbortController:原生取消,但要认清它和 axios 旧写法的区别

fetch 原生支持通过 AbortController 中止请求,浏览器端已经稳定可用:

1let controller = null
2
3async function loadUser(id) {
4  controller?.abort()
5  controller = new AbortController()
6
7  try {
8    const response = await fetch(`/api/users/${id}`, {
9      signal: controller.signal,
10    })
11
12    return await response.json()
13  } catch (error) {
14    if (error.name === 'AbortError') return null
15    throw error
16  }
17}

新请求发出前先把上一个 controller abort 掉,网络层面这次请求真的会被中断(Chrome DevTools 的 Network 面板里能看到状态是 (cancelled)),不只是前端假装没看见。组件卸载的时候同样调用一次 abort:

1onBeforeUnmount(() => {
2  controller?.abort()
3})

问题出在项目里还留着不少 axios 写的老接口封装,axios 到现在为止取消请求走的是自己那套 CancelTokenaxios.CancelToken.source() 拿到 tokencancel 方法,请求配置里传 cancelToken: source.token),这套东西比 AbortController 出现得早,是 axios 在原生取消能力还不成熟的时候自己垫上的方案。这两年 axios 陆续在新版本里加上了对 AbortController 的支持,配置里同样可以传 signal,但 CancelToken 并没有被拿掉,社区里存量代码大多还在用它。混着用最容易出问题的地方是错误判断:CancelToken 触发的取消,error 对象上要用 axios.isCancel(error) 判断;原生 AbortController 触发的取消,判断的是 error.name === 'AbortError'。这次踩坑就是把两种判断方式写反了,CancelToken 取消的请求走进了"网络异常"的兜底逻辑,页面弹出一句本不该出现的"请求失败,请稍后重试"。

统一封装成两个判断函数,比每处手写更保险:

1function isCancelError(error) {
2  return error?.name === 'AbortError' || (axios.isCancel && axios.isCancel(error))
3}

新写的接口一律用 AbortController,旧的 axios 封装暂时不动,只在错误处理这一层做兼容,不强行把所有老代码迁移一遍。

一个 controller 管多个请求,容易漏掉的坑

详情页经常是进入时并发发好几个请求——基本信息、评论列表、推荐列表分别调用不同接口,理想情况下这几个请求应该共享同一次"取消"的时机:只要 id 变了或者组件卸载了,这几个请求全都该作废。第一版我图省事,写成了三个请求共用同一个 controller

1async function loadDetail(id) {
2  controller?.abort()
3  controller = new AbortController()
4
5  const [info, comments, related] = await Promise.all([
6    fetchInfo(id, { signal: controller.signal }),
7    fetchComments(id, { signal: controller.signal }),
8    fetchRelated(id, { signal: controller.signal }),
9  ])
10
11  return { info, comments, related }
12}

这么写本身没错,AbortControllersignal 本来就设计成可以同时挂给多个请求。真正的坑在 Promise.all 上:只要其中一个请求被 abort,Promise.all 会立刻整体 reject,剩下两个请求的结果哪怕已经在路上也会被丢弃,外层 catch 里如果没有专门判断 AbortError,会跟真正的接口报错混在一起,弹出一句莫名其妙的"加载失败"。改成 Promise.allSettled 之后每个请求的成功或失败都能单独拿到,取消的那几个用 isCancelError 单独过滤掉,不影响其他真正报错的请求正常提示:

1const results = await Promise.allSettled([
2  fetchInfo(id, { signal: controller.signal }),
3  fetchComments(id, { signal: controller.signal }),
4  fetchRelated(id, { signal: controller.signal }),
5])
6
7results.forEach((result) => {
8  if (result.status === 'rejected' && !isCancelError(result.reason)) {
9    console.error(result.reason)
10  }
11})

Promise.allSettled 是 ES2020 就有的方法,不算新鲜,但和取消场景搭配起来用的时候容易被忽略——习惯性用 Promise.all 图个简洁,一旦引入取消逻辑,行为就变了。

防抖减少请求数量,但不解决竞态

搜索框基本都会加一层 debounce:

1var debouncedSearch = debounce(search, 300)

它能明显减少请求次数,但解决不了竞态问题本身。因为哪怕做了防抖,只要连续两次触发的间隔超过 300 毫秒,两个请求照样会同时在路上,谁先回来依旧看网络脸色。debounce 和竞态保护是两件事,不能互相替代:

1var debouncedLoad = debounce(function (keyword) {
2  loadList({ keyword })
3}, 300)

loadList 内部还是要靠序号或者 abort 处理旧请求,debounce 只是从源头减少了触发次数,降低竞态出现的概率,不是消除它。

组件卸载后,请求不该再影响页面

React 项目里最常见的隐患写法:

1useEffect(() => {
2  fetchDetail(id).then(setDetail)
3}, [id])

如果组件已经卸载,请求才回来,setDetail 这次调用作用在一个已经不存在的组件上。加一层清理:

1useEffect(() => {
2  var controller = new AbortController()
3
4  fetchDetail(id, { signal: controller.signal })
5    .then(setDetail)
6    .catch((error) => {
7      if (error.name !== 'AbortError') {
8        setError(error)
9      }
10    })
11
12  return function () {
13    controller.abort()
14  }
15}, [id])

Vue 这边道理一样,只是清理时机换成 onBeforeUnmount

1export default {
2  setup(props) {
3    var controller = null
4
5    onMounted(function () {
6      controller = new AbortController()
7      fetchDetail(props.id, { signal: controller.signal }).then(function (data) {
8        detail.value = data
9      })
10    })
11
12    onBeforeUnmount(function () {
13      controller?.abort()
14    })
15  },
16}

组件不在了,请求就不该继续对它施加影响,这条原则不分框架。团队里 Vue 3 已经是新项目的默认版本,setup 这套写法基本成了标配,但存量的 Vue 2 页面也一样要补这层清理,只是拿不到 Composition API,得用 beforeDestroy 钩子写同样的逻辑。

上传场景:取消不只是"不管结果"

前面几种做法都建立在一个假设上——请求取消了,前端就不再关心它的结果。上传场景不完全是这样,用户点"取消上传"的时候,往往还期待一件事:服务端不要把这个半途而废的文件当成正常上传处理,最好也能中断。

AbortController 中断的只是浏览器这一端和服务器的连接,fetch/XMLHttpRequest 层面确实会立刻停止收发数据,但如果后端是分片上传、服务端已经收到并落盘了一部分分片,光靠前端 abort 并不能触发服务端清理这些残留分片。真正做完整,还得配合一个专门的"取消上传"接口,前端 abort 网络连接的同时,调用这个接口告诉后端"这次上传作废,把已经收到的分片清掉"。这是这次顺带发现的一个坑:只做了前端 abort,测试的时候来回点了几次"取消上传",回头翻测试环境的分片目录,发现里面攒了不少没人认领的文件——后端压根不知道这次上传已经作废了。

1async function cancelUpload(uploadId) {
2  controller?.abort()
3  await api.post('/upload/cancel', { uploadId })
4}

进度条场景同理,onUploadProgress 这类回调在 abort 之后可能还会再触发一次(不同浏览器实现细节不完全一致),如果进度状态没有跟着 controller 一起标记为"已取消",进度条可能诡异地卡在某个百分比上不消失。

弹窗关闭时取消请求,容易漏掉的一个细节

弹窗打开时发请求、用户手快点了关闭,这个场景看起来和路由切换差不多,但容易被漏掉的地方在于:弹窗组件很多团队的写法是用 v-show/display: none 隐藏,而不是真正销毁组件实例。如果取消逻辑写在 onBeforeUnmount 里,v-show 压根不会触发这个钩子,请求照样在后台跑着,回来的时候弹窗虽然不可见,但状态还是被写脏了,下次重新打开弹窗时可能会先闪一下上次遗留的数据。

排查这个问题的时候我加了一句日志才确认:弹窗关闭的事件触发了,但 onBeforeUnmount 没触发。这类用显隐控制、复用同一个组件实例的场景,取消逻辑得挂在"关闭"这个业务事件上,而不是指望生命周期钩子帮你兜底:

1function closeDialog() {
2  controller?.abort()
3  visible.value = false
4}

这是一个具体的教训:取消请求这件事不能只认框架生命周期,还要看清楚组件的显隐策略到底是"销毁重建"还是"隐藏复用",两种策略下清理时机完全不一样。

用 React Query、SWR 这类库时,取消是内置的

组里有人在给一个后台管理系统的重写方案里试用 React Query(目前是 3.x),发现它对竞态和取消这两件事基本是开箱处理的:同一个 queryKey 触发新请求时,库内部会自动用 AbortController 取消上一次没完成的请求(前提是 queryFn 里接住了 signal 并传给 fetch),也会在拿到结果时校验这次请求是不是当前最新的一次,避免过期数据覆盖状态。SWR 的思路类似,靠 key 匹配和请求去重来避免竞态,只是取消信号需要自己在 fetcher 里接上。

这类库把"版本号比对"和"AbortController 接管"这两件我们手写的事都内置了,好处是团队里少写很多重复的样板代码;代价是要理解它们各自的缓存和重新请求策略,出问题的时候不能只盯着自己写的那几行代码排查,还得清楚库内部什么时候会自动重新发请求。这次只是在一个内部工具里小范围试用,还没有铺到主力后台系统上,团队还在观察它跟现有的状态管理方案(部分模块已经在往 Pinia 迁)配合起来会不会有冲突。

接口错误要按类型分开处理

取消、网络失败、业务错误,处理方式完全不同,不能揉在一起弹同一句提示:

1function normalizeError(error) {
2  if (isCancelError(error)) {
3    return { type: 'cancelled' }
4  }
5
6  if (!navigator.onLine) {
7    return { type: 'network', message: '网络不可用' }
8  }
9
10  if (error.code === 'VALIDATION_ERROR') {
11    return { type: 'business', fields: error.fields }
12  }
13
14  return { type: 'unknown', message: '请求失败,请稍后重试' }
15}

取消不提示,网络失败可以给重试按钮,业务错误要回填到具体字段上,未知错误上报监控。全都弹同一句"请求失败,请稍后重试",用户遇到的其实是四种完全不同的情况,却只能自己猜一个操作。

这次内部工具的清理,最后落地成什么样

这次给内部工具补的请求清理,最后落地的样子跟一开始想的不太一样:没有把所有 axios 老接口都推翻重写成 AbortController,只在错误处理那一层加了 isCancelError 把两套判断方式接住;详情页三个并发请求从 Promise.all 换成了 Promise.allSettled;弹窗那处改成挂在 closeDialog 这个业务事件上,不再指望 onBeforeUnmount;上传取消额外调了一个后端接口清分片。改动看着零散,但都是照着"过期请求还能不能改当前状态"这一件事挨个查过去的。

CancelToken 判断写反那次,页面弹出的错误提示是错的但至少还弹了;分片没清干净那次,前端什么异常都没有,是后端存储先攒出问题的。两种坑发现的成本完全不一样,这也是这次为什么值得把两套取消机制的判断方式和几个具体场景一起理一遍的原因。