前端请求取消:用 AbortController 从 fetch 到 axios

前端开发里,页面切换、用户重复操作,都可能让尚未完成的请求变成“孤儿”。这些请求如果继续影响当前页面,数据和 UI 状态就容易变得不可控。

取消这些无效请求,可以避免旧结果继续影响当前页面。请求取消本身不难:实例化一个 AbortController,把 signal 传给请求,需要取消时调用 abort()。难的是,真实项目里请求通常会经过 axios 实例、二次封装的 axios、业务 API,最后才到页面组件。那么 AbortController 应该在哪里创建?signal 要怎么传?多个请求又该怎么处理?

下面从原生 fetch 开始讲解,再过渡到已经封装好的 axios 业务接口。

为什么要取消请求

例如,页面存在未完成的请求时用户跳转,旧请求可能引发几个问题:

  • 请求返回后还想更新一个已经卸载的组件。
  • 旧页面的数据被写回某个共享状态。
  • 用户重复点击,前一个慢请求比后一个快请求更晚返回,导致数据被覆盖。
  • 弹窗已经关闭了,但弹窗里的请求还在跑。

取消请求不是为了保证服务端一定停止执行。请求发出去之后,服务端可能已经收到,也可能已经处理完了。前端调用 abort(),其实是在告诉当前这次请求:我这边不等了,这个结果也不应该继续影响当前页面。

核心:AbortController

首先了解一下 AbortController

1
2
3
4
5
const controller = new AbortController()

controller.signal

controller.abort()

AbortController 是控制器。

controller.signal 是取消信号,传给请求。

controller.abort() 是发出取消通知。

注意:controller 调用过 abort() 之后,就不能复用了。

因为它的 signal.aborted 会变成 true,而且不会再变回 false。如果你把一个已经 aborted 的 signal 传给新的请求,新请求会立刻被取消。

fetch 里的取消请求

取消单个请求

先用 fetch 演示最小流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 就可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 本身怎么工作。

实际开发里更常见的是这样:

1
2
3
4
页面组件
-> getUserList()
-> http.get()
-> axios 实例

这里的 getUserList 是已经封装好的业务请求接口。先关心两件事:

  • 它可以接收业务参数,比如分页、筛选条件。
  • 它也可以接收 signal,内部会把这个 signal 继续传给 axios 实例。

业务 API 内部怎么拆参数、怎么调用 http.get,不同项目写法不一样,这里不展开讲。本文只关心 signal 怎么传下去:页面创建 controller,把 controller.signal 传给业务 API,业务 API 再把它交给 axios。

普通业务调用

先看不取消请求时,业务 API 是怎么用的,方便理解后面代码。

1
2
3
4
5
6
7
8
async function loadUsers() {
const users = await getUserList({
page: 1,
pageSize: 10,
})

console.log(users)
}

getUserList 本身还是普通的业务请求函数。后面要取消请求,只是在这个调用基础上多传一个 signal

单个 axios 请求取消

现在给这个调用加上取消能力。到了页面组件里,还要考虑组件卸载时清理请求。下面用 Vue 3 举例。

具体流程是:

  1. 发请求前创建新的 AbortController
  2. 调用业务 API 时传入 signal
  3. 取消时调用 controller.abort()
  4. 组件销毁前也调用一次取消,避免页面离开后旧请求继续影响页面。

代码示例:

注意:为了方便演示,这里没有展开重复点击、旧请求回调晚返回这些边界处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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。也就是说,取消时只取消前两个请求,权限列表不受影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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 已经取消了,就创建一个新的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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。页面维护一个请求队列,需要取消时统一处理。

这种方式适合请求生命周期不完全一致的页面。需要取消的请求放进队列,不需要取消的请求正常调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
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()
}

页面卸载时统一取消:

1
2
3
4
5
import { onUnmounted } from 'vue'

onUnmounted(() => {
cancelAllRequests()
})

队列写法代码多一点,但更灵活。真实页面里如果有很多接口,只有一部分需要取消,用队列会比手写多个 controller.abort() 更清楚。

总结

controller 一般放在页面或业务流程里创建,signal 传给业务 API,再由业务 API 继续传给 axios。调用过 abort() 的 controller 不能复用,因为它的 signal.aborted 已经是 true

单个请求就单独 new controller;一组请求要一起取消,就共用 signal;如果页面请求多、生命周期不一样,就一个请求一个 controller,再用队列统一取消。取消请求不是业务失败,UI 上不要把它当成普通 error 展示。

参考

  1. JS-AbortController:优雅中止请求操作在前端开发中,我们经常遇到需要中途撤回请求的情况(例如:搜索框快速 - 掘金