如何不用拦截器为 Axios 扩展功能

1,284 阅读3分钟

背景

在笔者日常工作中,均是以 axios 作为前端应用里请求后端接口数据的工具,它的优势本文不再叙述,这里对如何便于使用 axios 且如何让其功能可复制可扩展易移植进行探讨。

拦截器的问题

axios 的拦截器功能想必大家都不陌生,主要用于在请求和响应后对配置项或数据状态做一些额外处理,但本文并不会使用这个功能作为主题,但这里仍然要说明下原因。

  • 扩展不同功能之间高度耦合,不易拆分和维护
  • 扩展功能不易移植
  • 功能越多越不易扩展

功能示例

取消重复请求功能

export const http = axios.create({ baseURL: '/api' })

// 取消重复请求功能的通用做法

// 使用 map 记录接口数据
export const pendingServices = new Map()

/**
 * 生成接口标记
 *
 * @param {import('axios').AxiosRequestConfig} config 请求配置项
 */
function createMark(config = {}) {
  const { method = 'get', url = '', data = {}, params = {} } = config

  // 请求之后配置项会被转为字符串,为了保证请求与响应一致这里进行额外处理
  const processValue = (value) =>
    typeof value === 'string' ? safeJsonParse(value, value) : JSON.parse(JSON.stringify(value))

  // 序列化参数生成唯一标识
  return serialize({
    method,
    url,
    data: processValue(data),
    params: processValue(params)
  })
}

/**
 * 接口取消类型
 */
export const ServiceCancelTypeEnum = {
  /**
   * 超时取消
   */
  timeout: 'timeout',
  /**
   * 手动取消
   */
  manual: 'manual'
}

/**
 * 是否手动取消请求
 *
 * @param {string} cancelType 接口请求取消类型
 *
 * @returns {boolean}
 */
export function isManualCancel(cancelType) {
  return cancelType === ServiceCancelTypeEnum.manual
}

/**
 * 添加接口请求
 *
 * @description 以节流思维避免接口重复请求。
 *
 * @param {import('axios').AxiosRequestConfig} config 接口配置项
 *
 * @example
 * ```js
 * http.get('/api') // A
 * http.get('/api') // B
 *
 * // B 被取消
 * // pendingServices 只存在 A 的信息
 * ```
 */
function addPendingService(config = {}) {
  // 如果允许请求重复或已经携带了 cancelToken 则直接返回
  if (!config.$cancelRepeatRequest || !!config.cancelToken) return

  // 生成接口标识
  const mark = createMark(config)

  // 判断是否已经存在当前标识
  if (pendingServices.has(mark)) {
    // 请求重复时取消本次调用
    config.cancelToken = new Axios.CancelToken((cancel) => {
      // 记录当前接口取消类型
      config.$cancelType = ServiceCancelTypeEnum.manual
      // 记录的当前接口是由于重复请求而取消
      config.$isRepeatCancel = true

      // cancel 函数传入的数据将被作为响应失败后的 error.message
      cancel(config)
    })
  } else {
    // 由于 CancelToken 回调为异步,所以这里先留存记录
    pendingServices.set(mark, { mark, cancel: null, config })
    config.cancelToken = new Axios.CancelToken((cancel) => {
      pendingServices.get(mark).cancel = cancel
    })
  }
}

/**
 * 移除 pendingServices 中的接口请求
 *
 * @param {import('axios').AxiosRequestConfig} config 接口配置项
 */
function removePendingService(config = {}) {
  // 如果是重复请求则直接返回
  if (config.$isRepeatCancel) return

  const mark = createMark(config)

  if (!pendingServices.has(mark)) return null

  const service = pendingServices.get(mark)

  pendingServices.delete(mark)

  return service
}

/**
 * 错误响应时获取对应的请求配置项
 *
 * @param {any} error 错误数据
 *
 * @returns {import('axios').AxiosRequestConfig}
 */
function getErrorConfig(error) {
  if (error.config) return error.config

  return typeof error.message === 'object' ? error.message : null
}

// 请求拦截
http.interceptors.request.use((config) => {
  // 取消重复请求,默认 true
  config.$cancelRepeatRequest ??= true
  // 取消类型,用于区分重复取消和超时取消
  config.$cancelType = undefined
  // 是否为重复取消的请求
  config.$isRepeatCancel = false
  
  // 添加接口记录
  addPendingService(config)

  return config
})

// 响应拦截
http.interceptors.response.use(
  (response) => {
    // 移除接口记录
    removePendingService(response.config)

    return response.data
  },
  (error) => {
    // 获取错误信息里的请求配置项
    const config = getErrorConfig(error)
    // 判断是否为取消请求,接口超时时 isCancel 为 false ,所以需要判断 message
    const isCancel = Axios.isCancel(error) || error.message?.indexOf('timeout') > -1
    // 获取取消类型
    const cancelType = isCancel ? getCancelType(config) : ''

    // 根据配置项移除请求记录
    removePendingService(config)
    
    if (isManualCancel(cancelType)) {
      // 这里只简单的为重复取消兜底,若存在响应包装可自行处理
      return Promise.resolve({ data: { isManualCancel: true } })
    }
    
    // 往上抛出错误
    return Promise.reject(new Error(error))
  }
)

vue 中使用示例:

export default {
  methods: {
    async loadData() {
      try {
        // 默认取消重复请求
        const response = http.get('/demo/list')
        
        // 如果是重复取消或组件已被卸载则终止后续操作
        if (response.data.isManualCancel || this._isDestroyed) return
        
        // do somethings...
      } catch(err) {
        console.log(err.message)
      }
    }
  }
}

以上是对接口重复请求的封装,属于较为常见功能,但是我们仍然需要完成整个请求流程,从请求到响应结束均会执行拦截器代码,无法在判断重复时直接终止请求操作。

这样的情况会导致我们在拦截器扩展的其他功能时,需要在重复请求取消时添加一些额外逻辑以保证流程可以正常执行,但如此反而增加了不同功能之间的耦合度。

问题探讨

上述的问题也许有朋友就要说:“我可以把不同功能的代码拆分到不同文件里,仍然很好维护啊。”

这一方案笔者也尝试过,但是不同的功能所执行的时机是不同的,而且由于项目中为了避免 try...catch 逻辑,通常会将接口响应二次包装,通过不同的数据结构以体现成功和失败,让代码看起来更顺畅且更健壮(遗漏了错误兜底),这样的好处显而易见,但是对我们需要扩展其他功能来说需要考虑的点也会更多,所以这个方案仅仅能优化 http 文件里的代码不至于太过冗余。

其次就是不同的项目可能需要不同的功能,但就算耦合度较低的代码迁移起来仍然会涉及到源码的更改,所以拦截器方案仍然无法普及适用。

解决思路

笔者曾反复思考后,认为问题既是由于拦截器引起,那么还是得从拦截器方面入手,axios 自带的拦截器既然无法满足需要,那么是否可以通过模拟拦截器功能从而实现我们的需求呢?

方案落地

经过探索后,结合 vuehooks 使用经历,开发了一个插件 axios-ext,通过注册不同插件进而为 axios 实现不同功能。

使用示例

import { createAxios } from '@iel/axios-ext'
import AxiosExtCancelRepeat from '@iel/axios-ext-cancel-repeat'
import axios from 'axios'

// 接收 axios 配置项或实例并返回包装后的 axios 实例
// http.$axiosExt 为 AxiosExt 实例
const http = createAxios(axios)

// 注册该插件,默认会执行插件方法体内部函数
// 返回该实例,已注册插件不会被重复注册
http.$axiosExt
  // 注册取消重复请求插件
  .use(AxiosExtCancelRepeat, {
    manualCancelMessage: '手动取消接口',
    onRepeat: () => [true, '取消重复接口']
  })
  // 注册响应包装插件
  .use(AxiosExtResponseWrap, {
    wrapper: AxiosResponseTupleWrapper([ErrorAdaptor, SuccessAdaptor])
  })

// 销毁实例,在插件销毁时处理一些事情并清理所有插件信息
// http.$axiosExt.destroy()

示例截图

const loadData = async () => {
  const response = await http.get('/demo/list')
  console.log(response) // response 为下图中的 returnValue
}

image.png

测试页面

可在该页面进行测试!

系列插件

预设插件

解决繁琐的配置项和包安装步骤。

结尾

以上是笔者目前对 axios 扩展功能的想法及解决方案,也许受限于能力可能略有不足还请各位看官谅解,各位道友若有问题可在评论区留言!