likes
comments
collection
share

重新定义 Axios 的使用方式

作者站长头像
站长
· 阅读数 39

重新定义 Axios 的使用方式

前言

创建示例项目

作为一名 Vuer 当然是 vite + vue3 了😁。

# 可自行选择初始化方式
pnpm create vite test-app --template vue-ts

安装依赖

# 进入项目根目录并且安装依赖
cd test-app && pnpm i

# 安装本次示例依赖
pnpm add axios @rhao/request @rhao/request-middleware-vue @rhao/request-middleware-axios @rhao/request-basic-middleware

依赖说明

使用方式

创建 axios 实例

// src/utils/axios.ts
import Axios from 'axios'

export const axios = Axios.create({
  // ...
})

创建 useRequest

// src/hooks/useRequest.ts
import { createRequest } from '@rhao/request'
import { RequestVue } from '@rhao/request-middleware-vue'
import { RequestAxios } from '@rhao/request-middleware-axios'
import { axios } from '@/utils/axios'

// 导入需要注册的基础中间件
import { RequestSWR } from '@rhao/request-basic-middleware/swr'

export const useRequest = createRequest({
  // 注册全局中间件
  middleware: [
    // 适配 Vue
    RequestVue(),
    // 搭建 Axios 与 useRequest 的桥梁,需要传入默认的 axios 实例
    RequestAxios({ axios }),
    // 注册 SWR 功能
    RequestSWR(),
  ]
})

解析数据格式

上述仅创建了初始的 useRequest,接受一个 fetcher 进行管理,可以自动解析其入参和出参,但是往往我们会和后端约定一种数据格式,所以可以全局设置 dataParser 对执行请求后的数据进行拆包。

// src/hooks/useRequest.ts
// ...

import type {
  BasicRequest,
  RequestFetcher,
  RequestOptions,
  RequestResult,
} from '@rhao/request-middleware-vue'
import type { AxiosResponse } from 'axios'

// 扩展调用签名,由于本次基于 axios 作为请求器,所以先对其返回类型进行拆包
interface UseRequest extends BasicRequest {
  <TData, TParams extends unknown[] = unknown[]>(
    fetcher: RequestFetcher<TData, TParams>,
    options?: RequestOptions<TData, TParams>,
  ): RequestResult<TData extends AxiosResponse<infer D> ? D : TData, TParams>
}

// 与后端约定的数据格式
interface BackendDataFormat {
  success: boolean
  data: any
  message?: string
}

export const useRequest = createRequest({
  // ...
  
  // 对数据进行拆包和解析,错误消息可直接抛出异常,便于被内部执行器捕获然后判定为失败
  dataParser: (data: AxiosResponse) => {
    // 响应状态码不是 200 时直接抛出异常信息,可自定义
    if (data.status !== 200) throw new Error(data.statusText)
    
    // 后端格式数据
    const realData: BackendDataFormat = data.data
    
    // 后端返回状态标识未成功,则抛出错误消息
    if (!realData.success) throw new Error(realData.message)
    
    // 返回解析后的数据
    return realData.data
  }
})

管理接口

为了方便接口调用的管理,我们在 src 目录下创建 apis 目录存储所有接口请求。

// src/apis/example.ts
import { axios } from '@/utils/axios'

// 接口入参
export interface Params {
  a: number
  b: string
}

// 接口数据单项
export interface DataItem {
  a: number
  b: string
}

// 导出查询列表接口
// axios 调用时需填入响应数据类型用于 useRequest 解析
export const queryList = (params: Params) => axios.get<DataItem[]>('/api/example/list', { params })

页面使用

import { useRequest } from '@/hooks/useRequest'
import { queryList } from '@/apis/example'
import { axios as axios1 } from '@/utils/axios'

// data => Ref<DataItem[] | null> // 这里会自动对 AxiosResponse 进行拆包
// params => Ref<[Params]> // 会自动推导出 queryList 的入参
// loading => Ref<boolean> // loading 仅在初次和最后一次调用时允许被更改,即多次并发调用仅触发两次更新
// error => Ref<Error | null> // 执行失败时的错误信息
const { data, loading, error, params, run } = useRequest(queryList, {
  // 是否手动调用,默认自动调用
  manual: true,

  // 初次自动调用时的参数,设置 `manual` 为 `false` 时有效
  // 类型会自动推导出 Params
  defaultParams: [],

  // 若 queryList 使用 axios1,则此处可更改执行时桥接的 axios 实例
  axios: axios1,

  // 这里可以传入 axios 的配置项,支持函数格式
  axiosConfig: {
    timeout: 10e3
  },
  
  // 默认 useRequest 的 key 呈自增形式,安装 swr 中间件后可通过相同的 key 共享数据而不会触发重复请求
  key: 'shared-key'
})

// 所有的错误均通过 error 获取,run 只会返回执行后的数据
run({ a: 1, b: '123' })

开发中间件

createRequest 负责创建用于管理整个请求流程的 hook 函数,通过注册不同中间件来满足各种场景下的使用,支持函数和对象两种形式。

通过 hooks 事件开发和 middleware 开发的区别:

  • hook:before、after 回调顺序执行(先注册先执行),不符合整个请求流,且不支持中断后续回调
  • middleware:before、after 成对顺序执行(参考洋葱圈模型),符合整个请求流,支持中间件自主中断后续执行

中间件可通过传入的 contextoptionsresult 进行扩展,来满足不同场景下的功能需求。

防抖中间件

/* eslint-disable unused-imports/no-unused-vars */
import type { RequestMiddleware } from '@rhao/request'
// 辅助工具包,可选其他工具
import { assign, isUndef, mapValues, pick, toValue } from '@rhao/request-utils'
// 直接使用成熟的 lodash.debounce
import { debounce } from 'lodash-es'
// 辅助类型包,可选
import type { MaybeGetter } from '@rhao/request-types'

// 定义接受的配置项
export interface RequestDebounceOptions {
  /**
   * 防抖等待时间,单位:ms,设置后开启防抖模式
   */
  wait?: number
  /**
   * 延迟时最大等待时间
   */
  maxWait?: MaybeGetter<number>
  /**
   * 在延迟开始前执行调用
   * @default false
   */
  leading?: MaybeGetter<boolean>
  /**
   * 在延迟结束后执行调用
   * @default true
   */
  trailing?: MaybeGetter<boolean>
}

// 导出防抖中间件,支持传入一定的默认配置项
export function RequestDebounce(initialOptions?: RequestDebounceOptions) {
  // 中间件对象
  const middleware: RequestMiddleware = {
    // 中间件优先级,视情况而定,数值越大越先执行,也就可以获取到最原始的 fetcher、executor、result
    // 存在相同类型扩展时由优先级和注册顺序决定谁先扩展
    // 防抖、节流主要针对 executor,且与直接执行 executor 相关,所以优先级最低,这里给了 -1000,基本够用
    priority: -1000,
    
    // 每调用一次 useRequest 就会执行 setup 进行中间件初始化
    // 可通过传入的 context 对 options、result、executor、fetcher 等进行扩展
    setup: (ctx) => {
      const options = assign(
        // 默认的配置项
        { leading: false, trailing: true } as RequestDebounceOptions,
        
        // 注册中间件时传入的配置项
        initialOptions,
        
        // 单次调用 useRequest 时传入的配置项
        ctx.getOptions().debounce,
      )

      // 如果 wait 定义了则改造其 executor
      if (!isUndef(options.wait)) {
        // opts.maxWait 不能显示设置为空,这是 lodash.debounce 内部的处理导致
        const opts = mapValues(pick(options, ['maxWait', 'leading', 'trailing']), (v) => toValue(v))
        
        // 获取防抖后的 executor
        const debouncedExecutor = debounce(ctx.executor, options.wait, opts)

        // 注册取消事件,取消时也取消防抖后的调用
        ctx.hooks.hook('cancel', () => debouncedExecutor.cancel())
        
        // 修改原 executor,需返回 Promise
        ctx.executor = (...args) => Promise.resolve(debouncedExecutor(...args))
      }
    },
  }
  
  // 返回中间件
  return middleware
}

// 扩展 request 类型
declare module '@rhao/request' {
  // 为 options 入自定义的配置项提供类型
  interface RequestCustomOptions<TData, TParams extends unknown[] = unknown[]> {
    debounce?: RequestDebounceOptions
  }
}

刷新 Token

前端开发时,很多时候会用到 token 进行接口的安全调用,但 token 本身存在过期行为,此时由业务场景决定是直接跳转登录还是在一定时间内允许其刷新 token,此次来看看如何开发一个刷新 token 的中间件。

/* eslint-disable unused-imports/no-unused-vars */
import { MiddlewareHelper } from '@rhao/request'
import type { MiddlewareStoreKey, RequestContext, RequestMiddleware } from '@rhao/request'
import { assign, ensureError, pick, toValue } from '@rhao/request-utils'
import type { AwaitableFn, PromiseFn } from '@rhao/request-types'

// 定义需要接收的配置项
export interface RequestRefreshTokenOptions {
  /**
   * 本次过期是否允许刷新令牌,默认均可刷新,也可通过传入的 key 和上下文对象判定当前是否需要刷新
   * @default
   * ```ts
   * () => true
   * ```
   */
  allow?: AwaitableFn<[key: string, context: RequestContext<any, any[]>], boolean>
  /**
   * 验证刷新令牌是否过期,根据错误信息来验证
   */
  expired: AwaitableFn<[error: Error], boolean>
  /**
   * 允许刷新令牌时的具体刷新操作
   */
  handler: PromiseFn<[context: RequestContext<any, any[]>], void>
}

// 全局定义一个 store 来存储正在刷新的 promise
interface RefreshTokenGlobalStore {
  initialed: boolean

  // 若存在 promise 则意味着正在刷新 token,后续的请求需要等待 token 刷新后再真正执行
  // 若不存在则意味着 token 在有效期内
  refreshPromise: Promise<any> | null
}

// 全局 store key,参考了 vue InjectionKey
const globalStoreKey: MiddlewareStoreKey<RefreshTokenGlobalStore> = Symbol('refreshToken')
const { init: initGlobalStore, get: getGlobalStore } =
  MiddlewareHelper.createGlobalStore(globalStoreKey)

// 初始化函数,保证仅初始化一次
function init() {
  if (getGlobalStore()?.initialed) return
  initGlobalStore({
    initialed: true,
    refreshPromise: null,
  })
}

// 贯通 setup 和 handler 的 store,用来存储一些数据
interface RefreshTokenStore {
  options: RequestRefreshTokenOptions
}

const storeKey: MiddlewareStoreKey<RefreshTokenStore> = Symbol('refreshToken')

export function RequestRefreshToken(options: RequestRefreshTokenOptions) {
  // 注册时初始化,推荐全局注册 RefreshToken 插件
  init()

  // 定义中间件对象
  const middleware: RequestMiddleware = {
    // 定义优先级,这里因为要修改 fetcher,所以需要比同样修改 fetcher 的优先级要高,例如 RequestRetry
    priority: 1000,
    
    // useRequest 时调用,初始化配置项,当然这里也可以全部在 handler 内部实现
    setup: (ctx) => {
      MiddlewareHelper.initStore(storeKey, ctx, {
        options: assign(
          { allow: () => true } as Partial<RequestRefreshTokenOptions>,
          options,
          pick(ctx.getOptions().refreshToken || {}, ['allow']),
        ),
      })
    },
    handler: (ctx, next) => {
      const globalStore = getGlobalStore()!
      const { options } = MiddlewareHelper.getStore(storeKey, ctx)!
      const { fetcher, getKey, getOptions } = ctx

      // 修改 fetcher 执行方式
      ctx.fetcher = async (...args) => {
        // 正常执行
        if (globalStore.refreshPromise == null || !toValue(options.allow, getKey(), ctx))
          return fetcher(...args)

        // 如果存在正在刷新 token 的请求则等待其结束后再调用
        if (globalStore.refreshPromise)
          return Promise.resolve(globalStore.refreshPromise).then(() => fetcher(...args))
        
        try {
          // 正常调用 fetcher,之后再通过 dataParser 解析数据,验证请求是否失败
          const data = await fetcher(...args)
          await getOptions().dataParser(data)
        } catch (err: unknown) {
          // 请求失败后验证根据当前的错误验证是否是 token 过期导致的
          const error = ensureError(err)
          const isExpired = await options.expired(error)
          // 如果不是过期导致则返回异常错误
          if (!isExpired) return Promise.reject(error)

          // 如果是过期导致的则调用注册时传入的 handler 进行处理
          if (!globalStore.refreshPromise) globalStore.refreshPromise = options.handler(ctx)

          return Promise.resolve(globalStore.refreshPromise)
            // 刷新成功后重新执行请求
            // 新的请求出错则抛出新请求的错误
            .then(() => Promise.resolve(fetcher(...args)).catch((e) => Promise.reject(e)))
            // 异常时返回初次执行请求时的错误
            .catch(() => Promise.reject(error))
            .finally(() => {
              // 结束后清空 promise
              globalStore.refreshPromise = null
            })
        }
      }

      // 调用下一个中间件
      return next()
    },
  }

  return middleware
}

// 扩展 request 类型
declare module '@rhao/request' {
  // 为 options 自定义配置项提供类型
  interface RequestCustomOptions<TData, TParams extends unknown[] = unknown[]> {
    refreshToken?: Pick<RequestRefreshTokenOptions, 'allow'>
  }
}

最后

上述则是对 @rhao/request 的基础使用方式了,目前还比较粗糙,若大家还有什么想法或者意见的话可以评论区留言,也可以参与共建😁。

重新定义 Axios 的使用方式

相关链接

转载自:https://juejin.cn/post/7265967979337613369
评论
请登录