试想一个场景:在页面中有一个列表和一些筛选按钮,列表的数据与筛选按钮中的条件息息相关,切换筛选条件时,列表也随之刷新。
很简单吧,有手就行:

首先,在后台服务中设计了一个接口,根据不同的类型,返回不同的数据
```js
const records = [
{
id: 1,
type: 1,
name: '张三'
},
{
id: 2,
type: 2,
name: '李四'
},
{
id: 3,
type: 1,
name: '王五'
}
]
app.get('/api/records', (req, res) => {
const type = Number(req.query.type || 0)
const data = type === 0
? records
: records.filter(record => record.type === type)
res.send({
code: 0,
message: 'success',
data
})
})
```
然后,在前台的 vue 页面中请求这个接口:
```vue
```
完美,页面中的显示正是我们期望的结果。
当然,真实项目中的接口不可能这么简单。所以需要给接口加上一定的延时来模拟:
```js
app.get('/api/records', (req, res) => {
const type = Number(req.query.type || 0)
const data = type === 0
? records
: records.filter(record => record.type === type)
setTimeout(() => {
res.send({
code: 0,
message: 'success',
data
})
}, (3 - type) * 500)
})
```
在这里,根据 type 值的不同,接口请求的时长会有所不同。此时,我们再看一下页面上切换不同按钮时所展示的效果:

页面初始显示的时类型 1 所对应的数据,当切换到全部类型之后立即切换到类型 2 时,页面上的数据会先显示出类型 2 对应的数据,之后变成全部类型的数据:

这是为什么呢?
当切换到全部类型时,接口请求已发出,从接口中的设计可以看到,全部类型的 type 值为 0,它的响应时长为 `(3 - 0) * 500 = 1500`;而类型 2 的 type 值为 2,那么它的响应时长应该为 `(3 - 2) * 500 = 500`。可以发现,当我们几乎同时发起 type 值为 0 和 2 的两次请求,type 为 2 的响应会先回来,页面上展示该结果;约 1s 后,type 为 0 的响应结果也回来了,因此会覆盖掉上一次请求的结果,所以页面上就展示上图所示的结果。
**那么要如何避免这种问题呢?**
一个处理方法就是,在响应结果还没有回来之前,禁用其他按钮。既然问题是因为快速切换类型引起的,那么不让用户做到快速切换就解决了。
在用户发起请求时,loading 值为 true,此时所有的类型切换按钮均为禁用状态,点击也被设置成一个空的处理函数:
```vue
{}) : (currentType = item.value)"
>
{{ item.label }}
```
此时,在用户点击了其中一个类型时,页面上呈现的效果如下图所示:

这样,看似很完美地处理了这个问题,但却带来了非常不好的用户体验。假设用户想查看类型 1 的数据,却不小心点击到了其中全部类型或类型 2,此时页面发起新的请求。恰巧该接口响应时间非常长(10s、20s),由于按钮都被禁用了,用户只能等待该请求结束后才能切换到目标类型(也就是类型 1),然后又得等一段可能非常长的接口响应时间,此时用户的心里应该是这样的:

为了防止这种情况出现,下面引入本文的主角。
## 如何取消重复请求
首先得声明一下,在前端已经发出去的请求,无论你怎么做,都是没办法把该请求取消的。所以本文到此结束了。
当然不会这么简单就结束了,虽然没有办法把该请求取消,但是我们可以让该请求响应失败(取消请求)。
以 [axios](https://axios-http.com/) 为例。在 axios@0.22.0 版本之前是通过 `cancelToken` 的形式来实现的,在之后的版本支持以原生的 `AbortController` 的形式来实现。先看一下如何实现:
```ts
// CancelToken
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get('/user/12345', {
cancelToken: source.token
}).catch(function (thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// handle error
}
});
// AbortController
const controller = new AbortController();
axios.get('/foo/bar', {
signal: controller.signal
}).then(function(response) {
//...
});
// cancel the request
controller.abort()
```
本文会以 `v0.22.0` 之前的版本,也就是使用 `cancelToken` 的形式来完成上面的案例。首先,我们需要对 `axios` 进行一些简单的封装:
```ts
import axios, { AxiosInstance, AxiosRequestConfig, Canceler } from 'axios'
const CANCEL_TOKEN_FLAG = 'CANCEL_TOKEN_FLAG'
const instance = axios.create({
baseURL: '/api',
timeout: 300000
})
instance.interceptors.response.use(res => {
if (res.status === 200) {
return Promise.resolve(res.data)
}
return Promise.reject(res)
}, error => {
return Promise.reject(error)
})
// 注册可以取消请求的 get 请求方法
export const getWithCancelToken = () => {
let cancel: Canceler | null = null
const get: AxiosInstance['get'] = (url: string, config: AxiosRequestConfig = {}) => {
if (cancel) {
cancel(CANCEL_TOKEN_FLAG)
}
return instance.get(url, {
...config,
cancelToken: new axios.CancelToken(c => {
cancel = c
})
})
}
return get
}
export default instance
```
在 `src/api/index.ts` 中使用 `getWithCancelToken()` 方法:
```ts
import { getWithCancelToken } from '../libs/axios'
type ResponseType
= {
code: number;
message: string;
data: T
}
const getRecordsWithCancelToken = getWithCancelToken()
export const getRecords = (type: number) => {
return getRecordsWithCancelToken, ResponseType>('/records', {
params: {
type
}
})
}
```
然后,在使用时增加对由重复请求时程序自主取消的请求进行处理:
```vue
```
如此一来,无论我们怎么切换类型,都能够正确地获取到最后的类型对应的响应数据:

再看一下控制台:

可以看到在快速切换时的请求都被取消了。
但是,请求真的被取消了吗?我们尝试一下在后台接口增加一个计数器:
```js
let counter = 0
app.get('/api/records', (req, res) => {
const type = Number(req.query.type || 0)
// 计数
counter++
const data = type === 0
? records
: records.filter(record => record.type === type)
setTimeout(() => {
res.send({
code: 0,
message: 'success',
// 给名称携带上计数器
data: data.map(item => {
item.name += ` --- ${counter}`
return item
})
})
}, (3 - type) * 500)
})
```
然后重启服务器,再看看效果:

可以看到,最后输出的结果,计数器已经累加到了 7,再看看控制台发起请求的数量:

可以看到,刚好是 7 次请求,其中 6 次在前端层面被取消了。
所以说,在没有外力的干扰下,已经发出去的请求是没有办法被取消掉的。通常,我们只会在特定的情况下,并且是获取数据的请求增加取消功能。
## 关于页面切换时取消请求
看下面的例子:

当我们从【首页】切换到【关于我】这个页面时,在【首页】发起的请求出现错误了,却会影响到【关于我】这个页面,这不是我们想要的结果。
先看一下代码,在后台接口增加了两个 get 请求的接口:
```js
app.get('/api/home', (_req, res) => {
setTimeout(() => {
res.send({
code: -1,
message: 'error',
data: null
})
}, 2000)
})
app.get('/api/about', (_req, res) => {
setTimeout(() => {
res.send({
code: 0,
message: 'success',
data: []
})
}, 1000)
})
```
前端的 `src/api/index.ts` 增加两个请求方法:
```ts
import axios, { getWithCancelToken } from '../libs/axios'
type ResponseType = {
code: number;
message: string;
data: T
}
const getRecordsWithCancelToken = getWithCancelToken()
export const getRecords = (type: number) => {
return getRecordsWithCancelToken, ResponseType>('/records', {
params: {
type
}
})
}
export const getHomeData = () => {
return axios.get>('/home')
}
export const getAboutData = () => {
return axios.get>('/about')
}
```
在前端的 `Home.vue` 和 `About.vue` 页面中分别增加如下代码:
```ts
// src/pages/Home.vue
onMounted(() => {
getHomeData()
.then(res => {
if (res.code !== 0) {
throw res
}
console.log('Home: success')
})
.catch(err => {
alert(`Home Error: ` + (err?.message || '服务器出错,请稍候再试'))
})
})
// src/pages/About.vue
onMounted(() => {
getAboutData()
.then(res => {
if (res.code !== 0) {
throw res
}
console.log('About: success')
})
.catch(err => {
alert('About Error: ' + (err?.message || '服务器出错,请稍候再试'))
})
})
```
可以看到,页面上的逻辑是没什么问题的。现在的问题是,在切换页面之后,前一个页面的请求出现了问题影响到了当前页面,这是不合理的。所以需要取消上一个页面未完成的请求。
### 取消上一个页面的请求
同样需要对 `axios` 进行一下请求拦截处理:
```ts
// src/libs/axios.ts
import axios, { AxiosInstance, AxiosRequestConfig, Canceler } from 'axios'
import { useCommonStore } from '../store'
const CANCEL_TOKEN_FLAG = 'CANCEL_TOKEN_FLAG'
export const PAGE_CANCEL_TOKEN_FLAG = 'PAGE_CANCEL_TOKEN_FLAG'
const instance = axios.create({
baseURL: '/api',
timeout: 300000
})
instance.interceptors.request.use(config => {
// 收集 get 请求
if (config.method === 'get') {
const commonStore = useCommonStore()
config.cancelToken = new axios.CancelToken(c => {
commonStore.SET_CANCELERS([
...commonStore.cancelers,
c
])
})
}
return config
})
instance.interceptors.response.use(res => {
if (res.status === 200) {
return Promise.resolve(res.data)
}
return Promise.reject(res)
}, error => {
return Promise.reject(error)
})
export const getWithCancelToken = () => {
let cancel: Canceler | null = null
const get: AxiosInstance['get'] = (url: string, config: AxiosRequestConfig = {}) => {
if (cancel) {
cancel(CANCEL_TOKEN_FLAG)
}
return instance.get(url, {
...config,
cancelToken: new axios.CancelToken(c => {
cancel = c
})
})
}
return get
}
export default instance
```
增加 [pinia](https://github.com/vuejs/pinia) 依赖,并在 `src/main.ts` 中引入:
```ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './router/guard'
import './style.css'
const pinia = createPinia()
createApp(App)
.use(router)
.use(pinia)
.mount('#app')
```
同时创建 `src/store/index.ts` 文件,添加以下代码:
```ts
import { Canceler } from 'axios'
import { defineStore } from 'pinia'
import { PAGE_CANCEL_TOKEN_FLAG } from '../libs/axios'
export const useCommonStore = defineStore('common', {
state: () => (<{
cancelers: Array
}>{
cancelers: []
}),
actions: {
SET_CANCELERS (payload: Array = []) {
if (payload.length === 0) {
this.cancelers.forEach(c => c(PAGE_CANCEL_TOKEN_FLAG))
}
this.cancelers = payload
}
}
})
```
最后创建 `src/router/guard.ts` 增加全局路由拦截:
```ts
import router from './index'
import { useCommonStore } from '../store'
router.beforeEach((_to, _from, next) => {
const commonStore = useCommonStore()
commonStore.SET_CANCELERS([])
next()
})
```
完成这些处理之后,再针对页面中发起的请求被取消时进行错误处理:
```ts
// src/pages/Home.vue
onMounted(() => {
getHomeData()
.then(res => {
if (res.code !== 0) {
throw res
}
console.log('Home: success')
})
.catch(err => {
if (!axios.isCancel(err)) {
alert(`Home Error: ` + (err?.message || '服务器出错,请稍候再试'))
}
})
})
// src/pages/About.vue
onMounted(() => {
getAboutData()
.then(res => {
if (res.code !== 0) {
throw res
}
console.log('About: success')
})
.catch(err => {
if (!axios.isCancel(err)) {
alert('About Error: ' + (err?.message || '服务器出错,请稍候再试'))
}
})
})
```
## 处理冲突
我们发现,在加上了页面切换时的取消请求处理之后,单独页面中的取消重复请求的功能失效了,原因是我们加在 `config.cancelToken` 中的值冲突了。所以需要重新对上面的封装进行处理。
首先,`store` 中不再使用数组来个收集数据,改用键值对的 Map 结构:
```ts
// src/store/index.ts
import { defineStore } from 'pinia'
import { Canceler } from 'axios'
export const useCommonStore = defineStore('common', {
state: () => ({
cancelers: new Map()
}),
actions: {
SET_CANCELERS (payload: Map) {
this.cancelers = payload
},
CLEAR_CANCELERS () {
this.cancelers.forEach(cancaler => {
cancaler()
})
this.cancelers.clear()
}
}
})
```
然后,`axios` 中的收集方式更改:
```ts
import axios from 'axios'
import { useCommonStore } from '../store'
export const CANCELER_KEY = Symbol('CANCELER_KEY')
const instance = axios.create({
baseURL: '/api',
timeout: 300000
})
instance.interceptors.request.use(config => {
// 收集 get 请求
if (config.method === 'get') {
const commonStore = useCommonStore()
/**
* @example
* axios.get('/api/xxx', {
* params: {
* [CANCELER_KEY]: 'Unique key',
* ... another params
* }
* })
*/
const key = config.params?.[CANCELER_KEY]
const key = config.params?.[CANCELER_KEY]
if (key) {
const cancelerList = commonStore.cancelers
const canceler = cancelerList.get(key)
if (canceler) {
canceler()
} else {
config.cancelToken = new axios.CancelToken(c => {
cancelerList.set(key, c)
})
commonStore.SET_CANCELERS(cancelerList)
}
}
}
return config
})
instance.interceptors.response.use(res => {
const commonStore = useCommonStore()
// 尝试清理已经被响应过的请求,如果它存在 canceler
commonStore.cancelers.delete(res.config.params?.[CANCELER_KEY])
if (res.status === 200) {
return Promise.resolve(res.data)
}
return Promise.reject(res)
}, error => {
return Promise.reject(error)
})
export default instance
```
然后修改全局路由守卫中的执行清理的方法:
```ts
// src/router/guard.ts
import router from './index'
import { useCommonStore } from '../store'
router.beforeEach((_to, _from, next) => {
const commonStore = useCommonStore()
commonStore.CLEAR_CANCELERS()
next()
})
```
最后,在请求接口中按需增加 `[CANCELER_KEY]` 这个 `params ` 即可:
```ts
import axios, { CANCELER_KEY } from '../libs/axios'
type ResponseType = {
code: number;
message: string;
data: T
}
export const getRecords = (type: number) => {
return axios.get, ResponseType>('/records', {
params: {
type,
[CANCELER_KEY]: 'getRecords'
}
})
}
export const getHomeData = () => {
return axios.get>('/home', {
params: {
[CANCELER_KEY]: 'getHomeData'
}
})
}
export const getAboutData = () => {
return axios.get>('/about', {
params: {
[CANCELER_KEY]: 'getHomeData'
}
})
}
```
以上,就是本文的所有内容。文中对错误的处理封装并不完善,当然,这不会影响到正常的使用。
[demo地址](https://github.com/humandetail/axios-cancel-request)