
【Vue3】超便捷的基于 Axios 的接口请求工具包
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 人赞过
评论
评论加载中…