Axios取消重复请求的那些事儿

前端开发
2022年11月08日
1405

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

很简单吧,有手就行:

演示1.gif

首先,在后台服务中设计了一个接口,根据不同的类型,返回不同的数据

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 值的不同,接口请求的时长会有所不同。此时,我们再看一下页面上切换不同按钮时所展示的效果:

演示2.gif

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

image-20221107135742398.png

这是为什么呢?

当切换到全部类型时,接口请求已发出,从接口中的设计可以看到,全部类型的 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>

此时,在用户点击了其中一个类型时,页面上呈现的效果如下图所示:

image-20221107141604834.png

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

image-20221107142217358.png

为了防止这种情况出现,下面引入本文的主角。

如何取消重复请求

首先得声明一下,在前端已经发出去的请求,无论你怎么做,都是没办法把该请求取消的。所以本文到此结束了。

当然不会这么简单就结束了,虽然没有办法把该请求取消,但是我们可以让该请求响应失败(取消请求)。

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>

如此一来,无论我们怎么切换类型,都能够正确地获取到最后的类型对应的响应数据:

演示3.gif

再看一下控制台:

image-20221107155851018.png

可以看到在快速切换时的请求都被取消了。

但是,请求真的被取消了吗?我们尝试一下在后台接口增加一个计数器:

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) })

然后重启服务器,再看看效果:

演示4.gif

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

image-20221107160718568.png

可以看到,刚好是 7 次请求,其中 6 次在前端层面被取消了。

所以说,在没有外力的干扰下,已经发出去的请求是没有办法被取消掉的。通常,我们只会在特定的情况下,并且是获取数据的请求增加取消功能。

关于页面切换时取消请求

看下面的例子:

演示5.gif

当我们从【首页】切换到【关于我】这个页面时,在【首页】发起的请求出现错误了,却会影响到【关于我】这个页面,这不是我们想要的结果。

先看一下代码,在后台接口增加了两个 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.vueAbout.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' } }) }

以上,就是本文的所有内容。文中对错误的处理封装并不完善,当然,这不会影响到正常的使用。

demo地址