前端开发里,页面切换、用户重复操作,都可能让尚未完成的请求变成“孤儿”。这些请求如果继续影响当前页面,数据和 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: 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()
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 举例。
具体流程是:
- 发请求前创建新的
AbortController。
- 调用业务 API 时传入
signal。
- 取消时调用
controller.abort()。
- 组件销毁前也调用一次取消,避免页面离开后旧请求继续影响页面。
代码示例:
注意:为了方便演示,这里没有展开重复点击、旧请求回调晚返回这些边界处理。
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: controller.signal, })
status.value = 'success' } catch (error) { 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 { 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) { 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) { 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() { 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 展示。
参考
- JS-AbortController:优雅中止请求操作在前端开发中,我们经常遇到需要中途撤回请求的情况(例如:搜索框快速 - 掘金