文章
努力加载图片中...
【Vue3】超便捷的基于 Axios 的接口请求工具包
  • 9963 字

  • 13 分钟

  • 16 次

  • 2026-06-23
标签:

1 前言

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ts
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 管理的请求工具,维护各个分页参数和结果、提供各种便捷分页操作的分页工具,以及一个便捷的接口定义工具。

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

ts
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

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 组合。

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

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

这四个类型如下:

ts
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 文件,实现基础的代码:

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 方法:

ts
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 部分:

ts
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,类型如下:

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

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

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

ts
/** 分页数据合并策略类型 */ 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 时间。

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

ts
/** 分页 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 文件:

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 文件,定义通用的类型:

ts
/** HTTP 请求方法类型 */ export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' /** HTTP 请求参数位置类型 */ export type ApiParamIn = 'query' | 'body'

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

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

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 的字典了。

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

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

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

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

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

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

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

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

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

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

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

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

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

ts
/** HTTP Hook 定义类型 */ export type ApiHooksDefinition = Record<string, unknown> /** HTTP Hook 集合类型 */ export type ApiHooks<H extends ApiHooksDefinition> = { [K in keyof H]: H[K] }

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

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

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

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

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

ts
/** 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 请求构建工具的实现

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

我们可以这样做:

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

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

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

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

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

思路是不是有了?

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

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

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

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

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

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

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

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

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

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

ts
/** 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 这样的定义。多写一行,比多写一堆好点。

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

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

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

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

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

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

ts
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 构建器的类型:

ts
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 两个记录:

ts
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', }) }, } }

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

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

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

ts
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 的类型:

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

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

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

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

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

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

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

这里的 hookBuilders,就是:

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

这样的模糊字典。

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

ts
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> }

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

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

ts
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. 时,没有任何提示。

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

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

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

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

ts
const hookBuilders = define(builderMap)

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

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

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

ts
[key: string]: unknwon

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

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

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

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

请求构建工具同步更改:

ts
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

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:

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', }) }, } }

使用方法:

ts
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 构建器。
  • 了解了回调函数的编写逻辑。
  • 明白了如何通过类型定义,让字典的字符串键,变成字面量类型键,实现类型提示。
作者: Xigrut发布时间: 2026-06-23 13:15:46上次编辑时间: 2026-06-23 13:15:46 许可协议: CC BY-NC-SA 4.0
留言区