重新定义 Axios 的使用方式

重新定义 Axios 的使用方式

前言

✨ 某次刷到了VueUse或许不是唯一选择,试试这匹黑马 VueHooks Plus 🎉文章,内部推荐的 useRequest 让作为一名 Vuer 的我眼前一亮,对上眼了👁️👁️。但是在后续的项目开发中,由于其基于插件方式进行扩展,不太符合完整的请求流,而且不适配 Vue2,之后借鉴了 SWRKoa 开发出一个基于中间件的请求状态管理库,与 VueReact 无关,但可通过中间件进行集成。

创建示例项目

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

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

安装依赖

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

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

依赖说明

使用方式

创建 axios 实例

ts 复制代码
// src/utils/axios.ts
import Axios from 'axios'

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

创建 useRequest

ts 复制代码
// 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 对执行请求后的数据进行拆包。

ts 复制代码
// 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 目录存储所有接口请求。

ts 复制代码
// 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 })

页面使用

ts 复制代码
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 进行扩展,来满足不同场景下的功能需求。

防抖中间件

ts 复制代码
/* 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 的中间件。

ts 复制代码
/* 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 的基础使用方式了,目前还比较粗糙,若大家还有什么想法或者意见的话可以评论区留言,也可以参与共建😁。

相关链接

相关推荐
mldong2 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排2 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60613 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角4 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!4 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰4 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi6 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen6 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马7 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒7 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端