前端开发里,页面切换、用户重复操作,都可能让尚未完成的请求变成"孤儿"。这些请求如果继续影响当前页面,数据和 UI 状态就容易变得不可控。
取消这些无效请求,可以避免旧结果继续影响当前页面。请求取消本身不难:实例化一个 AbortController,把 signal 传给请求,需要取消时调用 abort()。难的是,真实项目里请求通常会经过 axios 实例、二次封装的 axios、业务 API,最后才到页面组件。那么 AbortController 应该在哪里创建?signal 要怎么传?多个请求又该怎么处理?
下面从原生 fetch 开始讲解,再过渡到已经封装好的 axios 业务接口。
为什么要取消请求
例如,页面存在未完成的请求时用户跳转,旧请求可能引发几个问题:
- 请求返回后还想更新一个已经卸载的组件。
- 旧页面的数据被写回某个共享状态。
- 用户重复点击,前一个慢请求比后一个快请求更晚返回,导致数据被覆盖。
- 弹窗已经关闭了,但弹窗里的请求还在跑。
取消请求不是为了保证服务端一定停止执行。请求发出去之后,服务端可能已经收到,也可能已经处理完了。前端调用 abort(),其实是在告诉当前这次请求:我这边不等了,这个结果也不应该继续影响当前页面。
核心:AbortController
首先了解一下 AbortController:
js
const controller = new AbortController()
controller.signal
controller.abort()
AbortController 是控制器。
controller.signal 是取消信号,传给请求。
controller.abort() 是发出取消通知。
注意:controller 调用过 abort() 之后,就不能复用了。
因为它的 signal.aborted 会变成 true,而且不会再变回 false。如果你把一个已经 aborted 的 signal 传给新的请求,新请求会立刻被取消。
fetch 里的取消请求
取消单个请求
先用 fetch 演示最小流程。
js
const controller = new AbortController()
async function getUserList() {
try {
await fetch('/api/users', {
// 把 signal 交给 fetch,这个请求才可以被 abort 控制
signal: controller.signal,
})
console.log('请求成功')
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求已取消')
return
}
console.log('请求失败', error)
}
}
getUserList()
// 需要取消时调用
controller.abort()
取消时调用 controller.abort(),这次请求就会收到取消信号。
注意在 catch 中区分错误类型。fetch 被取消后会抛出 AbortError,它不是普通业务错误。页面上最好不要把它显示成"请求失败",否则用户会看到一个并不真实的错误提示。
批量取消
如果多个请求要一起取消,给它们传同一个 signal 就可以。
js
const controller = new AbortController()
async function getPageData() {
try {
await Promise.all([
fetch('/api/users', {
signal: controller.signal,
}),
fetch('/api/roles', {
signal: controller.signal,
}),
])
console.log('页面数据请求成功')
} catch (error) {
if (error.name === 'AbortError') {
console.log('这一组请求已取消')
return
}
console.log('请求失败', error)
}
}
getPageData()
// 一个 abort 会取消所有共用这个 signal 的请求
controller.abort()
这种写法适合一组请求要一起取消的场景。比如弹窗关闭后,弹窗里的几个请求都不需要了。
进入实际开发
前面的例子直接写 fetch,是为了看清楚 AbortController 本身怎么工作。
实际开发里更常见的是这样:
txt
页面组件
-> getUserList()
-> http.get()
-> axios 实例
这里的 getUserList 是已经封装好的业务请求接口。先关心两件事:
- 它可以接收业务参数,比如分页、筛选条件。
- 它也可以接收
signal,内部会把这个signal继续传给 axios 实例。
业务 API 内部怎么拆参数、怎么调用 http.get,不同项目写法不一样,这里不展开讲。本文只关心 signal 怎么传下去:页面创建 controller,把 controller.signal 传给业务 API,业务 API 再把它交给 axios。
普通业务调用
先看不取消请求时,业务 API 是怎么用的,方便理解后面代码。
js
async function loadUsers() {
const users = await getUserList({
page: 1,
pageSize: 10,
})
console.log(users)
}
getUserList 本身还是普通的业务请求函数。后面要取消请求,只是在这个调用基础上多传一个 signal。
单个 axios 请求取消
现在给这个调用加上取消能力。到了页面组件里,还要考虑组件卸载时清理请求。下面用 Vue 3 举例。
具体流程是:
- 发请求前创建新的
AbortController。 - 调用业务 API 时传入
signal。 - 取消时调用
controller.abort()。 - 组件销毁前也调用一次取消,避免页面离开后旧请求继续影响页面。
代码示例:
注意:为了方便演示,这里没有展开重复点击、旧请求回调晚返回这些边界处理。
js
import { onUnmounted, ref } from 'vue'
import axios from 'axios'
import { getUserList } from '@/api/user'
const users = ref([])
const status = ref('idle')
let controller = null
async function loadUsers() {
controller = new AbortController()
status.value = 'loading'
try {
users.value = await getUserList({
page: 1,
pageSize: 10,
// signal 会一路传到 axios 请求配置里
signal: controller.signal,
})
status.value = 'success'
} catch (error) {
// axios 取消请求会进入 catch,需要单独区分
if (axios.isCancel(error)) {
status.value = 'aborted'
return
}
status.value = 'error'
} finally {
controller = null
}
}
function cancelRequest() {
controller?.abort()
}
onUnmounted(() => {
// 跳转页面或组件销毁时,清理还没完成的请求
cancelRequest()
})
前面说过,调用过 abort() 的 controller 不能复用,因为它的 signal.aborted 已经是 true。所以每次重新请求前,都要 new 一个新的 AbortController。
取消请求和请求失败也要分开处理。取消是 aborted,失败才是 error。
多请求:一个 signal 取消多个请求
如果页面里有一组请求要一起取消,可以让它们共用一个 controller。
下面这个例子里,用户列表和角色列表共用同一个 signal,权限列表不传 signal。也就是说,取消时只取消前两个请求,权限列表不受影响。
js
import axios from 'axios'
import { getPermissionList, getRoleList, getUserList } from '@/api/user'
let controller = null
async function loadPageData() {
cancelPageData()
const currentController = new AbortController()
controller = currentController
try {
// 这两个请求共用一个 signal,取消时会一起取消
const usersPromise = getUserList({
signal: currentController.signal,
})
const rolesPromise = getRoleList({
signal: currentController.signal,
})
getPermissionList()
.then(permissions => {
console.log('权限请求成功', permissions)
})
.catch(error => {
console.log('权限请求失败', error)
})
const [users, roles] = await Promise.all([
usersPromise,
rolesPromise,
])
if (controller === currentController) {
console.log(users, roles)
}
} catch (error) {
if (axios.isCancel(error)) {
if (controller === currentController) {
console.log('用户和角色请求已取消')
}
return
}
console.log('请求失败', error)
} finally {
if (controller === currentController) {
controller = null
}
}
}
function cancelPageData() {
controller?.abort()
}
这里用户和角色属于同一个取消分组,所以放进同一个 Promise.all。权限请求不传 signal,也不放进这个 Promise.all,而是单独处理。这样用户和角色被取消时,权限请求不会被 abort,它自己的结果也不会被这个取消分支跳过。
这个写法有一个缺陷:一旦调用 controller.abort(),这个 controller 就不能再用了。下一轮请求必须重新创建新的 controller。
多请求:可更新的共享 signal
上面的例子把一组请求都放在 loadPageData() 里,controller 的创建和清理都在一个地方。实际页面里,请求经常分散在不同函数中:用户列表在 loadUsers 里,角色列表在 loadRoles 里,但它们又想进入同一个取消分组。
这种情况下可以写一个 getOrCreateSharedSignal()。每次发请求前先拿 signal;如果旧 controller 已经取消了,就创建一个新的。
js
import axios from 'axios'
import { getRoleList, getUserList } from '@/api/user'
let sharedController = null
function getOrCreateSharedSignal() {
if (!sharedController || sharedController.signal.aborted) {
// 旧 controller 已经 abort 后不能复用,所以这里要重新创建
sharedController = new AbortController()
}
return sharedController.signal
}
async function loadUsers() {
try {
const users = await getUserList({
signal: getOrCreateSharedSignal(),
})
console.log(users)
} catch (error) {
if (axios.isCancel(error)) {
console.log('用户请求已取消')
return
}
console.log('用户请求失败', error)
}
}
async function loadRoles() {
try {
const roles = await getRoleList({
signal: getOrCreateSharedSignal(),
})
console.log(roles)
} catch (error) {
if (axios.isCancel(error)) {
console.log('角色请求已取消')
return
}
console.log('角色请求失败', error)
}
}
function cancelGroupRequests() {
sharedController?.abort()
sharedController = null
}
这段代码处理的是"共享 signal 取消后不能复用"的问题。取消之后把旧 controller 丢掉,下一次调用 getOrCreateSharedSignal() 时会自动创建新的 controller。
多请求:一个请求一个 controller
除了在共享 signal 方案里重新创建 controller,也可以换个思路:一个请求一个 controller。页面维护一个请求队列,需要取消时统一处理。
这种方式适合请求生命周期不完全一致的页面。需要取消的请求放进队列,不需要取消的请求正常调用。
js
import axios from 'axios'
import { getPermissionList, getRoleList, getUserList } from '@/api/user'
const controllers = new Map()
function createController(key) {
// 只有需要取消的请求才登记到队列里
controllers.get(key)?.abort()
const controller = new AbortController()
controllers.set(key, controller)
return controller
}
function removeController(key, controller) {
// 只清理当前请求自己的 controller,避免误删后发起的新请求
if (controllers.get(key) === controller) {
controllers.delete(key)
}
}
async function loadUsers() {
const controller = createController('users')
try {
const users = await getUserList({
signal: controller.signal,
})
console.log(users)
} catch (error) {
if (axios.isCancel(error)) {
console.log('用户请求已取消')
return
}
console.log('用户请求失败', error)
} finally {
removeController('users', controller)
}
}
async function loadRoles() {
const controller = createController('roles')
try {
const roles = await getRoleList({
signal: controller.signal,
})
console.log(roles)
} catch (error) {
if (axios.isCancel(error)) {
console.log('角色请求已取消')
return
}
console.log('角色请求失败', error)
} finally {
removeController('roles', controller)
}
}
async function loadPermissions() {
// 这个请求不需要取消,所以不创建 controller,也不传 signal
const permissions = await getPermissionList()
console.log(permissions)
}
function cancelAllRequests() {
// 页面离开时遍历队列,批量取消还没完成的请求
controllers.forEach(controller => {
controller.abort()
})
controllers.clear()
}
页面卸载时统一取消:
js
import { onUnmounted } from 'vue'
onUnmounted(() => {
cancelAllRequests()
})
队列写法代码多一点,但更灵活。真实页面里如果有很多接口,只有一部分需要取消,用队列会比手写多个 controller.abort() 更清楚。
总结
controller 一般放在页面或业务流程里创建,signal 传给业务 API,再由业务 API 继续传给 axios。调用过 abort() 的 controller 不能复用,因为它的 signal.aborted 已经是 true。
单个请求就单独 new controller;一组请求要一起取消,就共用 signal;如果页面请求多、生命周期不一样,就一个请求一个 controller,再用队列统一取消。取消请求不是业务失败,UI 上不要把它当成普通 error 展示。
参考
首发地址:https://blog.xchive.top/2026/cancel-requests-with-abort-controller.html