避免重复请求

对于一个HTTP请求,前端可以经由AbortController取消浏览器对该请求的响应;但请求仍会在服务器处理

故而,通过在Axios中取消请求,想减少代码在UI上的锁机制是行不通的

如果此时有一个表单的修改操作,用户重复按下5次,“取消请求”后,服务器仍会收到5次修改操作。这种重复请求被发送到服务器显然是一种资源浪费,甚至污染日志数据。通常,前端会在表单确认按钮上添加loading保证响应返回。而后端如何处理,我理解的是后端如何解决数据竞争问题,此处不做讨论

恰巧,本人刚从事前端开发时,网上很盛行如下写法,AbortController结合请求拦截器和响应拦截器,使用取消响应来解决重复请求的问题。

/*
 * @Description: AbortController解决请求未返回时取消发送同样请求
 */
import type { AxiosRequestConfig } from 'axios'
import qs from 'qs'

export class RequestCanceler {
  // 存储每个请求的标志和取消函数
  private pendingRequestMap: Map<string, AbortController>
  constructor() {
    this.pendingRequestMap = new Map<string, AbortController>()
  }

  private generateReqKey(config: AxiosRequestConfig): string {
    const { method, url } = config
    return [url || '', method || '', qs.stringify(config.params), qs.stringify(config.data)].join('&')
  }

  addPendingRequest(config: AxiosRequestConfig) {
    const requestKey: string = this.generateReqKey(config)
    if (!this.pendingRequestMap.has(requestKey)) {
      const controller = new AbortController()
      // 给config挂载signal
      config.signal = controller.signal
      this.pendingRequestMap.set(requestKey, controller)
    }
    else {
      // 如果requestKey已经存在,则获取之前设置的controller,并挂载signal
      config.signal = this.pendingRequestMap.get(requestKey)!.signal
    }
  }

  removePendingRequest(config: AxiosRequestConfig) {
    const requestKey = this.generateReqKey(config)
    // message.destroy(requestKey)
    if (this.pendingRequestMap.has(requestKey)) {
      // 取消请求
      ;this.pendingRequestMap.get(requestKey)!.abort()
      // 从pendingRequest中删掉
      this.pendingRequestMap.delete(requestKey)
    }
  }
}

不发送请求

然而,当然可以不发送请求,可以有两种方式,一种是每个按钮添加loading;另一种是在请求层统一处理,如下

import type { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'
import axios from 'axios'
import qs from 'qs'

/** “不发送重复请求”:同键已有在途请求则抛错,不取消、不替换上一次请求 */
export class DuplicateRequestGuard {
  /** 在途请求键(无 AbortController:不通过 abort 取消在途请求) */
  private pendingKeys = new Set<string>()

  /** 用 method + url + 序列化后的 params/data 作为“同一请求”的键;排序保证键稳定 */
  private generateReqKey(config: AxiosRequestConfig): string {
    const { method = '', url = '' } = config
    const params = qs.stringify(config.params, { sort: (a, b) => a.localeCompare(b) })
    const data = typeof config.data === 'string'
      ? config.data
      : qs.stringify(config.data, { sort: (a, b) => a.localeCompare(b) })

    return [method, url, params, data].join('&')
  }

  /** 若同键已在途则抛错,由拦截器转为 reject,本次请求不会发出 */
  addPendingRequest(config: AxiosRequestConfig) {
    const key = this.generateReqKey(config)
    if (this.pendingKeys.has(key)) {
      throw new Error('DUPLICATE_REQUEST')
    }
    this.pendingKeys.add(key)
  }

  /** 成功或失败都要调用,否则 Set 会一直认为该键仍在途 */
  removePendingRequest(config: AxiosRequestConfig) {
    const key = this.generateReqKey(config)
    this.pendingKeys.delete(key)
  }

  clear() {
    this.pendingKeys.clear()
  }
}

// 与拦截器共用的单例(须放在类声明之后,避免 TDZ)
const duplicateRequestGuard = new DuplicateRequestGuard()

class RequestHttp {
  private service: AxiosInstance

  constructor(config: AxiosRequestConfig) {
    this.service = axios.create(config)

    this.service.interceptors.request.use(
      (config) => {
        try {
          duplicateRequestGuard.addPendingRequest(config)
        }
        catch (e) {
          // 避免prefer-promise-reject-errors,即 reject 须为 Error,再用属性区分业务类型
          return Promise.reject(
            Object.assign(new Error('重复请求'), { type: 'duplicate' as const })
          )
        }
        return config
      },
      error => Promise.reject(error)
    )

    this.service.interceptors.response.use(
      (response) => {
        duplicateRequestGuard.removePendingRequest(response.config)

        const { data } = response
        const { code } = data

        if (code === 401) {
          // 401
          return Promise.reject(data)
        }

        if (code && code !== 200) {
          return Promise.reject(data)
        }

        return data
      },
      (error: AxiosError) => {
        if (error.config) {
          duplicateRequestGuard.removePendingRequest(error.config)
        }

        if (error.message?.includes('timeout')) {
          showWarning('请求超时')
        }

        if (!navigator.onLine) {
          window.location.hash = '/500'
        }

        return Promise.reject(error)
      }
    )
  }

  request<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.service.request(config)
  }
}

请求共享或缓存响应

不过比起手动实现我更加推荐使用 Alova

仍然记得发现这个库时的惊喜,当时颇有天下苦Axios久矣的感觉