跳转到内容

RequestService

使用示例

ts
import { RequestService } from '.'
import type { RequestConfig } from '.'

export const service = new RequestService({
  retry: {
    maxCount: 2,
    delay: 500,
    timing: 'linear', // 'linear' | 'exponential'
    retryIf(error, count) {
      // 全局自定义重试条件
      console.log('global retry', error, count)
      return true
    },
    onRetry(error, count, delay) {
      console.log('global retry', error, count, delay)
    },
  },
  cache: {
    storage: 'memory', // 'memory' | 'session' | 'local'
    expireTime: 5 * 60 * 1000,
  },
  queue: {
    maxConcurrent: 5, // 最大并发数
  },
})

service.interceptors.request.use((config) => {
  console.log('request', config)

  if (config.withToken) {
    if (!config.headers) config.headers = {}
    config.headers['Authorization'] =
      'Bearer ' + localStorage.getItem('token')
  }

  return config
})

service.interceptors.response.use((response) => {
  console.log('response', response)

  const config = response.config as RequestConfig

  // 明确不处理 response 或 responseType 不是 json 时,直接返回 response
  if (!config.handleResponse || config.responseType !== 'json') {
    return response
  }

  const { code, msg, data } = response.data

  switch (code) {
    case 200:
      return data
    case 401:
      alert('未授权')
      // 处理未授权
      break
    default:
      // 处理其他错误
      alert(msg)
      break
  }

  return data
})

/**
 * 使用示例
 ********************************/

// 不带 token
service.get('/login', { withToken: false })

// 不处理 response
service.get('/api', { handleResponse: false })

// 启用缓存
service.get('/api', { cache: true })

// 设置优先级
service.get('/api', { priority: 9 })

// 阻止重复请求
// 取消新的请求
service.get('/api', { preventDuplicate: 'cancel-new' })
// 取消旧的请求
service.get('/api', { preventDuplicate: 'cancel-old' })
// 将新的请求链接到旧的请求,新的请求直接使用旧的请求的响应数据
service.get('/api', { preventDuplicate: 'link' })

// 请求重试
// 启用重试,如果配置了重试,则使用默认启用重试
service.get('/api', { retry: true })
// 禁用重试
service.get('/api', { retry: false })
// 启用重试,并设置重置配置
service.get('/api', {
  retry: {
    maxCount: 3,
    delay: 1000,
    timing: 'exponential',
    retryIf: (error) => error.code === 'ECONNABORTED',
    onRetry: (error, retryCount, delay) => {
      console.log('retry', retryCount, delay)
    },
  },
})

封装实现

ts
export * from './service'
export type * from './types'
ts
import {
  AxiosInterceptorManager,
  AxiosRequestConfig,
  AxiosResponse,
  CreateAxiosDefaults,
} from 'axios'

export interface RequestServiceConfig extends CreateAxiosDefaults {
  /**
   * 默认重试配置
   *
   * 配置此属性后,所有请求都会启用重试机制
   */
  retry?: RetryConfig

  /**
   * 默认请求缓存配置
   */
  cache?: RequestCacheConfig

  /**
   * 请求队列配置
   */
  queue?: RequestQueueConfig
}

export interface RequestConfig<D = any> extends AxiosRequestConfig<D> {
  /**
   * 请求优先级 0-9,值越大,优先级越高
   *
   * @default 5
   */
  priority?: number

  /**
   * 阻止重复请求策略
   *
   * 'cancel-new': 取消新的请求,继续使用旧的请求
   * 'cancel-old': 取消旧的请求,使用新的请求
   * 'link': 将新的请求链接到旧的请求,新的请求直接使用旧的请求的响应数据
   */
  preventDuplicate?: 'cancel-new' | 'cancel-old' | 'link'

  /**
   * 重试配置,会覆盖默认配置
   */
  retry?: RetryConfig | boolean

  /**
   * 重试次数
   */
  retryCount?: number

  /**
   * 是否启用缓存
   */
  cache?: boolean

  /**
   * 是否处理response
   *
   * @default true
   */
  handleResponse?: boolean

  /**
   * 是否携带token
   *
   * @default true
   */
  withToken?: boolean
}

export interface RetryConfig {
  /**
   * 最大重试次数
   *
   * @default 2
   */
  maxCount?: number

  /**
   * 重试间隔时间 (ms)
   *
   * @default 500
   */
  delay?: number

  /**
   * 时间曲线
   *
   * @default 'linear'
   */
  timing?: 'linear' | 'exponential'

  /**
   * 是否重试
   *
   * 返回 true 时执行重试
   *
   * 执行顺序:
   * 1. `RequestServiceConfig.retry.retryIf` 这里用于控制所有请求是否重试
   * 2. `RequestConfig.retry.retryIf` 这里用于控制单个请求是否重试
   */
  retryIf?: (error: any, count: number) => boolean

  /**
   * 重试事件
   *
   * 执行顺序:
   * 1. `RequestServiceConfig.retry.onRetry`
   * 2. `RequestConfig.retry.onRetry`
   */
  onRetry?: (error: any, count: number, delay: number) => void
}

export interface RequestQueueConfig {
  /**
   * 最大并发数
   *
   * @default 5
   */
  maxConcurrent?: number
}

export interface RequestCacheConfig {
  /**
   * 缓存存储位置
   *
   * @default 'memory'
   */
  storage?: 'memory' | 'session' | 'local'

  /**
   * 缓存过期时间
   *
   * @default 5 * 60 * 1000
   */
  expireTime?: number
}

export interface RequestError extends Error {
  code?: string
  config: RequestConfig
  response?: AxiosResponse
  isAxiosError: boolean
  retryCount?: number
}

export interface BaseResponse<T = any> {
  data: T
  code: number
  msg: string
}

export type ServiceInterceptors = {
  request: AxiosInterceptorManager<RequestConfig>
  response: AxiosInterceptorManager<AxiosResponse>
}
ts
import axios, { AxiosError } from 'axios'
import { RequestQueue } from './queue'
import type { AxiosInstance } from 'axios'
import type {
  BaseResponse,
  RequestConfig,
  RequestServiceConfig,
  RetryConfig,
  ServiceInterceptors,
} from './types'
import { isFunction, isNumber, isObject, sleep } from './utils'
import { RequestCacher } from './cache'

export class RequestService {
  private readonly retryConfig?: RetryConfig
  private readonly axiosInstance: AxiosInstance
  private readonly queue: RequestQueue
  private readonly cacher: RequestCacher

  constructor(config?: RequestServiceConfig) {
    const {
      retry: retryConfig,
      queue: queueConfig,
      cache: cacheConfig,
      ...axiosConfig
    } = config || {}

    this.axiosInstance = axios.create(axiosConfig)
    this.queue = new RequestQueue(queueConfig)
    this.cacher = new RequestCacher(cacheConfig)

    if (retryConfig) {
      this.retryConfig = {
        maxCount: 2,
        delay: 500,
        timing: 'linear',
        ...retryConfig,
      }
    }
  }

  get interceptors() {
    return this.axiosInstance.interceptors as ServiceInterceptors
  }

  /**
   * 生成请求ID
   */
  private generateRequestID(config: RequestConfig) {
    const { method, url, params, data } = config
    const compose: string[] = []
    if (method) compose.push(method.toLocaleUpperCase())
    if (url) compose.push(url)
    if (params) compose.push(JSON.stringify(params))
    if (data) compose.push(JSON.stringify(data))
    return compose.join('_')
  }

  /**
   * 判断是否需要重试
   */
  private shouldRetry(error: unknown): boolean {
    if (error instanceof AxiosError) {
      const { code, config, response } = error

      const retryConfig = (config as RequestConfig)?.retry
      const retryCount = (config as RequestConfig)?.retryCount

      if (retryConfig === false) {
        // 明确不重试
        return false
      }

      if (
        isObject(retryConfig) &&
        isNumber(retryCount) &&
        retryCount >= (retryConfig.maxCount || 0)
      ) {
        // 超过最大重试次数
        return false
      }

      if (
        this.retryConfig?.retryIf &&
        !this.retryConfig.retryIf(error, retryCount!)
      ) {
        // 全局自定义重试条件不满足
        return false
      }

      if (
        isObject(retryConfig) &&
        isFunction(retryConfig.retryIf) &&
        this.retryConfig?.retryIf !== retryConfig.retryIf && // 避免重复判断全局条件
        !retryConfig.retryIf(error, retryCount!)
      ) {
        // 自定义重试条件不满足
        return false
      }

      if (code === 'ECONNABORTED') {
        // 超时
        // return false
      }

      // 网络错误
      if (code === 'NETWORK_ERROR') {
        // return false
      }

      // 请求被取消
      if (axios.isCancel(error)) {
        return false
      }

      if (response) {
        if (response.status >= 500 && response.status < 600) {
          // 服务器错误
          return false
        }

        if (response.status === 401) {
          // 未授权
          return false
        }

        if (response.status === 404) {
          // 未找到资源
          return false
        }
      }
    }

    if (!this.retryConfig) {
      // 没有配置重试,默认不重试
      return false
    }

    return true
  }

  /**
   * 请求重试
   */
  private async requestRetry<
    R extends BaseResponse = BaseResponse,
    D = any,
  >(config: RequestConfig, error: unknown) {
    if (config.retryCount === void 0) {
      config.retryCount = 1
    } else {
      config.retryCount++
    }

    const retryConfig = config.retry as RetryConfig

    let delay = retryConfig.delay!

    if (retryConfig.timing === 'exponential') {
      delay = delay * Math.pow(2, config.retryCount - 1)
    }

    if (retryConfig.onRetry) {
      retryConfig.onRetry(error, config.retryCount, delay)
      if (
        this.retryConfig &&
        this.retryConfig.onRetry &&
        retryConfig.onRetry !== this.retryConfig.onRetry
      ) {
        this.retryConfig.onRetry(error, config.retryCount, delay)
      }
    }

    await sleep(delay)

    return this.request<R, D>(config)
  }

  /**
   * 处理错误
   */
  private handleError(error: unknown, config: RequestConfig) {
    return error
  }

  /**
   * 取消请求
   */
  cancel(id: string, reason = 'canceled') {
    this.queue.remove(id, reason)
  }

  /**
   * 取消所有请求
   */
  cancelAll(reason = 'canceled') {
    this.queue.clear(reason)
  }

  /**
   * 发起请求
   */
  async request<R extends BaseResponse = BaseResponse, D = any>(
    config: RequestConfig<D>,
  ): Promise<R> {
    const mergedConfig: RequestConfig = {
      priority: RequestQueue.DEFAULT_PRIORITY,
      handleResponse: true,
      withToken: true,
      ...config,
    }

    if (mergedConfig.retry === void 0 && mergedConfig.retry === true) {
      mergedConfig.retry = this.retryConfig
    } else if (isObject(mergedConfig.retry)) {
      mergedConfig.retry = {
        ...this.retryConfig,
        ...mergedConfig.retry,
      }
    }

    const id = this.generateRequestID(mergedConfig)

    const cache = this.cacher.get(id)
    // 有缓存,直接返回
    if (cache) return cache

    const source = axios.CancelToken.source()
    mergedConfig.cancelToken = source.token

    const task = () => this.axiosInstance.request<any, R, D>(mergedConfig)

    try {
      const response = await this.queue.add(
        id,
        mergedConfig,
        task,
        source.cancel,
      )
      // 缓存
      this.cacher.cache(id, mergedConfig, response)
      return response
    } catch (error) {
      if (this.shouldRetry(error)) {
        return this.requestRetry(mergedConfig, error)
      }
      throw this.handleError(error, mergedConfig)
    }
  }

  /**
   * 发起 GET 请求
   */
  get<R extends BaseResponse = BaseResponse, D = any>(
    url: string,
    config?: RequestConfig<D>,
  ) {
    return this.request<R, D>({
      url,
      method: 'get',
      ...config,
    })
  }

  /**
   * 发起 POST 请求
   */
  post<R extends BaseResponse = BaseResponse, D = any>(
    url: string,
    data?: D,
    config?: RequestConfig<D>,
  ) {
    return this.request<R, D>({
      url,
      method: 'post',
      data,
      ...config,
    })
  }

  /**
   * 发起 PUT 请求
   */
  put<R extends BaseResponse = BaseResponse, D = any>(
    url: string,
    data?: D,
    config?: RequestConfig<D>,
  ) {
    return this.request<R, D>({
      url,
      method: 'put',
      data,
      ...config,
    })
  }

  /**
   * 发起 DELETE 请求
   */
  delete<R extends BaseResponse = BaseResponse, D = any>(
    url: string,
    config?: RequestConfig<D>,
  ) {
    return this.request<R, D>({
      url,
      method: 'delete',
      ...config,
    })
  }

  /**
   * 发起 PATCH 请求
   */
  patch<R extends BaseResponse = BaseResponse, D = any>(
    url: string,
    data?: D,
    config?: RequestConfig<D>,
  ) {
    return this.request<R, D>({
      url,
      method: 'patch',
      data,
      ...config,
    })
  }

  /**
   * 发起 HEAD 请求
   */
  head<R extends BaseResponse = BaseResponse, D = any>(
    url: string,
    config?: RequestConfig<D>,
  ) {
    return this.request<R, D>({
      url,
      method: 'head',
      ...config,
    })
  }

  /**
   * 发起 OPTIONS 请求
   */
  options<R extends BaseResponse = BaseResponse, D = any>(
    url: string,
    config?: RequestConfig<D>,
  ) {
    return this.request<R, D>({
      url,
      method: 'options',
      ...config,
    })
  }
}
ts
import { CanceledError, Canceler } from 'axios'
import { RequestConfig, RequestQueueConfig } from './types'

export class RequestQueue {
  static readonly DEFAULT_PRIORITY = 5
  static readonly DEFAULT_MAX_CONCURRENT = 5

  private readonly pending: TaskContext[] = []
  private readonly concurrent: TaskContext[] = []
  private readonly maxConcurrent: number

  constructor(config: RequestQueueConfig = {}) {
    this.maxConcurrent =
      config.maxConcurrent ?? RequestQueue.DEFAULT_MAX_CONCURRENT
  }

  private process() {
    if (this.pending.length === 0) return

    // 计算可用并行量
    const available = this.maxConcurrent - this.currentConcurrent
    if (available <= 0) return

    // 取出需要并行执行任务
    const tasksToRun = this.pending.splice(0, available)

    for (const context of tasksToRun) {
      this.concurrent.push(context)
      context.isRunning = true
      context
        .task()
        .then(context.resolve)
        .catch(context.reject)
        .finally(() => {
          context.isRunning = false
          // 移除已结束任务
          this.concurrent.splice(this.concurrent.indexOf(context), 1)
          // 递归处理后续任务
          this.process()
        })
    }
  }

  /**
   * 查找任务
   *
   * @param id 任务id
   */
  find(id: string): TaskContext | undefined {
    for (const context of this.pending) {
      if (context.id === id) {
        return context
      }
    }
    for (const context of this.concurrent) {
      if (context.id === id) {
        return context
      }
    }
  }

  /**
   * 当前并发数
   */
  get currentConcurrent(): number {
    return this.concurrent.length
  }

  /**
   * 添加任务
   *
   * @param id 任务id
   * @param priority 任务优先级
   * @param task 任务
   * @param cancel 任务取消函数
   */
  add<T = any>(
    id: string,
    config: RequestConfig,
    task: () => Promise<T>,
    cancel: Canceler,
  ): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      const context = new TaskContext(
        id,
        config,
        task,
        resolve,
        reject,
        cancel,
      )

      // 处理重复请求
      switch (config.preventDuplicate) {
        case 'cancel-new':
          {
            if (this.find(id)) {
              context.reject(new CanceledError('task duplicate'))
            }
          }
          break
        case 'cancel-old':
          {
            const old = this.find(id)
            if (old) {
              if (old.isRunning) {
                old.cancel('task canceled')
                this.concurrent.splice(this.concurrent.indexOf(old), 1)
              } else {
                old.reject(new CanceledError('task duplicate'))
                this.pending.splice(this.pending.indexOf(old), 1)
              }
            }
          }
          break
        case 'link':
          {
            const old = this.find(id)
            if (old) {
              old.link(context)
              this.process()
              return
            }
          }
          break
      }

      if (this.pending.length > 0) {
        let index = this.pending.length
        for (let i = this.pending.length - 1; i > 0; i--) {
          if (this.pending[i].priority >= context.priority) {
            break
          }
          index = i
        }
        this.pending.splice(index, 0, context)
      } else {
        this.pending.push(context)
      }

      this.process()
    })
  }

  /**
   * 移除任务
   *
   * @param id 任务id
   * @returns 移除的数量
   */
  remove(id: string, reason = 'task removed') {
    const indexes: number[] = []
    for (let i = 0; i < this.pending.length; i++) {
      const context = this.pending[i]
      if (context.id === id) {
        // 倒序插入,以免 splice 删除的索引位置错误
        indexes.unshift(i)
        context.reject(new CanceledError(reason))
      }
    }
    for (const index of indexes) {
      this.pending.splice(index, 1)
    }
    return indexes.length
  }

  /**
   * 清空队列
   */
  clear(reason = 'task removed') {
    for (const context of this.pending) {
      context.reject(new CanceledError(reason))
    }
    const count = this.pending.length
    this.pending.length = 0
    return count
  }
}

export class TaskContext<T = any> {
  readonly id: string
  readonly config: RequestConfig
  readonly priority: number
  readonly task: () => Promise<T>
  readonly cancel: Canceler
  readonly links: TaskContext[] = []

  /**
   * 是否正在运行
   */
  isRunning = false

  /**
   * 是否已经完成
   */
  private isFinished = false

  private readonly _reject: (reason?: any) => void
  private readonly _resolve: (value: T) => void

  constructor(
    id: string,
    config: RequestConfig,
    task: () => Promise<T>,
    resolve: (value: T) => void,
    reject: (reason?: any) => void,
    cancel: Canceler,
  ) {
    this.id = id
    this.config = config
    this.priority = config.priority ?? RequestQueue.DEFAULT_PRIORITY
    this.task = task
    this.cancel = cancel
    this._resolve = resolve
    this._reject = reject

    this.resolve = this.resolve.bind(this)
    this.reject = this.reject.bind(this)
  }

  resolve(value: T) {
    if (this.isFinished) return
    this._resolve(value)
    if (this.links && this.links.length > 0) {
      // 有链接的任务
      for (const link of this.links) {
        link.resolve(value)
      }
    }
    this.links.length = 0
    this.isFinished = true
  }

  reject(reason?: any) {
    if (this.isFinished) return
    this._reject(reason)
    if (this.links && this.links.length > 0) {
      // 有链接的任务
      for (const link of this.links) {
        link.reject(reason)
      }
    }
    this.links.length = 0
    this.isFinished = true
  }

  link(content: TaskContext) {
    this.links.push(content)
  }
}
ts
import {
  LocalStorage,
  MemoryStorage,
  RequestCacheStorage,
  SessionStorage,
} from './storage'
import { RequestCacheConfig, RequestConfig } from './types'

export class RequestCacher {
  readonly storage: RequestCacheStorage
  constructor(config: RequestCacheConfig = {}) {
    const { storage, expireTime = 5 * 60 * 1000 } = config

    switch (storage) {
      case 'memory':
        this.storage = new MemoryStorage({ expireTime })
        break
      case 'session':
        this.storage = new SessionStorage({ expireTime })
        break
      case 'local':
        this.storage = new LocalStorage({ expireTime })
        break
      default:
        this.storage = new MemoryStorage({ expireTime })
    }
  }

  get(id: string) {
    return this.storage.get(id)
  }

  remove(id: string) {
    this.storage.remove(id)
  }

  set(id: string, value: any) {
    this.storage.set(id, value)
  }

  clear() {
    this.storage.clear()
  }

  cache(id: string, config: RequestConfig, response: any) {
    if (config.cache) {
      this.set(id, response)
    }
  }
}
ts
import { deepClone } from './utils'

export interface RequestCacheStorageConfig {
  expireTime: number
}

export interface RequestCacheStorageItem {
  value: any
  expireTime: number
}

export interface RequestCacheStorage {
  readonly expireTime: number
  get(key: string): any
  set(key: string, value: any): void
  remove(key: string): void
  clear(): void
}

export class MemoryStorage implements RequestCacheStorage {
  readonly expireTime: number
  private storage: Record<string, RequestCacheStorageItem> = {}

  constructor(config: RequestCacheStorageConfig) {
    this.expireTime = config.expireTime
  }

  get(key: string) {
    const item = this.storage[key]
    if (!item) return null
    if (Date.now() > item?.expireTime) {
      this.remove(key)
      return null
    }
    return deepClone(item.value)
  }

  set(key: string, value: any) {
    this.storage[key] = {
      value: deepClone(value),
      expireTime: Date.now() + this.expireTime,
    }
  }

  remove(key: string) {
    delete this.storage[key]
  }

  clear(): void {
    this.storage = {}
  }
}

export class SessionStorage implements RequestCacheStorage {
  readonly expireTime: number

  constructor(config: RequestCacheStorageConfig) {
    this.expireTime = config.expireTime
  }

  get(key: string): any {
    try {
      const itemStr = sessionStorage.getItem(key)
      if (!itemStr) return null

      const item: RequestCacheStorageItem = JSON.parse(itemStr)
      if (Date.now() > item.expireTime) {
        this.remove(key)
        return null
      }

      return item.value
    } catch (e) {
      return null
    }
  }

  set(key: string, value: any): void {
    const item: RequestCacheStorageItem = {
      value,
      expireTime: Date.now() + this.expireTime,
    }
    sessionStorage.setItem(key, JSON.stringify(item))
  }

  remove(key: string): void {
    sessionStorage.removeItem(key)
  }

  clear(): void {
    sessionStorage.clear()
  }
}

export class LocalStorage implements RequestCacheStorage {
  readonly expireTime: number

  constructor(config: RequestCacheStorageConfig) {
    this.expireTime = config.expireTime
  }

  get(key: string): any {
    try {
      const itemStr = localStorage.getItem(key)
      if (!itemStr) return null

      const item: RequestCacheStorageItem = JSON.parse(itemStr)
      if (Date.now() > item.expireTime) {
        this.remove(key)
        return null
      }

      return item.value
    } catch (e) {
      return null
    }
  }

  set(key: string, value: any): void {
    const item: RequestCacheStorageItem = {
      value,
      expireTime: Date.now() + this.expireTime,
    }
    localStorage.setItem(key, JSON.stringify(item))
  }

  remove(key: string): void {
    localStorage.removeItem(key)
  }

  clear(): void {
    localStorage.clear()
  }
}
ts
export function isObject(
  val: any,
): val is Record<string | number | symbol, any> {
  return val !== null && typeof val === 'object'
}

export function isNumber(val: any): val is number {
  return typeof val === 'number'
}

export function isFunction(val: any): val is Function {
  return typeof val === 'function'
}

export function sleep(ms?: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

export function deepClone<T = any>(value: T): T {
  // 如果是 null 或者不是对象,直接返回
  if (value === null || typeof value !== 'object') {
    return value
  }

  // 检查是否为二进制数据类型(ArrayBuffer, Blob等)
  if (
    value instanceof ArrayBuffer ||
    (typeof Blob !== 'undefined' && value instanceof Blob)
  ) {
    throw new Error('Binary data is not supported')
  }

  // 处理 Array 类型
  if (Array.isArray(value)) {
    const clonedArray: any[] = []
    for (let i = 0; i < value.length; i++) {
      clonedArray[i] = deepClone(value[i])
    }
    return clonedArray as any
  }

  // 处理普通对象
  if (isObject(value)) {
    const clonedObj: Record<string | number | symbol, any> = {}
    for (const key in value) {
      if (Object.prototype.hasOwnProperty.call(value, key)) {
        clonedObj[key] = deepClone(value[key])
      }
    }
    return clonedObj as any
  }

  // 其他情况直接返回原值
  return value
}