Axios插件-请求去重

645 阅读4分钟

本文将使用 TS 开发一个请求去重的 axios 插件,用户只需创建插件实例,并将实例的请求、响应拦截器注册到 axios 拦截器中,即可实现请求去重。

背景

笔者在项目开发的过程中,碰到了一些问题,例如:“兄弟组件依赖同一个接口的数据,在进入父组件时,会同时初始化这两个组件,但单独打开其中一个组件时,又需要更新这个接口的数据”。
如果将该接口的数据保存在全局状态管理器中,会污染全局数据,不太友好。
那在父组件请求接口,待数据返回后,分发给两个子组件呢?也不太优雅,因为在进入子组件时需要更新数据,就算在父组件给子组件传参,子组件还是要添加请求逻辑。

axios 拦截器中处理呢?同时发送多个相同请求时,实际只向服务器发送一次,等待结果返回后,再将结果分发给其他请求。
想想还挺不错,不用修改组件逻辑,避免了同时向服务器发送多个相同请求,还减少了服务器资源占用。

可行性分析

axios 是否可以不向服务器发起请求并直接返回结果?
可以。在 axios 请求拦截器的第一个函数参数 onFulfilled 中,返回 Promise.reject 错误,则可以避免向服务器发送请求。然后可以在 axios 响应拦截器的第二个函数参数 onRejected 中捕获这个错误,捕获错误后可以直接返回结果。

核心功能

  1. 识别并合并短时间内的相同请求,只向服务器发送一次实际请求,当请求完成时,将请求结果分发给所有相同请求;
  2. 允许用户自定义规则,判断请求是否相同;
  3. 允许用户跳过去重处理;
  4. 插件能够无缝集成到现有的 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 更多用法。