本文将使用 TS 开发一个请求去重的 axios 插件,用户只需创建插件实例,并将实例的请求、响应拦截器注册到 axios 拦截器中,即可实现请求去重。
背景
笔者在项目开发的过程中,碰到了一些问题,例如:“兄弟组件依赖同一个接口的数据,在进入父组件时,会同时初始化这两个组件,但单独打开其中一个组件时,又需要更新这个接口的数据”。
如果将该接口的数据保存在全局状态管理器中,会污染全局数据,不太友好。
那在父组件请求接口,待数据返回后,分发给两个子组件呢?也不太优雅,因为在进入子组件时需要更新数据,就算在父组件给子组件传参,子组件还是要添加请求逻辑。
在 axios 拦截器中处理呢?同时发送多个相同请求时,实际只向服务器发送一次,等待结果返回后,再将结果分发给其他请求。
想想还挺不错,不用修改组件逻辑,避免了同时向服务器发送多个相同请求,还减少了服务器资源占用。
可行性分析
axios 是否可以不向服务器发起请求并直接返回结果?
可以。在 axios 请求拦截器的第一个函数参数 onFulfilled 中,返回 Promise.reject 错误,则可以避免向服务器发送请求。然后可以在 axios 响应拦截器的第二个函数参数 onRejected 中捕获这个错误,捕获错误后可以直接返回结果。
核心功能
- 识别并合并短时间内的相同请求,只向服务器发送一次实际请求,当请求完成时,将请求结果分发给所有相同请求;
- 允许用户自定义规则,判断请求是否相同;
- 允许用户跳过去重处理;
- 插件能够无缝集成到现有的
axios配置中。
开发
生成请求唯一 key 的方法
添加一个默认方法,该方法可根据请求接口的地址、类型、参数,生成一个唯一 key。
import type { AxiosRequestConfig } from 'axios'
const getDataType = (obj: unknown) => {
let res = Object.prototype.toString.call(obj).split(' ')[1]
res = res.substring(0, res.length - 1).toLowerCase()
return res
}
export class AxiosDeduplicator {
static generateKey(config: AxiosRequestConfig): string {
const { method, url, data, params } = config
let key = `${method}-${url}`
try {
switch (getDataType(data)) {
case 'object':
key += `-${JSON.stringify(data)}`
break
case 'string':
key += `-${data}`
break
case 'formdata':
for (const [k, v] of data.entries()) {
if (v instanceof Blob) {
continue
}
key += `-${k}-${v}`
}
break
default:
break
}
if (getDataType(params) === 'object') {
key += `-${JSON.stringify(params)}`
}
} catch (e) {
/* empty */
}
return key
}
}
判断参数类型是为了处理 FormData 、二进制等格式情况。
如果你需要将 key 添加到 header 中,最好使用 encodeURIComponent 转换一下。本文没有将生成的 key 添加到请求头中,是为了避免增加请求头大小。
接收自定义参数
自定义请求 key 生成方法、自定义跳过去重方法、请求超时时间。
import type {
AxiosError,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios'
export interface IOptions<
T extends AxiosRequestConfig = AxiosRequestConfig,
U extends AxiosError = AxiosError,
V extends AxiosResponse = AxiosResponse
> {
/**
* 发起请求时间 - 上次相同请求完成时间 < repeatWindowMs,则视为重复请求。
* 默认为 0ms,即上次请求还没完成,又发起相同请求,才视为重复请求。
*/
repeatWindowMs: number
generateKey: (config: T) => string
/**
* 某些情况下,直接跳过,去重处理
*/
skip?: (config?: T, res?: V, error?: U) => boolean
timeout?: number
}
export class AxiosDeduplicator {
options: IOptions = {
repeatWindowMs: 0,
generateKey: AxiosDeduplicator.generateKey
}
constructor(config: Partial<IOptions> = {}) {
this.options.skip = config.skip
if (config.generateKey) {
this.options.generateKey = config.generateKey
}
if (config.repeatWindowMs) {
this.options.repeatWindowMs = config.repeatWindowMs
}
this.options.timeout = config.timeout
}
...
}
等待队列与接口缓存
添加接口请求等待队列与接口信息缓存变量。
import type {
AxiosError,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios'
import { deepClone } from '@/utils/common'
export type ICallback = (data?: AxiosResponse, error?: AxiosError) => void
export interface IOptions<
T extends AxiosRequestConfig = AxiosRequestConfig,
U extends AxiosError = AxiosError,
V extends AxiosResponse = AxiosResponse
> {
/**
* 发起请求时间 - 上次相同请求完成时间 < repeatWindowMs,则视为重复请求。
* 默认为 0ms,即上次请求还没完成,又发起相同请求,才视为重复请求。
*/
repeatWindowMs: number
generateKey: (config: T) => string
/**
* 某些情况下,直接跳过,去重处理
*/
skip?: (config?: T, res?: V, error?: U) => boolean
timeout?: number
}
export interface ICachedResponse {
data?: AxiosResponse
lastRequestTime: number
}
export class AxiosDeduplicatorPlugin {
...
// 接口信息缓存
history: Map<string, ICachedResponse> = new Map()
// 等待队列
queue: Map<string, ICallback[]> = new Map()
...
clearExpiredHistory() {
const now = Date.now()
for (const [key, { lastRequestTime }] of this.history) {
if (now - lastRequestTime > this.options.repeatWindowMs!) {
this.history.delete(key)
}
}
}
private remove(key: string) {
this.queue.delete(key)
this.clearExpiredHistory()
}
private emit(key: string, data: AxiosResponse): void
private emit(key: string, data: undefined, error: AxiosError): void
private emit(key: string, data?: AxiosResponse, error?: AxiosError): void {
if (this.queue.has(key)) {
for (const callback of this.queue.get(key)!) {
callback(data, error)
}
}
this.remove(key)
}
private enqueue(key: string) {
return new Promise<AxiosResponse>((resolve, reject) => {
const delay = this.options.timeout
let timer: NodeJS.Timeout | undefined
if (delay) {
timer = setTimeout(() => {
reject({
code: 'ERR_CANCELED',
message: 'Request timeout'
})
}, delay)
}
const callback = (data?: AxiosResponse, error?: AxiosError) => {
timer && clearTimeout(timer)
data ? resolve(deepClone(data)) : reject(deepClone(error))
}
// 将相同请求添加到等待队列中
if (!this.queue.has(key)) {
this.queue.set(key, [])
}
this.queue.get(key)!.push(callback)
})
}
}
添加拦截器方法(完整代码)
添加请求拦截器:如果该请求已存在 history 中,则先中断,抛出错误到失败响应拦截器中。
添加成功响应拦截器:返回结果给等待队列。
添加失败响应拦截器:将重复请求添加到等待队列中,以及错误时返回结果给等待队列。
import type {
AxiosError,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios'
import { deepClone } from '...'
export type ICallback = (data?: AxiosResponse, error?: AxiosError) => void
export interface IOptions<
T extends AxiosRequestConfig = AxiosRequestConfig,
U extends AxiosError = AxiosError,
V extends AxiosResponse = AxiosResponse
> {
/**
* 发起请求时间 - 上次相同请求完成时间 < repeatWindowMs,则视为重复请求。
* 默认为 0ms,即上次请求还没完成,又发起相同请求,才视为重复请求。
*/
repeatWindowMs: number
generateKey: (config: T) => string
/**
* 某些情况下,直接跳过,去重处理
*/
skip?: (config?: T, res?: V, error?: U) => boolean
timeout?: number
}
export interface ICachedResponse {
data?: AxiosResponse
lastRequestTime: number
}
const getDataType = (obj: unknown) => {
let res = Object.prototype.toString.call(obj).split(' ')[1]
res = res.substring(0, res.length - 1).toLowerCase()
return res
}
export class AxiosDeduplicator {
static CODE = 'ERR_REPEATED'
// 记录请求发起时间与请求结果
history: Map<string, ICachedResponse> = new Map()
// 请求等待队列
queue: Map<string, ICallback[]> = new Map()
options: IOptions = {
repeatWindowMs: 0,
generateKey: AxiosDeduplicator.generateKey
}
constructor(config: Partial<IOptions> = {}) {
this.options.skip = config.skip
if (config.generateKey) {
this.options.generateKey = config.generateKey
}
if (config.repeatWindowMs) {
this.options.repeatWindowMs = config.repeatWindowMs
}
this.options.timeout = config.timeout
}
static generateKey(config: AxiosRequestConfig): string {
const { method, url, data, params } = config
let key = `${method}-${url}`
try {
switch (getDataType(data)) {
case 'object':
key += `-${JSON.stringify(data)}`
break
case 'string':
key += `-${data}`
break
case 'formdata':
for (const [k, v] of data.entries()) {
if (v instanceof Blob) {
continue
}
key += `-${k}-${v}`
}
break
default:
break
}
if (getDataType(params) === 'object') {
key += `-${JSON.stringify(params)}`
}
} catch (e) {
/* empty */
}
return key
}
clearExpiredHistory() {
const now = Date.now()
for (const [key, { lastRequestTime }] of this.history) {
if (now - lastRequestTime > this.options.repeatWindowMs!) {
this.history.delete(key)
}
}
}
private remove(key: string) {
this.queue.delete(key)
this.clearExpiredHistory()
}
private emit(key: string, data: AxiosResponse): void
private emit(key: string, data: undefined, error: AxiosError): void
private emit(key: string, data?: AxiosResponse, error?: AxiosError): void {
if (this.queue.has(key)) {
for (const callback of this.queue.get(key)!) {
callback(data, error)
}
}
this.remove(key)
}
private enqueue(key: string) {
return new Promise<AxiosResponse>((resolve, reject) => {
const delay = this.options.timeout
let timer: NodeJS.Timeout | undefined
if (delay) {
timer = setTimeout(() => {
reject({
code: 'ERR_CANCELED',
message: 'Request timeout'
})
}, delay)
}
const callback = (data?: AxiosResponse, error?: AxiosError) => {
timer && clearTimeout(timer)
data ? resolve(deepClone(data)) : reject(deepClone(error))
}
// 入列
if (!this.queue.has(key)) {
this.queue.set(key, [])
}
this.queue.get(key)!.push(callback)
})
}
requestInterceptor(config: InternalAxiosRequestConfig) {
const isSkip = this.options.skip ? this.options.skip(config) : false
if (isSkip) {
return config
}
const key = this.options.generateKey(config)
const history = this.history.get(key)
// 如果存在符合条件的相同请求,则抛出错误(该错误会在 axios 的响应拦截器的 reject 中处理)
if (
history &&
(!history.data || Date.now() - history.lastRequestTime < this.options.repeatWindowMs)
) {
return Promise.reject({
code: AxiosDeduplicator.CODE,
message: 'Request repeated',
config
})
}
this.history.set(key, { lastRequestTime: Date.now() })
return config
}
responseInterceptorFulfilled(response: AxiosResponse) {
const key = this.options.generateKey(response.config)
// 判断是否需要跳过去重
if (this.options.skip && this.options.skip(undefined, response)) {
this.remove(key)
this.history.delete(key)
return response
}
const history = this.history.get(key)
if (this.options.repeatWindowMs && history) {
history.data = deepClone(response)
// 缓存过期后,清理缓存,避免占用内存过大
setTimeout(() => {
this.clearExpiredHistory()
}, this.options.repeatWindowMs)
}
// 发送结果给等待队列
this.emit(key, response)
return response
}
responseInterceptorRejected(error: AxiosError) {
const key = this.options.generateKey(error.config!)
// 判断是否跳过去重
if (this.options.skip && this.options.skip(undefined, undefined, error)) {
this.remove(key)
this.history.delete(key)
return Promise.reject(error)
}
// 处理请求拦截器中抛出的错误
if (error.code === AxiosDeduplicator.CODE) {
const history = this.history.get(key)
// 距离上次请求时间间隔不小于 repeatWindowMs,从缓存中取出请求结果
if (history && history.data) {
return Promise.resolve(deepClone(history.data))
}
// 上次请求还未完成,等待结果
return this.enqueue(key)
}
this.emit(key, undefined, error)
return Promise.reject(error)
}
}
export default function createAxiosDeduplicatorInstance(options: Partial<IOptions> = {}) {
const instance = new AxiosDeduplicator(options)
return {
requestInterceptor: instance.requestInterceptor.bind(instance), // 解决 this 指向问题
responseInterceptorFulfilled: instance.responseInterceptorFulfilled.bind(instance),
responseInterceptorRejected: instance.responseInterceptorRejected.bind(instance)
}
}
你可能会问“为什么在请求/响应拦截器中重复生成key,而不是在请求拦截器中生成一次,保存到 header 中”?
因为我不想将生成的 key 发送到服务端,也可以避免出现覆盖用户自定义 header 属性的情况。
如果你的应用对性能特别敏感,你可以只生成一次 key 并保存到 header 中。
基本使用
import axios, { type AxiosRequestConfig } from 'axios'
import AxiosDeduplicator from '..'
const instance = axios.create({...})
const deduplicator = AxiosDeduplicator({
skip(config) {
return config?.headers?.isAllowRepetition === true
}
})
// 注册请求去重插件
instance.interceptors.request.use(deduplicator.requestInterceptor)
instance.interceptors.response.use(
deduplicator.responseInterceptorFulfilled,
deduplicator.responseInterceptorRejected
)
// 你的token携带逻辑。axios请求拦截器,后进先出。
instance.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
更多用法
直接安装使用
npm install axios-deduplicator
pnpm add axios-deduplicator
点击查看 axios-deduplicator 更多用法。