基于 vue-hooks-plus 的 useRequest 方法学习如何插件化设计

818 阅读4分钟

前言

大家好,我是 Ray,一个前端开发者,同时也是 vue-hooks-plus 库的成员。今天,我想和大家分享一下如何基于该库的 useRequest 方法学习如何插件化设计。

什么是插件化

插件化,就是将一个功能模块化,然后通过插件的形式进行注入。然后通过插件的形式进行调用,使得在主功能不变的情况下,通过插件的形式进行功能扩展。并且插件化可以使得主功能更加简洁,更加容易维护。

useRequest 的基础设计

这是 useRequest 的基础设计,通过这个设计,我们可以看到,useRequest 的运行流程是:

graph TD
    A[开始] --> B[传入参数]
    B --> C1[service 方法]
    B --> C2[options 配置项]
    B --> C3[plugins 自定义插件]

    C2 --> D[合并配置项]
    C3 --> E[合并插件]

    D & E --> F[运行 run/runAsync 方法]
    F --> G[调用 service 方法]
    G --> H[运行插件系统]
    H --> I[结束]

代码实现

类型定义

// types.ts

/**
 * 异步服务函数类型
 * @template TData 返回数据类型
 * @template TParams 参数类型数组
 */
export type Service<TData, TParams extends any[] = any[]> = (
  ...args: TParams
) => Promise<TData>

/**
 * useRequest 返回值类型
 * @template TData 返回数据类型
 * @template TParams 参数类型数组
 */
export interface Result<TData, TParams extends any[] = any[]> {
  loading: Ref<boolean> // 加载状态
  data: Ref<TData | undefined> // 请求数据
  error: Ref<Error | undefined> // 错误信息
  params: Ref<TParams | []> // 当前请求参数
  run: (...params: TParams) => Promise<TData> // 触发请求方法
  runAsync: (...params: TParams) => Promise<TData> // 异步请求方法
  refresh: () => Promise<TData> // 使用当前参数重新请求
  cancel: () => void // 取消当前请求
}

/**
 * 配置选项类型
 * @template TData 返回数据类型
 * @template TParams 参数类型数组
 */
export interface Options<TData, TParams extends any[] = any[]> {
  manual?: boolean // 是否手动触发
  defaultParams?: TParams // 默认参数
  pollingInterval?: number // 轮询间隔(ms)
  cancelOnUnmount?: boolean // 组件卸载时是否取消请求
  onBefore?: (params: TParams) => void // 请求前回调
  onSuccess?: (data: TData, params: TParams) => void // 请求成功回调
  onError?: (error: Error, params: TParams) => void // 请求失败回调
  onFinally?: (params: TParams, data?: TData, error?: Error) => void // 请求完成回调
}

/**
 * 插件返回的状态类型
 */
export interface PluginState<TData, TParams extends any[] = any[]> {
  loading?: boolean
  data?: TData
  error?: Error
  params?: TParams
}

/**
 * 插件返回值类型
 * @template TData 返回数据类型
 * @template TParams 参数类型数组
 */
export interface PluginReturn<TData, TParams extends any[] = any[]> {
  onBefore?: (params: TParams) => void | PluginState<TData, TParams>
  onRequest?: (
    service: Service<TData, TParams>,
    params: TParams,
  ) => Promise<TData>
  onSuccess?: (data: TData, params: TParams) => void
  onError?: (error: Error, params: TParams) => void
  onFinally?: (params: TParams, data?: TData, error?: Error) => void
  onCancel?: () => void
}

/**
 * 插件类型
 * @template TData 返回数据类型
 * @template TParams 参数类型数组
 */
export type Plugin<TData, TParams extends any[] = any[]> = {
  (instance: Result<TData, TParams>, options: Options<TData, TParams>): Partial<
    PluginReturn<TData, TParams>
  >
}

方法实现

// useRequest.ts
import { ref, onUnmounted, type Ref } from 'vue'
import type {
  Service,
  Options,
  Plugin,
  Result,
  PluginReturn,
  PluginState,
} from './types'

/**
 * useRequest Hook
 * @template TData 返回数据类型
 * @template TParams 参数类型数组
 * @param service 异步服务函数
 * @param options 配置选项
 * @param plugins 插件数组
 * @returns Result 对象,包含状态和方法
 */
export function useRequest<TData, TParams extends any[] = any[]>(
  service: Service<TData, TParams>,
  options: Options<TData, TParams> = {},
  plugins: Plugin<TData, TParams>[] = [],
): Readonly<Result<TData, TParams>> {
  // 解构配置项,设置默认值
  const {
    manual = false,
    defaultParams = [] as unknown as TParams,
    cancelOnUnmount = true,
    pollingInterval,
    onBefore,
    onSuccess,
    onError,
    onFinally,
  } = options

  // 创建响应式状态
  const loading = ref<boolean>(false)
  const data = ref<TData | undefined>()
  const error = ref<Error | undefined>()
  const params = ref<TParams | []>(defaultParams)

  // 内部控制变量
  let count = 0
  let pollingTimer: NodeJS.Timeout | undefined

  /**
   * 初始化插件实例
   * 传入当前实例和配置项
   */
  const pluginImpls = plugins.map((p) =>
    p(
      {
        loading,
        data,
        error,
        params,
        run,
        runAsync,
        refresh,
        cancel,
      },
      options,
    ),
  )

  /**
   * 取消当前请求
   * 清除轮询定时器并触发插件的 onCancel 钩子
   */
  function cancel(): void {
    if (pollingTimer) {
      clearTimeout(pollingTimer)
      pollingTimer = undefined
    }
    count += 1
    pluginImpls.forEach((plugin) => plugin.onCancel?.())
  }

  /**
   * 使用当前参数重新发起请求
   * @returns Promise<TData>
   */
  async function refresh(): Promise<TData> {
    return run(...(params.value as TParams))
  }

  /**
   * 核心请求方法
   * @param args 请求参数
   * @returns Promise<TData>
   */
  async function run(...args: TParams): Promise<TData> {
    cancel()

    const currentCount = count
    params.value = args
    loading.value = true
    error.value = undefined

    try {
      // 执行 options 的 onBefore 回调
      onBefore?.(args)

      // 执行插件的 onBefore 钩子
      for (const plugin of pluginImpls) {
        if (plugin.onBefore) {
          const newState = plugin.onBefore(args)
          if (newState) {
            // 更新状态
            if ('loading' in newState) loading.value = newState.loading!
            if ('data' in newState) data.value = newState.data
            if ('error' in newState) error.value = newState.error
            if ('params' in newState) params.value = newState.params!
          }
        }
      }

      // 执行请求
      let serviceResult: TData

      // 通过插件链处理请求
      const servicePromise = (async () => {
        for (const plugin of pluginImpls) {
          if (plugin.onRequest) {
            return await plugin.onRequest(service, args)
          }
        }
        return await service(...args)
      })()

      serviceResult = await servicePromise

      // 如果不是当前请求,直接返回结果
      if (currentCount !== count) {
        return serviceResult
      }

      data.value = serviceResult

      // 执行成功回调
      onSuccess?.(serviceResult, args)
      pluginImpls.forEach((plugin) => plugin.onSuccess?.(serviceResult, args))

      // 处理轮询
      if (pollingInterval && pollingInterval > 0) {
        pollingTimer = setTimeout(() => {
          refresh()
        }, pollingInterval)
      }

      return serviceResult
    } catch (err: any) {
      // 如果不是当前请求的错误,继续抛出
      if (currentCount !== count) {
        throw err
      }

      // 确保错误类型
      const errorInstance = err instanceof Error ? err : new Error(String(err))
      error.value = errorInstance

      // 执行错误回调
      onError?.(errorInstance, args)
      pluginImpls.forEach((plugin) => plugin.onError?.(errorInstance, args))
      throw errorInstance
    } finally {
      // 只处理当前请求的 finally 逻辑
      if (currentCount === count) {
        loading.value = false
        onFinally?.(args, data.value, error.value)
        pluginImpls.forEach((plugin) =>
          plugin.onFinally?.(args, data.value, error.value),
        )
      }
    }
  }

  // runAsync 与 run 相同,用于语义化区分
  const runAsync = run

  // 组件卸载时的清理工作
  if (cancelOnUnmount) {
    onUnmounted(() => {
      cancel()
      count = 0
      data.value = undefined
      error.value = undefined
    })
  }

  // 非手动模式下,自动执行首次请求
  if (!manual) {
    run(...defaultParams)
  }

  // 返回只读的结果对象
  return {
    loading,
    data,
    error,
    params,
    run,
    runAsync,
    refresh,
    cancel,
  } as const
}

基础使用

我们基于简化后的 useRequest 方法来实战一下。

// 定义接口返回数据类型
interface UserInfo {
  id: number
  name: string
  email: string
}

// 缓存插件:实现数据的本地缓存
const cachePlugin: Plugin<UserInfo, [number]> = (instance, options) => ({
  name: 'cachePlugin',
  // 请求前检查缓存
  onBefore: (params) => {
    const cacheKey = `user-${params[0]}`
    const cached = localStorage.getItem(cacheKey)
    if (cached) {
      return {
        loading: false,
        data: JSON.parse(cached),
      }
    }
  },
  // 请求成功后更新缓存
  onSuccess: (data) => {
    const cacheKey = `user-${data.id}`
    localStorage.setItem(cacheKey, JSON.stringify(data))
  },
})

// 自动转为大写插件:自动将 name 转为大写
const responseNameToUpperCasePlugin: Plugin<UserInfo, [number]> = (
  instance,
  options,
) => ({
  name: 'responseNameToUpperCasePlugin',
  onSuccess: (data) => {
    // 将 name 转为大写
    data.name = data.name.toUpperCase()
  },
})

// 模拟请求返回
const mockFetch = (id: number): Promise<UserInfo> => {
  return new Promise<UserInfo>((resolve) => {
    setTimeout(() => {
      resolve({ id, name: 'John Doe' + ' ' + id, email: 'john@example.com' })
    }, 1000)
  })
}

let id = 1

// 使用插件
const { data, run } = useRequest(
  mockFetch,
  {
    pollingInterval: 3000,
    defaultParams: [id],
    onSuccess: (data) => {
      id++
    },
  },
  // 传入插件数组
  [cachePlugin, responseNameToUpperCasePlugin],
)

这个是 useRequest 的简化版,但是已经具备了插件化的能力。如果有兴趣查看完整版,可以点击查看详细的 useRequest 实现。

最后

感谢大家的阅读,谢谢。

如果感觉 vue-hooks-plus 能够帮助到你,欢迎给个 star 支持一下。如果有什么意见或者建议,可以提一个 issues,我们会及时回复与处理。