>
post-detail

【Vue3】超便捷的基于 Axios 的接口请求工具包

「技术」

13 分钟

23 阅读

0

2026-06-23

1 前言

在一个 Vue + Typescript + Axios 的项目中,如果要写一个接口请求工具,大概是要这几个步骤:

  • 写一个 Axios 实例配置,配置好 baseUrl、请求超时时间、请求拦截器、响应拦截器等等。
  • 写一个文件,这个文件专门导出一个模块的请求函数。请求函数的内容是,接收请求参数,然后配置好请求方法、请求路径、请求头等信息,然后返回这个 Axios 请求结果。
  • 为了有好的代码提示,还需要写一个文件,这个文件专门导出接口的响应数据类型,或者请求数据类型。

这只是一个基础的请求工具。

在此之后,你逐渐发现一个问题,当项目中有很多地方,需要使用 Loading 效果时,你就得为需要的接口,在相应的页面组件,都定义一个 Loading 的 ref,然后在请求发起前后,都编写 Loading 的触发逻辑。

如果某个接口,在多个页面都有使用,并且都需要 Loading 效果时,你就得在相应的页面组件,都编写同样的 Loading 逻辑。

随着代码的日渐堆积,你发现这样下去不是办法。然后,你编写了一个增强工具,这个工具专门提供请求的调用和独立的 Loading 维护。

你将相关的接口迁移到了这个增强工具,代码量大大减少。

然而,还有另一个问题,那就是分页请求。

对于一个分页请求,你需要维护相应的 Current、Size分页参数,还需要维护响应的 Total、Pages 等等参数。并且,你还需要为分页请求,编写相应的下一页、上一页、跳转页等等逻辑。

此外,对于不同的分页请求触发方式,例如分页组件分页、下拉刷新等等,你都需要编写不同的分页结果构建逻辑。

如果你有多个页面都需要用到分页请求,那这些分页请求逻辑的实现会逐渐让你感到头大。

于是,借助着你之前实现的请求工具,你花费了整整一天时间,完成了一个通用的分页请求工具,极大减少了代码量。

你以为这样就结束了,对于一个仅有 CURD 功能的前端项目,在这套组合拳下来,对请求的处理已经非常完美了。

但是,当你要添加一个新的模块,新增多个新模块的接口时,麻烦来了。

首先,你需要在相应的模块,创建一个专门编写基础请求方法的文件,然后,你需要重复的编写下面的代码:

function xxxRequest(xx: xx, xx: xx): xx {
	return httpClient({ url, method, headers })
}

然后,为了提供类型提示,你还需要再定义接口类型的包中,创建一个专门的文件,将各个接口的响应类型定义出来,并导出。

再然后,为了提供 Loading 效果,或者是为了提供分页请求,你还需要使用你编写的请求工具,将相应的基础请求方法,用工具包装,做成 Hook。

为了减少页面代码逻辑,你可能还需要将包装 Hook 的代码,再次写在一个专门的文件中。

这时候,你就可以去使用你的请求了。但是,代价是什么?

代价就是,你点开定义 Api 接口的包有,发现了三个包,分别是定义基础请求接口的包、定义接口类型的包、定义请求 Hook 的包。

这三个包有相同的文件数量,因为三者的文件是一一对应关系。这意味着,你要编写一个接口,需要连续在三个文件下定义。

此外,当你点开基础请求工具的定义文件,发现一堆密密麻麻的 function xxxRequest(xx: xx, xx: xx): xx ,难以从中找到对应的接口。

于是,你开始思考,有什么方法,可以在一个文件就能够定义出我想要的请求工具,并且定义过程非常便捷,定义出来的内容也简洁易懂呢?

这就是本文要解决的问题。

本文将会分享我在做前端项目中,做出来的一套使用起来很便捷的的请求工具包。这套工具包包含了自带独立 Loading 管理的请求工具,维护各个分页参数和结果、提供各种便捷分页操作的分页工具,以及一个便捷的接口定义工具。

在定义接口时,只需要写这样的代码:

export const useArticleApi = defineApiHook('/article', ({ get }) => ({
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    path: '/list',
    paramIn: 'query',
    loadingKeep: 1000,
    initialSize: 12,
    initialCurrent: 1,
    mergeStrategy: 'replace',
  }),
  getArticleDetail: get.core<{ articleId: number }, GetArticleDetailResponse>({
    path: '/detail',
    paramIn: 'query',
    loadingKeep: 500,
  }),
}))

构建请求工具和请求 Hook 的逻辑,完全由工具完成,你只需写上面的代码,就可以在带类型提示的情况下,直接使用。

Loading 管理、分页参数管理等等,都不需要自己编写管理逻辑,工具都能独立提供。

当然,类型还是需要自己定义的。

此外,我还叠个甲。这个工具只是我自己从项目中一步步编写完善的,很多都只适配我自己的项目,对于其他项目,可能或多或少都有不兼容的地方,希望大家谅解。我分享出来,只是为大家提供一个思路和参考。

接下来,就开始说说我的实现吧。

2 核心请求工具

这个核心请求工具,是基于 Axios 的一个请求工具,主要提供 Loading 独立管理。此外,我还实现了一个,我从 vueuse (还是 Vue Request 包来着?)中了解到的,叫 LoadingKeep 的功能。

LoadingKeep,顾名思义,持续 Loading,其实叫做 minLoadingTime 更加合适。假如我的 LoadingKeep 设置为 500 ms,如果我的实际请求时间,小于 500 ms,那就先接收结果,延迟到 500 ms 后,再返回;如果大于 500 ms,那等到接收响应后,直接返回结果。

这个 LoadingKeep 效果主要是用于防止 Loading UI 组件闪烁的。假如我的实际请求实际只有 20 ms,那前端的 Loading 组件就会瞬间出现又消失。

如果你的 Loading 组件有出现和消失的过渡动画,那你会发现,在 Loading 组件逐渐出现的时候,数据已经展示出来了,Loading 组件像是走个过场硬控用户。

2.1 基础配置与类型定义

既然是基于 Axios 的工具,那当然得先配置好 Axios 了。我这里使用非常简单通用的配置,文件为 httpClient.ts:

import axios from 'axios'

const baseURL: string = '/api'

const instance = axios.create({
  baseURL,
  timeout: 20000,
})

instance.interceptors.request.use(
  (config) => {
    return config
  },
  (err) => Promise.reject(err),
)

instance.interceptors.response.use(
  (res) => {
    return res
  },
  (err) => {
    return Promise.reject(err)
  },
)

export default instance
export { baseURL }

接下来就是类型定义, 在包中,创建一个 types 包,然后创建一个 coreHook.ts 文件。

在编写类型之前,我们先考虑需要什么类型。首先,这个请求工具是一个对基础的 Axios 请求的一个再封装。这意味着,这个请求工具需要接收原请求方法,然后返回 Loading 和 增强的请求执行方法给用户调用。这样,这里就需要定义两个类型了,一个是接收的请求方法类型,一个是返回的 Hook 类型。

此外,在接收响应的时候,我们需要一个统一的响应格式,就是经典的 code 、message、data 组合。

但是,我这里有个要求,因为我希望给用户使用的前端,不要完整给出错误信息。例如在我的博客网站中,我把大多数的请求异常,统一为了服务器异常,因为博客本身没有多少交互功能,几乎都是查询接口,所以我不希望将返回的错误日志都完整打印,让用户疑惑。

所以,我还需要定义一个,接收原始响应的响应数据类型,以及一个在原始响应做出转换后的的响应数据类型。

这四个类型如下:

import type { AxiosResponse } from 'axios'
import type { Ref } from 'vue'

/** 服务端原始响应(Axios 的 data 字段)类型 */
export interface ApiRawResponse<R = unknown> {
  code: number
  msg: string
  data: R
}

/** 统一后的 API 响应格式类型 */
export interface ApiResponse<R = unknown> {
  code: number
  message: string
  data: R | null
}

/** Axios 基础请求函数类型 */
export type ApiRawCall<P = unknown, R = unknown> = (
  params?: P,
) => Promise<AxiosResponse<ApiRawResponse<R>>>

/** 核心请求 Hook 类型 */
export interface ApiCoreHook<P = unknown, R = unknown> {
  /** 加载状态 */
  loading: Ref<boolean>
  /** 执行请求 */
  execute: (params?: P) => Promise<ApiResponse<R>>
}

2.2 核心请求工具编写

这个请求工具的核心,就是提供独立的 Loading 状态管理,以及一个带 LoadingKeep 效果的执行方法。

对于这个请求工具,前面说了,需要接收基础的请求方法,返回 Hook。此外,还需要接收指定的 LoadingKeep 时间。

Loading 状态管理很简单,就是定义一个 Ref<boolean>,在请求开始前设为 true,在请求结束后设为 false。

先创建一个 useRequest.ts 文件,实现基础的代码:

export function useRequest<P = unknown, R = unknown>(
  apiFn: ApiRawCall<P, R>,
  loadingKeep: number = 0,
): ApiCoreHook<P, R> {
  // 定义 loading 状态
  const loading = ref(false)

  // 定义执行方法
  const execute = async (params?: P): Promise<ApiResponse<R>> => {
    // 开始执行
    try {
      // 执行方法
      loading.value = true
      const res = await apiFn(params)
      // 接收结果
      result = res.data
    } catch (err) {
      // 处理异常
      console.error('Request Error: ', err)
      return {
        code: 500,
        message: '服务器异常',
        data: null,
      }
    } finally {
	  // 结束 Loading
	  loading.value = false
    }

    return {
      code: result.code,
      message: result.msg,
      data: result.data,
    }
  }

  return {
    loading,
    execute,
  }
}

而 LoadingKeep 效果实现也很简单。在请求开始前,记录开始时间,然后在请求结束后,再记录结束时间。利用开始时间和结束时间,计算请求持续时间,并且判断这个持续时间是否大于指定的 LoadingKeep 时间。如果大于,就返回响应;如果小于,就启动一个延迟方法,延迟到指定的 LoadingKeep 时间后再返回响应;

我们需要改造 execute 方法:

export function useRequest<P = unknown, R = unknown>(
  apiFn: ApiRawCall<P, R>,
  loadingKeep: number = 0,
): ApiCoreHook<P, R> {
  // 定义 loading 状态
  const loading = ref(false)
  // 定义延迟器
  let timer: ReturnType<typeof setTimeout> | null = null

  // 定义执行方法
  const execute = async (params?: P): Promise<ApiResponse<R>> => {
    // 记录开始时间
    const startTime: number = Date.now()
  
    // 开始执行
    try {
      // 执行方法
      loading.value = true
      const res = await apiFn(params)
      // 接收结果
      result = res.data
    } catch (err) {
      // 处理异常
      console.error('Request Error: ', err)
      return {
        code: 500,
        message: '服务器异常',
        data: null,
      }
    } finally {
      // 记录最终时间
      const endTime: number = Date.now()
      // 计算实际请求实际
      const duration: number = endTime - startTime

      // 判断请求时间是否大于 loadingKeep
      if (duration >= loadingKeep) {
        // 大于,放行
        if (timer) {
          clearTimeout(timer)
        }
        loading.value = false
      } else {
        // 小于,延迟剩余时间后放行
        await new Promise<void>((resolve) => {
          timer = setTimeout(() => {
            loading.value = false
            resolve()
            if (timer) {
              clearTimeout(timer)
            }
          }, loadingKeep - duration)
        })
      }
    }

    return {
      code: result.code,
      message: result.msg,
      data: result.data,
    }
  }

  return {
    loading,
    execute,
  }
}

接下来,我们还需要统一响应数据,就是利用 AxiosError 这个类型了,判断它的 code,来区分是超时请求还是服务器异常请求,改造一下 catch 部分:

import type {
  ApiCoreHook,
  ApiRawCall,
  ApiRawResponse,
  ApiResponse,
} from '@/utils/requests/types/coreHook'
import { AxiosError } from 'axios'
import { ref } from 'vue'

export function useRequest<P = unknown, R = unknown>(
  apiFn: ApiRawCall<P, R>,
  loadingKeep: number = 0,
): ApiCoreHook<P, R> {
  // 定义 loading 状态
  const loading = ref(false)
  // 定义延迟器
  let timer: ReturnType<typeof setTimeout> | null = null

  // 定义执行方法
  const execute = async (params?: P): Promise<ApiResponse<R>> => {
    // 记录开始时间
    const startTime: number = Date.now()
  
    // 开始执行
    try {
      // 执行方法
      loading.value = true
      const res = await apiFn(params)
      // 接收结果
      result = res.data
    } catch (err) {
      // 处理异常
      console.error('Request Error: ', err)
      if (err instanceof AxiosError) {
        switch (err.code) {
          case 'ECONNABORTED':
            return {
              code: 408,
              message: '请求超时...',
              data: null as R,
            }
          default:
            return {
              code: 500,
              message: '服务器异常...',
              data: null as R,
            }
        }
      }
      return {
        code: 500,
        message: '服务器异常',
        data: null,
      }
    } finally {
      // 记录最终时间
      const endTime: number = Date.now()
      // 计算实际请求实际
      const duration: number = endTime - startTime

      // 判断请求时间是否大于 loadingKeep
      if (duration >= loadingKeep) {
        // 大于,放行
        if (timer) {
          clearTimeout(timer)
        }
        loading.value = false
      } else {
        // 小于,延迟剩余时间后放行
        await new Promise<void>((resolve) => {
          timer = setTimeout(() => {
            loading.value = false
            resolve()
            if (timer) {
              clearTimeout(timer)
            }
          }, loadingKeep - duration)
        })
      }
    }

    return {
      code: result.code,
      message: result.msg,
      data: result.data,
    }
  }

  return {
    loading,
    execute,
  }
}

这样就搞定了。

3 分页请求工具

3.1 类型定义

分页请求,请求参数和响应参数都是很常见的,我最初是以 Mybatis Plus 的分页请求参数为准的,在 types 包下创建一个文件 paginationHook.ts,类型如下:

/** 分页请求参数类型 */
export interface ApiPageQuery {
  current: number
  size: number
}

/** 分页请求响应类型 */
export interface ApiPageResponse<T = unknown> {
  records: T[]
  total: number
  pages: number
  current: number
  size: number
}

当然,只有这些还不行。要知道,我们的分页请求工具主要解决三个问题,一个是独立维护分页参数,一个是提供便捷的分页请求,第三是支持不同的分页数据构建方式。

独立维护分页参数,意味着我们的分页参数必须都是响应式的,方便外部调用;提供便捷的分页请求,说明我们最终要返回一个包含各种分页操作的 Hook;支持不同的分页数据构建方式,说明我们需要用户指定分页策略。类型如下:

/** 分页数据合并策略类型 */
export type ApiPageMergeStrategy = 'replace' | 'append' | 'prepend'

/** 分页请求状态类型 */
export interface ApiPagiationState<R> {
  current: Ref<number>
  size: Ref<number>
  total: Ref<number>
  pages: Ref<number>
  records: Ref<R[]>
  totalRecords: Ref<R[]>
  loading: Ref<boolean>
  hasMore: Ref<boolean>
}

/** 分页请求 Hook 类型 */
export interface ApiPagnHook<P extends ApiPageQuery, R> {
  /** 当前分页状态 */
  state: ApiPagiationState<R>
  /** 初始化请求(支持自定义参数) */
  initCall: (
    params?: Omit<P, 'current' | 'size'>,
  ) => Promise<ApiResponse<ApiPageResponse<R>>>
  /** 刷新当前页 */
  refresh: () => Promise<void>
  /** 跳转到第一页 */
  firstPage: () => Promise<void>
  /** 跳转到最后一页 */
  lastPage: () => Promise<void>
  /** 跳转到上一页 */
  prevPage: () => Promise<void>
  /** 跳转到下一页 */
  nextPage: () => Promise<void>
  /** 跳转到指定页 */
  goToPage: (page: number) => Promise<void>
  /** 添加一条数据并跳转到最后一页 */
  addOneAndGoToLastPage: () => Promise<void>
  /** 更改每页条数 */
  changeSize: (size: number) => Promise<void>
  /** 重置分页状态(保留配置) */
  reset: () => void
}
  • hasMore 参数主要用于判断是否到最后一页了。
  • totalRecords 参数是最终外部需要获取的分页记录集,因为不同的策略,展示的分页记录集是不同的,所以独立出来。
  • initCall 是第一次需要执行的请求,后续请求工具会记录请求参数,供后续操作接收执行。
  • addOneAndGoToLastPage 主要是应对新增情况,例如评论功能,我发表一个新评论后,按时间排序的话,我需要跳转到最后一页才能查看,这个方法就是方便这个场景的使用的。

这些是分页请求内部需要的,或者说提供的类型。我们还需要定义分页请求需要接收什么配置。

我们思考一下,分页请求一开始需要我们指定什么?必不可少的,就是页大小、当前页位置吧?此外,由于分页请求是基于上述的核心请求工具,我们还需要指定基础的请求方法,以及 LoadingKeep 时间。

此外,我们还需要指定分页请求的响应数据合并策略。这样,需要配置就决定好了:

/** 分页 Hook 配置 */
export interface ApiPagnHookConfig<P extends ApiPageQuery, R> {
  apiFn: ApiRawCall<P, ApiPageResponse<R>>
  initialSize: number
  nextSize?: number
  initialCurrent: number
  loadingKeep?: number
  mergeStrategy: ApiPageMergeStrategy
}

nextSize:是用于解决首页的页大小和后续页的页大小不同的情况。例如下滑刷新,我可能第一页需要 10 个数据,下滑后每次新增 5 个数据。这个参数默认和 initialSize 一样,除非用户指定。

3.2 分页请求工具实现

这个请求工具直接看代码吧,其实都是非常常见的分页操作的实现。创建一个 usePagnRequest.ts 文件:

import type { ApiResponse } from '@/utils/requests/types/coreHook'
import type {
  ApiPageQuery,
  ApiPageResponse,
  ApiPagnHook,
  ApiPagnHookConfig,
} from '@/utils/requests/types/paginationHook'
import { useRequest } from '@/utils/requests/useRequest'
import { computed, ref, type Ref } from 'vue'

/**
 * 分页请求 Hook
 */
export function usePagnRequest<
  P extends ApiPageQuery = ApiPageQuery,
  R = unknown,
>(config: ApiPagnHookConfig<P, R>): ApiPagnHook<P, R> {
  // 初始化配置
  const {
    apiFn: apiFunc,
    initialSize = 10,
    nextSize = initialSize,
    initialCurrent = 1,
    mergeStrategy = 'replace',
    loadingKeep = 0,
  } = config

  // 基础请求 Hook
  const pagRequest = useRequest<P, ApiPageResponse<R>>(apiFunc, loadingKeep)

  // 分页响应式状态
  const current = ref(initialCurrent)
  const size = ref(initialSize)
  const total = ref(0)
  const pages = ref(0)
  const records: Ref<R[]> = ref([])
  const totalRecords: Ref<R[]> = ref([])
  const loading = pagRequest.loading
  const hasMore = computed(() => current.value < pages.value)

  // 临时变量(用于首页和后续页页大小不同的情况)
  const oldCurrent = ref(initialCurrent)

  // 保存用户的自定义查询参数
  const customParams = ref<Omit<P, 'current' | 'size'>>(
    {} as Omit<P, 'current' | 'size'>,
  )

  /**
   * 应用数据合并策略
   */
  const applyMergeStrategy = (newRecords: R[]) => {
    switch (mergeStrategy) {
      case 'append':
        totalRecords.value = [...totalRecords.value, ...newRecords]
        break
      case 'prepend':
        totalRecords.value = [...newRecords, ...totalRecords.value]
        break
      case 'replace':
        totalRecords.value = [...newRecords]
        break
      default:
        totalRecords.value = [...newRecords]
        break
    }
  }

  /**
   * 构建请求参数(合并分页参数 + 自定义参数)
   */
  const buildRequestParams = (): P => {
    return {
      current: oldCurrent.value,
      size: size.value,
      ...customParams.value,
    } as P
  }

  /**
   * 初始化请求方法
   */
  const initCall = async (
    params?: Omit<P, 'current' | 'size'>,
  ): Promise<ApiResponse<ApiPageResponse<R>>> => {
    if (params) {
      customParams.value = { ...params }
    }

    const requestParams = buildRequestParams()
    const response = await pagRequest.execute(requestParams)

    if (response.data) {
      const {
        records: newRecords,
        total: newTotal,
        pages: newPages,
      } = response.data

      records.value = newRecords
      total.value = newTotal
      pages.value = newPages

      applyMergeStrategy(newRecords)
    }

    return response
  }

  /**
   * 请求成功后更新分页参数,失败则回滚
   */
  const updatePageParams = (response: ApiResponse<ApiPageResponse<R>>) => {
    if (response.code === 200) {
      current.value = oldCurrent.value
    } else {
      oldCurrent.value = current.value
      throw new Error(response.message)
    }
  }

  /** 刷新当前页 */
  const refresh = async () => {
    await initCall()
  }

  /** 跳转到第一页 */
  const firstPage = async () => {
    if (current.value === 1) return
    oldCurrent.value = 1
    updatePageParams(await initCall())
  }

  /** 跳转到最后一页 */
  const lastPage = async () => {
    if (current.value === pages.value) return
    oldCurrent.value = pages.value
    updatePageParams(await initCall())
  }

  /** 跳转到上一页 */
  const prevPage = async () => {
    if (current.value <= 1) return
    oldCurrent.value -= 1
    updatePageParams(await initCall())
  }

  /** 跳转到下一页 */
  const nextPage = async () => {
    if (current.value >= pages.value) return
    oldCurrent.value += 1

    const originalSize = size.value
    size.value = nextSize

    try {
      updatePageParams(await initCall())
    } finally {
      size.value = originalSize
    }
  }

  /** 跳转到指定页 */
  const goToPage = async (page: number) => {
    if (page < 1 || page > pages.value || page === current.value) return
    oldCurrent.value = page
    updatePageParams(await initCall())
  }

  /** 增加一条数据并跳转到最后一页 */
  const addOneAndGoToLastPage = async () => {
    total.value += 1
    pages.value = Math.ceil(total.value / size.value)

    if (current.value === pages.value) {
      updatePageParams(await initCall())
    } else {
      oldCurrent.value = pages.value
      updatePageParams(await initCall())
    }
  }

  /** 更改每页条数 */
  const changeSize = async (newSize: number) => {
    if (newSize === size.value) return
    size.value = newSize
    oldCurrent.value = 1
    updatePageParams(await initCall())
  }

  /** 重置分页状态 */
  const reset = () => {
    current.value = initialCurrent
    size.value = initialSize
    total.value = 0
    pages.value = 0
    records.value = []
    totalRecords.value = []
    customParams.value = {} as Omit<P, 'current' | 'size'>
  }

  return {
    state: {
      current,
      size,
      total,
      pages,
      records,
      totalRecords,
      loading,
      hasMore,
    },
    initCall,
    refresh,
    firstPage,
    lastPage,
    prevPage,
    nextPage,
    goToPage,
    addOneAndGoToLastPage,
    changeSize,
    reset,
  }
}

4 请求构建器

4.1 类型定义

思考一下,我们定义一个请求方法,需要什么参数?我们需要知道这个请求的请求路径、请求参数、请求方法、请求头、响应参数。

对于请求参数和响应参数,我们可以通过具体的类型来指定。但是指定了请求参数类型还不够,我们还需要知道请求参数放在哪。

这下,我们知道了我们需要的请求配置:请求路径、请求参数位置、请求方法、请求头。这些配置,就可以让我们构建一个基础的 Axios 请求方法。

但是我们最终的目的,是构建一个请求 Hook。从上述的核心请求来看,我们知道了基础请求方法,剩下的就是指定 LoadingKeep 时间了;而对于分页请求,我们需要知道初始页大小、起始页码、合并策略,以及可选的后续页大小。

我们先在 types 包下创建一个 common.ts 文件,定义通用的类型:

/** HTTP 请求方法类型 */
export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

/** HTTP 请求参数位置类型 */
export type ApiParamIn = 'query' | 'body'

对于请求参数位置,因为我通常不用 path 类型,所以我并没有定义 path 类型,就是参数放在在请求路径上的,需要的可以自己实现。

然后再创建一个 defineHook.ts 文件:

/** 核心 Hook 配置 */
export interface CoreHookConfig {
  /** 请求路径 */
  path: string
  /** 请求参数位置 */
  paramIn: ApiParamIn
  /** 最小 loading 持续时长(ms),默认 0 */
  loadingKeep?: number
  /** 请求头 */
  headers?: Record<string, string>
}

/** 分页 Hook 配置 */
export interface PagnHookConfig extends CoreHookConfig {
  /** 初始页大小,默认 10 */
  initialSize?: number
  /** 下一页页大小, 默认为初始页大小 */
  nextSize?: number
  /** 起始页码,默认 1 */
  initialCurrent?: number
  /** 数据合并策略,默认 replace */
  mergeStrategy?: ApiPageMergeStrategy
}

/** Hook 配置 */
export type HookConfig = CoreHookConfig | PagnHookConfig

可以看到,我没有定义请求方法字段,因为后续,我们会用一个巧妙的方法来指定。

还没完,我们知道了请求配置,但是不知道请求构建工具要返回什么给我们。

思考一下,我们最终的目标,是让构建工具,返回什么?当然是一个带提示的字典。什么字典?当然是一个,键是请求方法名,值是请求 Hook 的字典了。

方法名肯定是一个字符串,但是怎么让它有类型提示?其实,前面就有答案。看下面的代码:

/** HTTP 请求方法类型 */
export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

这不就是一个,将字符串转化成类型的例子吗?而且有类型提示。

但是,ApiMethod 的类型,是确定的,或者说,ApiMethod 指定的字符串,是一些固定的字符串,像 'GET'、'POST' 这些,是固定的。而我们的请求方法名,它是不固定的。

你可能会问,我不是像这样:

getArticleList: {
  ...
},
getArticleDetail: {
  ...
},

在配置中指定了吗?它怎么就不是固定的?

对于这个配置来说,当然是固定的,但是,你不可能只有这一个配置吧?

像上面这个,我可以将其定义为 type ArticleApi = 'getArticleList' | 'getArticleDetail' 类型,但是我换一个接口配置:

getUserList: {
  ...
},
getUserDetail: {
  ...
},

那它是不是另一个类型?从整体上看,是不是不固定的?

也就是说,我的请求构建工具,需要根据不同的配置,动态生成对应的方法名类型。

怎么动态转化成类型?这里就用到了一个很巧妙的技巧:

/** HTTP Hook 定义类型 */
export type ApiHooksDefinition = Record<string, unknown>

/** HTTP Hook 集合类型 */
export type ApiHooks<H extends ApiHooksDefinition> = {
  [K in keyof H]: H[K]
}

我这里将类型的定义,拆成了两部,首先定义这个类型:

/** HTTP Hook 定义类型 */
export type ApiHooksDefinition = Record<string, unknown>

这个类型,主要是用于承接 Hook 字典的。什么意思?我们从 ApiMethod 中知道,我需要将字符串转化为类型,那么就得让字符串是固定的。

但是在构建过程中,对于我们的请求构建工具来说,它不知道你传过来的配置是什么样的,你可能是 Article 相关的方法名,也可能是 User 相关的方法名。在这个过程中,字典的键(方法名)肯定不是固定。所以,我们得定义一个类型,暂时将这个字典的类型模糊掉,用更加通用的类型来描述这些不固定的字典。

等待请求方法构建好以后,对于当前的配置来说,你们的字典就是固定的了。我们就可以将这个字典,断言成具体的,方法名有类型提示的字典类型。这个具体的字典类型,就是下面这个:

/** HTTP Hook 集合类型 */
export type ApiHooks<H extends ApiHooksDefinition> = {
  [K in keyof H]: H[K]
}

这里也用到了 ts 的类型定义技巧。我让类型的入参是上述的,模糊的字典(ApiHooksDefinition)泛型。

然后,对于这个具体的字典的键,我使用 K in keyof H,将模糊字典的字符串键名(方法名),作为这个具体字典的键的类型。其实就是将模糊字典的方法名,动态定义为类似 ApiMethod 的类型,这样就有类型提示了。

而对于具体字典的值,我们使用 H[K] 获取模糊字典的值,其值就是需要的请求 Hook 类型。

4.2 请求构建工具的实现

从类型定义中,我们解决了如何让方法名转化为有提示的类型。但是现在,我们还需要解决一个难题:怎么指定请求参数类型和响应参数类型?

我们不可能从字典中指定。唯一的办法就是,通过方法来指定。

通过方法指定,这意味着我们需要做类似这样的操作:

{
  getArticleList: defineHook<ArticleListQuery, ArticleListResponse>({
    ...
  })
}

我每次定义一个接口,我都要写一个 defineHook,这又回到了我们最初的,每定义一个接口,都要写这样的样板代码的问题:

function xxxRequest(xx: xx, xx: xx): xx {
	return httpClient({ url, method, headers })
}

有没有可以让他更加简洁的方法?

从之前的配置中,你有没有发现一个问题?我们没有指定请求参数,和 Hook 类型。也就是说,我们还需要知道,我们请求方法是什么,需要构建什么 Hook (核心请求 or 分页请求)。

我留着这个问题,就是要在这里解决的。

想一下,既然 defineHook 是样板代码,那我可以让他变成不样板的,或者说有效的,不就行了?

怎么变成有效的,例如,我可以在这里,指定请求方法或者要构建的 Hook:

{
  getArticleList: get<ArticleListQuery, ArticleListResponse>({
    ...
  })
},
{
  getArticleList: pagn<ArticleListQuery, ArticleListResponse>({
    ...
  })
}

这里定义一个的话,意味着另一个可能要写到配置里。我们更加大胆一点,直接全部在这里指定:

{
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    ...
  })
},
{
  getArticleList: pagn.get<ArticleListQuery, ArticleListResponse>({
    ...
  })
}

从语义上,我认为这样更加合适:

{
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    ...
  })
},

这样的话,不久没有样本代码了吗?不需要写重复的东西,每一步都是有效操作,而且可读性还很好。

那么,我们怎么实现这个功能?在get.pagn 中,明显,get 是个对象,pagn 是个方法,get 从哪获取?

这就是我们这个构建请求的精髓所在。

我们把,视角放大,完整的配置是这样的:

const articleApi = defineApiHook('/client/article', {
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    ...
  })
})

既然 get 是一个对象,那么,我们能不能在 defineApiHook 中定义,从 defineApiHook 中获取?

从函数中获取对象,有什么办法?我们在平时写代码,其实已经知道这个办法了,例如:

[1, 2, 3].map((item, index, array) => item + index);

没错,办法就是回调函数。

我们可以这样做:

const articleApi = defineApiHook('/client/article', (get, post) => ({
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    ...
  })
}))

甚至可以将它们合并在一个对象中:

const articleApi = defineApiHook('/client/article', (method) => ({
  getArticleList: method.get.pagn<ArticleListQuery, ArticleListResponse>({
    ...
  })
}))

或者用更加迎合我们简洁思想的写法:

const articleApi = defineApiHook('/client/article', ({ get }) => ({
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    ...
  },
  getArticleDetail: get.core<ArticleDetailQuery, ArticleDetailResponse>({
    ...
  })
}))

思路是不是有了?

还没结束。我们知道,pagn、core 是一个方法,但是是什么方法?我们从上面的配置可以看出了,pagn、core 是接收请求配置,构建 Hook 的方法。或者使用更加贴切的名字:Hook 构建器。

所以,我们需要在类型中,定义一下,这个 Hook 构建器类型:

export interface ApiHookBuilder {
  /** 构建核心请求 hook */
  core: <P, R>(config: CoreHookConfig) => () => ApiCoreHook<P, R>
  /** 构建分页请求 hook */
  pagn: <P extends ApiPageQuery, R>(
    config: PagnHookConfig,
  ) => () => ApiPagnHook<P, R>
}

还没完,我们的回调函数的类型还没确定呢。回调函数肯定是一个函数,但是是一个什么函数?我们观察一下:

({ get }) => ({
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    ...
  },
  getArticleDetail: get.core<ArticleDetailQuery, ArticleDetailResponse>({
    ...
  })
})

我们的构建器返回的是 Hook,并且接收的是构建器字典,所以可以写成:

({ get, post, put, delete }) => ({
  getArticleList: ApiPagnHook
  getArticleDetail: ApiCoreHook
})

现在,是不是一目了然了?回调函数,是一个接收构建器字典,返回键是方法名,值是相应 Hook 的字典。而这个字典,不就是我们之前定义的模糊字典类型:

/** HTTP Hook 定义类型 */
export type ApiHooksDefinition = Record<string, unknown>

所以,我们向类型文件中定义以下几个类型:

/** HTTP Hook 的构建器 */
export interface ApiHookBuilder {
  /** 构建核心请求 hook */
  core: <P, R>(config: CoreHookConfig) => () => ApiCoreHook<P, R>
  /** 构建分页请求  */
  pagn: <P extends ApiPageQuery, R>(
    config: PagnHookConfig,
  ) => () => ApiPagnHook<P, R>
}

/** 构建器字典 */
export interface ApiHookBuilderMap {
  get: ApiHookBuilder
  post: ApiHookBuilder
  put: ApiHookBuilder
  del: ApiHookBuilder
}

/** 请求构建器回调函数 */
export type ApiHookBuilderCallback = (map: ApiHookBuilderMap) => ApiHooksDefinition

这样,类型就定义完成了。还没结束,我们还缺一个类型,就是请求构建工具的输出类型。

我们知道,请求构建工具最终输出的,是一个具体字典,键是方法名,值是相应的 Hook。而这个具体字典,我们前面已经定义了,就是 ApiHooks。

但是,如果直接返回字典,我们后续的调用方式,就是 articleApi.getArticleList.initCall(),每次都要写 articleApi. 比较麻烦,所以我这里将 ApiHooks 包装进一个函数里,让它支持 const { getArticleList } = articleApi 这样的定义。多写一行,比多写一堆好点。

/** HTTP Hook 集合构建器类型 */
export type ApiHooksBuilder<H extends ApiHooksDefinition> = () => ApiHooks<H>

这样,类型就定义完了。接下来,我们开始编写实现方法。

编写方法,首先就是要确定输入类型和输出类型了。这个请求构建工具的输入参数类型很简单,就是一个统一的 prefex 路径前缀,和构建器的回调函数;而输出类型,就是我们之前定义的 ApiHooksBuilder,结构如下:

export function defineApiHook<H extends ApiHooksDefinition>(
  prefix: string,
  define: ApiHookBuilderCallback,
): ApiHooksBuilder<H> { }

逻辑怎么写呢?我们从目的出发,我们是要根据配置信息,构建 Hook。但是构建 Hook 需要两步,第一步是构建基础的 Axios 请求方法,第二步才是构建 Hook 。

思路就明确了,首先我们要先写一个,根据配置,生成基础 Axios 请求方法。

function createRawCall<P, R>(
  fullPath: string,
  method: ApiMethod,
  config: HookConfig,
): ApiRawCall<P, R> {
  return (params?: P) => {
    const axiosConfig: AxiosRequestConfig = {
      url: fullPath,
      method: method,
      headers: config.headers,
    }

    switch (config.paramIn) {
      case 'query':
        axiosConfig.params = params
        break
      case 'body':
        axiosConfig.data = params
      default:
        axiosConfig.params = params
    }

    return httpClient.request<ApiRawResponse<R>>(axiosConfig)
  }
}

然后,写一个,根据配置,利用 createRawCall 生成 Hook 的方法。我们先回顾一下 Hook 构建器的类型:

export interface ApiHookBuilder {
  /** 构建核心请求 hook */
  core: <P, R>(config: CoreHookConfig) => ApiCoreHook<P, R>
  /** 构建分页请求  */
  pagn: <P extends ApiPageQuery, R>(
    config: PagnHookConfig,
  ) => ApiPagnHook<P, R>
}

Hook 构建器其实是,返回一个字典,字典包含 core + 根据配置构建 ApiCoreHook 的方法和 pagn + ApiPagnHook 两个记录:

function createBuilder(method: ApiMethod, prefix: string): ApiHookBuilder {
  return {
    core<P, R>(config: CoreHookConfig) {
      const fullPath = prefix + config.path
      const apiRawCall: ApiRawCall<P, R> = createRawCall(
        fullPath,
        method,
        config,
      )

      return useRequest<P, R>(apiRawCall, config.loadingKeep)
    },

    pagn<P extends ApiPageQuery, R>(config: PagnHookConfig) {
      const fullPath = prefix + config.path
      const apiRawCall: ApiRawCall<P, ApiPageResponse<R>> = createRawCall<
        P,
        ApiPageResponse<R>
      >(fullPath, method, config)

      return usePagnRequest<P, R>({
        apiFn: apiRawCall,
        initialSize: config.initialSize ?? 10,
        nextSize:
          (config.nextSize ?? config.initialSize) ? config.initialSize : 10,
        initialCurrent: config.initialCurrent ?? 1,
        loadingKeep: config.loadingKeep ?? 0,
        mergeStrategy: config.mergeStrategy ?? 'replace',
      })
    },
  }
}

前置就做完了,现在,我们回顾一下配置文件:

const articleApi = defineApiHook('/client/article', ({ get }) => ({
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    ...
  },
  getArticleDetail: get.core<ArticleDetailQuery, ArticleDetailResponse>({
    ...
  })
}))

我们完成了 pagn、core 方法的创建,现在,就是要把它们装进 { get } 中,也就是 map 中。然后执行回调函数,让它调用 createBuilder 构建出相应的 Hook。

export function defineApiHook<H extends ApiHooksDefinition>(
  prefix: string,
  define: ApiHookBuilderCallback,
): ApiHooksBuilder<H> {
  // 创建构建器字典
  const builderMap: ApiHookBuilderMap = {
    get: createBuilder('GET', prefix),
    post: createBuilder('POST', prefix),
    put: createBuilder('PUT', prefix),
    del: createBuilder('DELETE', prefix),
  }

  // 执行回调,返回 Hook 构建器集合
  const hookBuilders = define(builderMap)
}

在这里,你可能会疑惑这种写法。我们先回顾一下 ApiHookBuilderCallback 的类型:

export type ApiHookBuilderCallback = (map: ApiHookBuilderMap) => ApiHooksDefinition

它是一个接收 ApiHookBuilderMap ,返回 ApiHooksDefinition 模糊字典的函数类型。而我们在配置中:

const articleApi = defineApiHook('/client/article', ({ get }) => ({
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    ...
  },
  getArticleDetail: get.core<ArticleDetailQuery, ArticleDetailResponse>({
    ...
  })
}))

告诉了 ApiHookBuilderCallback ,我们要拿 get 这个构建器,构建 pagn 和 core 这两个 Hook。所以我们第一步,要先创建构建器吧?

// 创建构建器字典
const builderMap: ApiHookBuilderMap = {
get: createBuilder('GET', prefix),
post: createBuilder('POST', prefix),
put: createBuilder('PUT', prefix),
del: createBuilder('DELETE', prefix),
  

然后我们要调用 ApiHookBuilderCallback 让他拿着构建器,帮我们构建出 Hook,也就是我们要调用它,让它生成 ApiHooksDefinition。

// 执行回调,返回 Hook 构建器集合
const hookBuilders = define(builderMap)

这里的 hookBuilders,就是:

{
 "getArticleList": ApiPagnHook,
 "getArticleDetail": ApiCoreHook,
}

这样的模糊字典。

最后一步呢?非常简单,我们将这个模糊字典,断言为具体字典:

export function defineApiHook<H extends ApiHooksDefinition>(
  prefix: string,
  define: ApiHookBuilderCallback,
): ApiHooksBuilder<H> {
  // 创建构建器字典
  const builderMap: ApiHookBuilderMap = {
    get: createBuilder('GET', prefix),
    post: createBuilder('POST', prefix),
    put: createBuilder('PUT', prefix),
    del: createBuilder('DELETE', prefix),
  }

  // 执行回调,返回 Hook 构建器集合
  const hookBuilders = define(builderMap)

  // 返回 Hook 集合的构建函数
  return () => hookBuilders as ApiHooks<H>
}

就完成了。没错,完成了,很简单的方法。

但是还有个问题,当你写好配置:

const articleApi = defineApiHook('/client/article', ({ get }) => ({
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    path: '/list',
    paramIn: 'query',
    loadingKeep: 1000,
    initialSize: 12,
    initialCurrent: 1,
    mergeStrategy: 'replace',
  },
  getArticleDetail: get.core<ArticleDetailQuery, ArticleDetailResponse>({
    path: '/detail',
    paramIn: 'query',
    loadingKeep: 500,
  })
}))

调用它时,你会发现,写下 articleApi. 时,没有任何提示。

这是为什么呢?其实问题就出在我们的回调函数上:

/** 请求构建器回调函数 */
export type ApiHookBuilderCallback  = (map: ApiHookBuilderMap) => ApiHooksDefinition

我们的回调函数,是一个返回了具体类型 ApiHooksDefinition 也就是 Record<string, unknown> 的函数。

所以在这一行,hookBuilders 的类型就是:Record<string, unknown>

const hookBuilders = define(builderMap)

到类型断言时,自然而然的,就断言成了:ApiHooks<Record<string, unknown>>,而 ApiHooks 的类型是:

/** HTTP Hook 集合类型 */
export type ApiHooks<H extends ApiHooksDefinition> = {
  [K in keyof H]: H[K]
}

但是 H 是 Record<string, unknown>,所以 ApiHooks 的内容是:

[key: string]: unknwon

我们本来具体的:'getArticleList' 、'getArticleDetail' 字符串键,被模糊成了 string。也就是说我们的具体字典,又被模糊成了模糊字典。

所以导致断言后,使用时没有提示。

解决办法很简单,我们不要让 ApiHookBuilderCallback 返回明确的 ApiHooksDefinition(Record<string, unknown>) 类型,而是通过泛型,让 TS 推断我们的具体字典类型。

/** 请求构建器回调函数 */
export type ApiHookBuilderCallback<H extends ApiHooksDefinition>  = (map: ApiHookBuilderMap) => H

请求构建工具同步更改:

export function defineApiHook<H extends ApiHooksDefinition>(
  prefix: string,
  define: ApiHookBuilderCallback<H>,
): ApiHooksBuilder<H> {
  // 创建构建器字典
  const builderMap: ApiHookBuilderMap = {
    get: createBuilder('GET', prefix),
    post: createBuilder('POST', prefix),
    put: createBuilder('PUT', prefix),
    del: createBuilder('DELETE', prefix),
  }

  // 执行回调,返回 Hook 构建器集合
  const hookBuilders = define(builderMap)

  // 返回 Hook 集合的构建函数
  return () => hookBuilders as ApiHooks<H>
}

这样就有类型提示了。

完整的类型和实现代码: types/defineHooks.ts

import type { ApiParamIn } from '@/utils/requests/types/common'
import type { ApiCoreHook } from '@/utils/requests/types/coreHook'
import type {
  ApiPageMergeStrategy,
  ApiPageQuery,
  ApiPagnHook,
} from '@/utils/requests/types/paginationHook'

/** 核心请求配置 */
export interface CoreHookConfig {
  /** 请求路径 */
  path: string
  /** 请求参数位置 */
  paramIn: ApiParamIn
  /** 最小 loading 持续时长(ms),默认 0 */
  loadingKeep?: number
  /** 请求头 */
  headers?: Record<string, string>
}

/** 分页请求配置 */
export interface PagnHookConfig extends CoreHookConfig {
  /** 初始页大小,默认 10 */
  initialSize?: number
  /** 下一页页大小, 默认为初始页大小 */
  nextSize?: number
  /** 起始页码,默认 1 */
  initialCurrent?: number
  /** 数据合并策略,默认 replace */
  mergeStrategy?: ApiPageMergeStrategy
}

/** 基础请求配置 */
export type HookConfig = CoreHookConfig | PagnHookConfig

/** HTTP Hook 定义类型 */
export type ApiHooksDefinition = Record<string, unknown>

/** HTTP Hook 集合类型 */
export type ApiHooks<H extends ApiHooksDefinition> = {
  [K in keyof H]: H[K]
}

/** HTTP Hook 的构建器 */
export interface ApiHookBuilder {
  /** 构建核心请求 hook */
  core: <P, R>(config: CoreHookConfig) => ApiCoreHook<P, R>
  /** 构建分页请求  */
  pagn: <P extends ApiPageQuery, R>(
    config: PagnHookConfig,
  ) => ApiPagnHook<P, R>
}

/** 构建器字典 */
export interface ApiHookBuilderMap {
  get: ApiHookBuilder
  post: ApiHookBuilder
  put: ApiHookBuilder
  del: ApiHookBuilder
}

/** 请求构建器回调函数 */
export type ApiHookBuilderCallback<H extends ApiHooksDefinition>  = (map: ApiHookBuilderMap) => H

/** HTTP Hook 集合构建器类型 */
export type ApiHooksBuilder<H extends ApiHooksDefinition> = () => ApiHooks<H>

useDefineApiHook.ts:

import httpClient from '@/utils/requests/httpClient'
import type { ApiMethod } from '@/utils/requests/types/common'
import type {
  ApiRawCall,
  ApiRawResponse,
} from '@/utils/requests/types/coreHook'
import type {
  ApiHookBuilder,
  ApiHookBuilderCallback,
  ApiHookBuilderMap,
  ApiHooks,
  ApiHooksBuilder,
  ApiHooksDefinition,
  CoreHookConfig,
  HookConfig,
  PagnHookConfig,
} from '@/utils/requests/types/defineHook'
import type {
  ApiPageQuery,
  ApiPageResponse,
} from '@/utils/requests/types/paginationHook'
import { usePagnRequest } from '@/utils/requests/usePagnRequest'
import { useRequest } from '@/utils/requests/useRequest'
import type { AxiosRequestConfig } from 'axios'

/** 请求 Hook 构建器 */
export function defineApiHook<H extends ApiHooksDefinition>(
  prefix: string,
  define: ApiHookBuilderCallback<H>,
): ApiHooksBuilder<H> {
  // 创建构建器字典
  const builderMap: ApiHookBuilderMap = {
    get: createBuilder('GET', prefix),
    post: createBuilder('POST', prefix),
    put: createBuilder('PUT', prefix),
    del: createBuilder('DELETE', prefix),
  }

  // 执行回调,返回 Hook 构建器集合
  const hookBuilders = define(builderMap)

  // 返回 Hook 集合的构建函数
  return () => hookBuilders as ApiHooks<H>
}

/**
 * 创建基础 HTTP 方法的函数
 */
function createRawCall<P, R>(
  fullPath: string,
  method: ApiMethod,
  config: HookConfig,
): ApiRawCall<P, R> {
  return (params?: P) => {
    const axiosConfig: AxiosRequestConfig = {
      url: fullPath,
      method: method,
      headers: config.headers,
    }

    switch (config.paramIn) {
      case 'query':
        axiosConfig.params = params
        break
      case 'body':
        axiosConfig.data = params
      default:
        axiosConfig.params = params
    }

    return httpClient.request<ApiRawResponse<R>>(axiosConfig)
  }
}

/**
 * 创建 HTTP Hook 构建器的函数
 */
function createBuilder(method: ApiMethod, prefix: string): ApiHookBuilder {
  return {
    core<P, R>(config: CoreHookConfig) {
      const fullPath = prefix + config.path
      const apiRawCall: ApiRawCall<P, R> = createRawCall(
        fullPath,
        method,
        config,
      )

      return useRequest<P, R>(apiRawCall, config.loadingKeep)
    },

    pagn<P extends ApiPageQuery, R>(config: PagnHookConfig) {
      const fullPath = prefix + config.path
      const apiRawCall: ApiRawCall<P, ApiPageResponse<R>> = createRawCall<
        P,
        ApiPageResponse<R>
      >(fullPath, method, config)

      return usePagnRequest<P, R>({
        apiFn: apiRawCall,
        initialSize: config.initialSize ?? 10,
        nextSize:
          (config.nextSize ?? config.initialSize) ? config.initialSize : 10,
        initialCurrent: config.initialCurrent ?? 1,
        loadingKeep: config.loadingKeep ?? 0,
        mergeStrategy: config.mergeStrategy ?? 'replace',
      })
    },
  }
}

使用方法:

const articleApi = defineApiHook('/client/article', ({ get }) => ({
  getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({
    path: '/list',
    paramIn: 'query',
    loadingKeep: 1000,
    initialSize: 12,
    initialCurrent: 1,
    mergeStrategy: 'replace',
  },
  getArticleDetail: get.core<ArticleDetailQuery, ArticleDetailResponse>({
    path: '/detail',
    paramIn: 'query',
    loadingKeep: 500,
  })
}))

const { getArticleList, getArticleDetail } = articleApi()

getArticleList.initCall()
getArticleDetail.execute()

5 总结

  • 实现了带独立 Loading 管理的请求工具。
  • 实现了带独立分页参数和便捷分页操作的分页请求管理器。
  • 实现了根据配置生成上述请求工具的请求 Hook 构建器。
  • 了解了回调函数的编写逻辑。
  • 明白了如何通过类型定义,让字典的字符串键,变成字面量类型键,实现类型提示。

0 人赞过

评论

评论加载中…