都快2022年了你连个API都写不好吗?来!这份ts+proxy的API编写风格指南你收好了
前提背景
这段时间公司要开始一个新的项目,趁着这机会我把项目的架构由原本的vue3 setup+vite+ts
改成了react hooks+vite+ts
,之所以要更改项目架构是因为vue3对ts的支持并没有我想的那么好,在某些场景下使用类型还得变通处理,编程体验欠佳,没办法,为了更好地拥抱ts,我只能做出了搭建一个新架构的决定,当然我有问过小组内成员的react掌握情况,他们都表示No Problem
!嗯,就算他们不会我也会让他们试着改变的,我就是这么的热情。
既然换了新的架构,那必然会学到有不少新的东西,那么今天就来给大伙分享一下我对项目API管理的一些新看法
糟糕的原架构API文件
我们先来看看原先我是怎么为项目编写API:
可以看到整个结构相当恶心,不知道大伙有多少个是这么写的,如果你是,那么现在得改了
- 由于之前没有考虑到巨大的api接口数量,我没有给api目录添加二级目录,这导致了一个文件夹下子文件密密麻麻造成视觉负担。是的,之前leader说这是个简单的项目,反正我是信了
- 这个
API
编写的方式是我之前参照 ant-design-pro的方式来写的,每一个接口写一个函数,但它们最终都只不过是给axios
的Get,Delete,Post,Put
这几个函数直接传值,而我的需求也只是获得参数的类型约束而已,对数据的处理一般会在API
函数外部进行,所以这样给每个接口都写一个函数实体实在是可耻的浪费 - 函数命名的心智负担,由于我给个接口都写了一个函数实体,对这巨量的接口函数命名便足以让我的发际线再提高一个档次
那么,如何改进?
首先由于之前没有考虑到API
的数量造成视觉负担,我在新的项目给api
目录做了一个简单的分类以方便日后查找方便,下面是新的目录结构:
第一个问题就这么简单的解决了👏,我真棒
下面就来解决剩下的问题
- 既然我只要接口传参的类型约束,那我能不能直接写
interface
就好,不用写什么操蛋的函数实体,比如像这样定义API
接口:
interface IUserGetArg {
id: string
}
interface IUserGetRe {
name: string
}
export default interface IUserAPI {
user: {
get: (arg: IUserGetArg) => IUserGetRe,
},
login: {
'verify-code': {
get: (arg: IUserGetArg) => IUserGetRe,
}
}
}
- 一般来说我们API接口函数返回的都是
Promise
,但为了省两个字我不太想给我定的get请求函数写成Promise<IUserGetRe>
,有没有一种方法可以给我自动加上Promise
,下面我来分享一下我是怎么为请求函数写的类型映射:
import { HttpConfig } from '.'
import IUserAPI from '@api/user'
// 这里是后端返回数据的固定字段,根据自己情况而定
export interface IResponse<T = any> {
code: number
message: string
success: boolean
data: T
}
// 只对继承改类型的函数进行映射,也就是当函数有0和1个参数时映射
type AnyFn = (arg: any) => any
// 函数返回结果的格式
type FnReturn<T extends AnyFn> = Promise<IResponse<ReturnType<T>>>
// 函数没定义参数时的映射格式
type Fn1<T extends AnyFn> = (config?: HttpConfig) => FnReturn<T>
// 函数定义参数时的映射格式
type Fn2<T extends AnyFn> = (arg: Parameters<T>[0], config?: HttpConfig) => FnReturn<T>
// 根据有无参数映射上面两个函数格式
type HttpMethod<T extends AnyFn> = Parameters<T>[0] extends (undefined | null) ? Fn1<T> : Fn2<T>
// 对传入的类型深度处理所有为AnyFn的子类型为Fn1或Fn2
type ToPromise<T> = {
[key in keyof T]: T[key] extends AnyFn
? HttpMethod<T[key]>
: ToPromise<T[key]>
}
// 把所有的API类型合并为一个
interface IMixinAPI extends
IUserAPI{}
// 对合并后的类型进行映射处理
export default interface IRootAPI extends ToPromise<IMixinAPI> {
url: string
}
这写的什么鬼东西,我们来具体分析下代码
IResponse
:这个是对后端返回固定格式的定义,看下面的转换
// 我们有一个IUserGetRe类型
interface IUserGetRe {
name: string
}
type A = IResponse<IUserGetRe>
// 经过一番处理后类型最终变成了下面的样子
type A = {
code: number
message: string
success: boolean
data: {
name: string
}
}
当然这个只要是了解ts的都会知道
FnReturn
:这个定义了函数返回结果的格式,和IResponse
差不多,要注意的是这里有一个ReturnType
// ReturnType 就是拿到函数的返回类型
type A = ReturnType<() => string>
// 转换后
type A = string
// 这里机灵的hxd就应该知晓了FnReturn的作用
type B = FnReturn<() => { name: string }>
// 转换后
type B = Promise<{
code: number
message: string
success: boolean
data: {
name: string
}
}>
通过FnReturn
我们可以将普通的对象转换成了Promise对象
Fn1
,Fn2
:这两个的作用就是将普通函数映射成异步函数,这里只需要注意一下Parameters
就好
// Parameters 就是拿到函数的参数类型,返回的是一个元组
type A = Parameters<(arg1: string, arg2: number) => void>
type B = Parameters<(arg1: string, arg2: number) => void>[1]
// 转换后
type A = [string, number]
type B = number
// 那么配合FnReturn后的Fn1, Fn2可以实现以下效果
type C = Fn2<(arg: string) => string>
// 转换后
type C = (arg: string, config?: HttpConfig) => FnReturn<(arg: string) => string>
FnReturn<(arg: string) => string>
的转换请看前面
HttpMethod
:根据函数有无参数映射将函数映射成Fn1
或Fn2
type HttpMethod<T extends AnyFn> = Parameters<T>[0] extends (undefined | null) ? Fn1<T> : Fn2<T>
type A = HttpMethod<() => string>
type B = HttpMethod<(arg: string) => string>
// 转换后
type A = Fn1<() => string>
type B = Fn2<(arg: string) => string>
ToPromise
: 对传入类型所有继承于AnyFn的子类型进行映射替换处理,下面是符合的函数类型
type A = () => any
type A = (arg: any) => any
// 其他的函数类型都默认不做映射处理
type C = (arg1: any, arg2: any) => any // 不处理
type D = (arg1: any, arg2: any, ...) => any // 不处理
类型映射说了一大堆,Proxy
在哪?
上面我说过能不能只写类型约束不写函数实体,下面我就聊聊如何通过Proxy
实现它,先上代码:
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import IRootAPI, { IResponse } from './typings'
// 这里就是简单的创建一个axios实例,懂的都懂,就不多介绍了
const service = axios.create({
// 这里的类型是确定的,也可以这么写 import.meta.env.VITE_API as string
baseURL: import.meta.env.VITE_API?.toString(),
timeout: 10000,
})
// 这里就是单纯的为了能用instanceof判断
export class HttpConfig {
public config: AxiosRequestConfig
constructor (config: AxiosRequestConfig) {
this.config = config
}
}
// 主要功能都搁在这
const handler: ProxyHandler<IRootAPI> = {
get: function get (target, prop): any {
let value: any
const item = Reflect.get(target, prop)
const httpMethod = Reflect.get(service, prop)
const methods = {
params: (arg1?: any, arg2?: HttpConfig) => {
return (arg1 instanceof HttpConfig)
? httpMethod(target.url, arg1?.config)
: httpMethod(
target.url,
{
params: { ...arg1 },
...arg2,
},
)
},
data: (arg1?: any, arg2?: HttpConfig) => {
return (arg1 instanceof HttpConfig)
? httpMethod(target.url, arg1?.config)
: httpMethod(target.url, { ...arg1 }, arg2?.config)
},
}
switch (prop) {
// case 'get' || 'delete': 这种写法是错的
case 'get':
case 'delete':
value = methods.params
break
case 'post':
case 'put':
value = methods.data
break
case 'url':
value = item
break
default:
value = {
...item,
url: `${target.url ?? ''}/${prop.toString()}`,
}
break
}
if (!item || typeof item !== 'function') Reflect.set(target, prop, value)
return new Proxy(
Reflect.get(target, prop),
{ get },
)
},
}
export const http = new Proxy(
{} as IRootAPI,
handler,
)
下面开始具体分析一下上面的代码
首先我们先创建了一个传入空对象的Proxy
,并将传入的空对象断言为IRootAPI
以获得类型提示:
export const http = new Proxy(
{} as IRootAPI,
handler,
)
接着来给Proxy
定义get
拦截函数
- 先看看在
get
函数中最先定义的三个变量:
// 用于为undefined的属性赋值
let value: any
// 对象取值,效果和target[prop]类似
const item = Reflect.get(target, prop)
// 这里主要是拿service.get,service.post,service.put,service.delete,为了方便就写在一起了
const httpMethod = Reflect.get(service, prop)
- 为http请求函数定义两种传参方式:
const methods = {
params: (arg1?: any, arg2?: HttpConfig) => {
return (arg1 instanceof HttpConfig)
? httpMethod(target.url, arg1?.config)
: httpMethod(
target.url,
{
params: { ...arg1 },
...arg2,
},
)
},
data: (arg1?: any, arg2?: HttpConfig) => {
return (arg1 instanceof HttpConfig)
? httpMethod(target.url, arg1?.config)
: httpMethod(target.url, { ...arg1 }, arg2?.config)
},
}
- 根据前面我们为
http
请求函数定义的类型映射规则,params
和data
函数其实都是对Fn1
和Fn2
的实现 - 两函数都是根据判断参数是否为
HttpConfig
,对httpMethod
进行有无参数的赋值 - 这里的
HttpConfig
是一个class
仅仅是为了用instanceof
,你不喜欢class
的话,可以考虑将函数格式改成下面的:
(arg: {
params?: any,
data?: any,
config?: AxiosRequestConfig,
}) => {...}
- 针对4个固定的
prop
赋值value
为methods.params
或methods.data
函数:
switch (prop) {
case 'get':
case 'delete':
value = methods.params
break
case 'post':
case 'put':
value = methods.data
break
case 'url':
value = item
break
default:
value = {
...item,
url: `${target.url ?? ''}/${prop.toString()}`,
}
break
}
- 其余情况给
target[prop]
添加一个路径属性url
,像这样:
http.user.name.url === '/user/name'
- 只有当
item
没定义或者item
不为函数时进行赋值处理,也就是说你可以通过自定义事件覆盖默认行为
if (!item || typeof item !== 'function') Reflect.set(target, prop, value)
- 返回赋值后的
target[prop]
的Proxy
方便后面调用:
return new Proxy(
Reflect.get(target, prop),
{ get },
)
看看效果:
嗯...可以看到数据被添加上了类型提示,请求也莫得问题
如何进行自定义事件处理?
也许有些情况下我们希望对传入的数据进行再处理,这时候我们可能就想要自定义事件,那么如何添加自定义事件?看下面:
export const http = new Proxy(
{
login: {
'verify-code': {
get: () => {
return new Promise(() => console.log('dfd'))
},
},
},
} as IRootAPI,
handler,
)
没错!你只需要简简单单地往我们之前定义的空对象里面直接写上便可
对于把id放在url的情况
你可能会遇到这种接口:user/:id
,:id
是可变的,那么你只需这样定义你的API
类型
interface IUserAPI {
user: {
[key: number]: {
get: () => any
}
}
}
然后这样调用:
const id: number = 123
http.user[id].get()
一些建议
当我们封装好后肯定不希望小组内其他人员动用核心的代码,所以可以试着把IMixinAPI
单独放到一个文件mixin.d.ts
里,相同的也可以把自定义函数用一个专门mixin
对象引入而不是直接写在http
的Proxy
上,如果你想把类型和自定义函数放到同一个文件里注意要把mixin.d.ts
改成mixin.ts
结语
这次重新搭建一个项目架构的收获还是挺多的,除了对API
管理的一些看法还有像:
react router6
的一些简单封装:路由守卫,懒加载,懒加载loading,类似vue
的meta
元信息等redux toolkit
的封装,持久化- 公共组件和全局状态的一些揉合
- 还有其他一些奇奇怪怪的封装 等后续整理好了再一一给大伙分享分享
这次之所以要把对API
的管理换成用interface
的风格来写,还有一个原因是为了让小组内其他成员养成写类型的习惯,之前的方式,总有人喜欢any
一把梭,给他们用不仅让ts
的优势荡然无存,甚至使代码变得更加臃肿,这显然是我们不愿意看到的.
好了,废话就说到这了,如果各位XDM
对于项目API
管理这一块有什么好的想法不妨在评论区也分享分享让我也学习学习😏
转载自:https://juejin.cn/post/7030714684764323877