Axios取消重复请求的那些事儿
试想一个场景:在页面中有一个列表和一些筛选按钮,列表的数据与筛选按钮中的条件息息相关,切换筛选条件时,列表也随之刷新。
很简单吧,有手就行:
首先,在后台服务中设计了一个接口,根据不同的类型,返回不同的数据
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
<template>
<div class="page-home">
<div class="conditions-wrapper">
<div
v-for="item of types"
:key="item.value"
class="item"
:class="{ active: currentType === item.value }"
@click="currentType = item.value"
>
{{ item.label }}
</div>
</div>
<div
class="results-wrapper"
:class="{ loading }"
>
<div
v-for="item of records"
:key="item.id"
class="item"
>
<div class="row">
<div class="label">类型:</div>
<div class="value">{{ item.type }}</div>
</div>
<div class="row">
<div class="label">名称:</div>
<div class="value">{{ item.name }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getRecords } from '../api'
type RecordType = {
id: number;
type: number;
name: string;
}
const types = [
{ label: '全部', value: 0 },
{ label: '类型1', value: 1 },
{ label: '类型2', value: 2 },
]
const currentType = ref(0)
const loading = ref(false)
const records = ref<RecordType[]>([])
const fetch = async () => {
try {
loading.value = true
const res = await getRecords<RecordType[]>(currentType.value)
if (res.code !== 0) {
throw res
}
records.value = res.data
loading.value = false
} catch (err: any) {
loading.value = false
records.value = []
alert(err.desc || '服务器出错,请稍候再试')
}
}
watch(currentType, () => {
fetch()
}, { immediate: true })
</script>
完美,页面中的显示正是我们期望的结果。
当然,真实项目中的接口不可能这么简单。所以需要给接口加上一定的延时来模拟:
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
<template>
<div class="page-home">
<div class="conditions-wrapper">
<div
v-for="item of types"
:key="item.value"
class="item"
:class="{
active: currentType === item.value,
disabled: loading
}"
@click="loading ? (() => {}) : (currentType = item.value)"
>
{{ item.label }}
</div>
</div>
<!-- 省略其它内容 -->
</div>
</template>
<script setup lang="ts">
// 省略其它内容
</script>
此时,在用户点击了其中一个类型时,页面上呈现的效果如下图所示:
这样,看似很完美地处理了这个问题,但却带来了非常不好的用户体验。假设用户想查看类型 1 的数据,却不小心点击到了其中全部类型或类型 2,此时页面发起新的请求。恰巧该接口响应时间非常长(10s、20s),由于按钮都被禁用了,用户只能等待该请求结束后才能切换到目标类型(也就是类型 1),然后又得等一段可能非常长的接口响应时间,此时用户的心里应该是这样的:
为了防止这种情况出现,下面引入本文的主角。
如何取消重复请求
首先得声明一下,在前端已经发出去的请求,无论你怎么做,都是没办法把该请求取消的。所以本文到此结束了。
当然不会这么简单就结束了,虽然没有办法把该请求取消,但是我们可以让该请求响应失败(取消请求)。
以 axios 为例。在 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<T = any> = {
code: number;
message: string;
data: T
}
const getRecordsWithCancelToken = getWithCancelToken()
export const getRecords = <T = any>(type: number) => {
return getRecordsWithCancelToken<ResponseType<T>, ResponseType<T>>('/records', {
params: {
type
}
})
}
然后,在使用时增加对由重复请求时程序自主取消的请求进行处理:
vue
<template>
<div class="page-home">
<div class="conditions-wrapper">
<div
v-for="item of types"
:key="item.value"
class="item"
:class="{
active: currentType === item.value,
}"
@click="currentType = item.value"
>
{{ item.label }}
</div>
</div>
<!-- 省略部分代码 -->
</div>
</template>
<script setup lang="ts">
import axios from 'axios'
import { ref, watch } from 'vue'
import { getRecords } from '../api'
type RecordType = {
id: number;
type: number;
name: string;
}
const types = [
{ label: '全部', value: 0 },
{ label: '类型1', value: 1 },
{ label: '类型2', value: 2 },
]
const currentType = ref(0)
const loading = ref(false)
const records = ref<RecordType[]>([])
const fetch = async () => {
try {
loading.value = true
const res = await getRecords<RecordType[]>(currentType.value)
if (res.code !== 0) {
throw res
}
records.value = res.data
loading.value = false
} catch (err: any) {
// 增加对程序取消的请求处理
if (axios.isCancel(err)) {
// 发起了新的请求,loading 状态需要保持
loading.value = true
} else {
// 非取消请求的错误处理
loading.value = false
records.value = []
alert(err.message || '服务器出错,请稍候再试')
}
}
}
watch(currentType, () => {
fetch()
}, { immediate: true })
</script>
如此一来,无论我们怎么切换类型,都能够正确地获取到最后的类型对应的响应数据:
再看一下控制台:
可以看到在快速切换时的请求都被取消了。
但是,请求真的被取消了吗?我们尝试一下在后台接口增加一个计数器:
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<T = any> = {
code: number;
message: string;
data: T
}
const getRecordsWithCancelToken = getWithCancelToken()
export const getRecords = <T = any>(type: number) => {
return getRecordsWithCancelToken<ResponseType<T>, ResponseType<T>>('/records', {
params: {
type
}
})
}
export const getHomeData = () => {
return axios.get<any, ResponseType<any>>('/home')
}
export const getAboutData = () => {
return axios.get<any, ResponseType<any>>('/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 依赖,并在 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<Canceler>
}>{
cancelers: []
}),
actions: {
SET_CANCELERS (payload: Array<Canceler> = []) {
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<string, Canceler>()
}),
actions: {
SET_CANCELERS (payload: Map<string, Canceler>) {
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<T = any> = {
code: number;
message: string;
data: T
}
export const getRecords = <T = any>(type: number) => {
return axios.get<ResponseType<T>, ResponseType<T>>('/records', {
params: {
type,
[CANCELER_KEY]: 'getRecords'
}
})
}
export const getHomeData = () => {
return axios.get<any, ResponseType<any>>('/home', {
params: {
[CANCELER_KEY]: 'getHomeData'
}
})
}
export const getAboutData = () => {
return axios.get<any, ResponseType<any>>('/about', {
params: {
[CANCELER_KEY]: 'getHomeData'
}
})
}
以上,就是本文的所有内容。文中对错误的处理封装并不完善,当然,这不会影响到正常的使用。