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 功能的前端项目,在这套组合拳下来,对请求的处理已经非常完美了。
但是,当你要添加一个新的模块,新增多个新模块的接口时,麻烦来了。
首先,你需要在相应的模块,创建一个专门编写基础请求方法的文件,然后,你需要重复的编写下面的代码:
tsfunction xxxRequest(xx: xx, xx: xx): xx { return httpClient({ url, method, headers }) }
然后,为了提供类型提示,你还需要再定义接口类型的包中,创建一个专门的文件,将各个接口的响应类型定义出来,并导出。
再然后,为了提供 Loading 效果,或者是为了提供分页请求,你还需要使用你编写的请求工具,将相应的基础请求方法,用工具包装,做成 Hook。
为了减少页面代码逻辑,你可能还需要将包装 Hook 的代码,再次写在一个专门的文件中。
这时候,你就可以去使用你的请求了。但是,代价是什么?
代价就是,你点开定义 Api 接口的包有,发现了三个包,分别是定义基础请求接口的包、定义接口类型的包、定义请求 Hook 的包。
这三个包有相同的文件数量,因为三者的文件是一一对应关系。这意味着,你要编写一个接口,需要连续在三个文件下定义。
此外,当你点开基础请求工具的定义文件,发现一堆密密麻麻的 function xxxRequest(xx: xx, xx: xx): xx ,难以从中找到对应的接口。
于是,你开始思考,有什么方法,可以在一个文件就能够定义出我想要的请求工具,并且定义过程非常便捷,定义出来的内容也简洁易懂呢?
这就是本文要解决的问题。
本文将会分享我在做前端项目中,做出来的一套使用起来很便捷的的请求工具包。这套工具包包含了自带独立 Loading 管理的请求工具,维护各个分页参数和结果、提供各种便捷分页操作的分页工具,以及一个便捷的接口定义工具。
在定义接口时,只需要写这样的代码:
tsexport 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:
tsimport 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 组合。
但是,我这里有个要求,因为我希望给用户使用的前端,不要完整给出错误信息。例如在我的博客网站中,我把大多数的请求异常,统一为了服务器异常,因为博客本身没有多少交互功能,几乎都是查询接口,所以我不希望将返回的错误日志都完整打印,让用户疑惑。
所以,我还需要定义一个,接收原始响应的响应数据类型,以及一个在原始响应做出转换后的的响应数据类型。
这四个类型如下:
tsimport 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 文件,实现基础的代码:
tsexport 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 方法:
tsexport 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 部分:
tsimport 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 文件:
tsimport 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' 这些,是固定的。而我们的请求方法名,它是不固定的。
你可能会问,我不是像这样:
tsgetArticleList: { ... }, getArticleDetail: { ... },
在配置中指定了吗?它怎么就不是固定的?
对于这个配置来说,当然是固定的,但是,你不可能只有这一个配置吧?
像上面这个,我可以将其定义为 type ArticleApi = 'getArticleList' | 'getArticleDetail' 类型,但是我换一个接口配置:
tsgetUserList: { ... }, 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,这又回到了我们最初的,每定义一个接口,都要写这样的样板代码的问题:
tsfunction 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 从哪获取?
这就是我们这个构建请求的精髓所在。
我们把,视角放大,完整的配置是这样的:
tsconst articleApi = defineApiHook('/client/article', { getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({ ... }) })
既然 get 是一个对象,那么,我们能不能在 defineApiHook 中定义,从 defineApiHook 中获取?
从函数中获取对象,有什么办法?我们在平时写代码,其实已经知道这个办法了,例如:
ts[1, 2, 3].map((item, index, array) => item + index);
没错,办法就是回调函数。
我们可以这样做:
tsconst articleApi = defineApiHook('/client/article', (get, post) => ({ getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({ ... }) }))
甚至可以将它们合并在一个对象中:
tsconst articleApi = defineApiHook('/client/article', (method) => ({ getArticleList: method.get.pagn<ArticleListQuery, ArticleListResponse>({ ... }) }))
或者用更加迎合我们简洁思想的写法:
tsconst articleApi = defineApiHook('/client/article', ({ get }) => ({ getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({ ... }, getArticleDetail: get.core<ArticleDetailQuery, ArticleDetailResponse>({ ... }) }))
思路是不是有了?
还没结束。我们知道,pagn、core 是一个方法,但是是什么方法?我们从上面的配置可以看出了,pagn、core 是接收请求配置,构建 Hook 的方法。或者使用更加贴切的名字:Hook 构建器。
所以,我们需要在类型中,定义一下,这个 Hook 构建器类型:
tsexport 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,结构如下:
tsexport function defineApiHook<H extends ApiHooksDefinition>( prefix: string, define: ApiHookBuilderCallback, ): ApiHooksBuilder<H> { }
逻辑怎么写呢?我们从目的出发,我们是要根据配置信息,构建 Hook。但是构建 Hook 需要两步,第一步是构建基础的 Axios 请求方法,第二步才是构建 Hook 。
思路就明确了,首先我们要先写一个,根据配置,生成基础 Axios 请求方法。
tsfunction 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 构建器的类型:
tsexport 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 两个记录:
tsfunction 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', }) }, } }
前置就做完了,现在,我们回顾一下配置文件:
tsconst articleApi = defineApiHook('/client/article', ({ get }) => ({ getArticleList: get.pagn<ArticleListQuery, ArticleListResponse>({ ... }, getArticleDetail: get.core<ArticleDetailQuery, ArticleDetailResponse>({ ... }) }))
我们完成了 pagn、core 方法的创建,现在,就是要把它们装进 { get } 中,也就是 map 中。然后执行回调函数,让它调用 createBuilder 构建出相应的 Hook。
tsexport 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 的类型:
tsexport type ApiHookBuilderCallback = (map: ApiHookBuilderMap) => ApiHooksDefinition
它是一个接收 ApiHookBuilderMap ,返回 ApiHooksDefinition 模糊字典的函数类型。而我们在配置中:
tsconst 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, }
这样的模糊字典。
最后一步呢?非常简单,我们将这个模糊字典,断言为具体字典:
tsexport 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> }
就完成了。没错,完成了,很简单的方法。
但是还有个问题,当你写好配置:
tsconst 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>
tsconst 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 返回明确的 ApiHooksDefinition(Record<string, unknown>) 类型,而是通过泛型,让 TS 推断我们的具体字典类型。
ts/** 请求构建器回调函数 */ export type ApiHookBuilderCallback<H extends ApiHooksDefinition> = (map: ApiHookBuilderMap) => H
请求构建工具同步更改:
tsexport 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
tsimport 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:
tsimport 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', }) }, } }
使用方法:
tsconst 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 构建器。
- 了解了回调函数的编写逻辑。
- 明白了如何通过类型定义,让字典的字符串键,变成字面量类型键,实现类型提示。