0. 简单的API封装
封装如下API:
GET /resource/{id}
Response
200 {
_id: string;
name: string;
}
404;
POST /resource
Payload {
name: string;
}
Response
201 {
_id: string;
name: string;
}
403;
实现:
class Client {
client: AxiosInstance;
constructor (baseUrl: string) {
this.client = axios.create({
baseUrl,
...
})
}
async request (method, url, options) {
return (await this.client.request({
method,
url,
...options
})).data;
}
get (url, params, options) {
return this.request("get", url, {
params,
...options
});
}
post (url, data, options) {
return this.request("post", url, {
data,
...options
})
}
}
class Resource extends Client {
constructor () {
super("/api/");
}
getById(id: string, page = 1, limit = 10) {
return this.get("/resource/{id}".replace("{id}", id), {
page,
limit
});
}
create(data) {
return this.post("/resource", data);
}
}
调用:
const resource = new Resource();
const data = await resource.getById(route.query.id);
const handleSubmit = async (data) => {
try {
const resp = await resource.create(data);
route.push(resp._id);
} catch(e) {
}
}
这很简单.
但很单调, 没有意思, 我们的代码里最后充斥着大量的this.get
this.post
, 还有一大堆无聊的方法, 无聊的参数创建.
我们的目标是得到一个炫酷的API封装写法.
1. 装饰器, 闪亮登场
风格设计
通过Nest.js获得灵感, 我们的目标是创建如下的代码风格:
class Resource extends Client {
// not cool, too old school
getById(id: string, page = 1, limit = 10): Promise<ResourceDto> {
return this.get("/resource/{id}".replace("{id}", id), {
page,
limit
});
}
// so cool, and clear
// GET /api/resource/1e45e4?page=1&limit=10
@Get("/resource/{id}")
getById(
@Param("id") id: string,
@Query("page") page = 1,
@Query("limit") limit = 10
): Promise<ResourceDto> { } // 最终目标是不再需要返回任何东西, 可以直接放个空括号在这里
}
@Get
装饰器能做的事情不是特别多, 因此需要发挥一点想象力, 才能实现这样的语法
先从@Get
写起:
const Get = (url: string) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
// 直接替换掉被装饰的方法, 使用我们自己的逻辑返回数据
// 这样方法体内写什么都无所谓, 可以直接不写
// !! 我们暂时还没有处理原来方法上的参数, 因此目前只能用于不带任何参数的请求
desciptor.value = function () {
return this.get(url);
}
}
}
用例
class Resource extends Client {
// ...
@Get("/resource/list")
list() {}
}
// ...
resource.list(); // 类型是void, 我们的方法体内没有返回任何东西, 也没有声明返回类型, 但实际上装饰器已经返回了东西.
// 但我们现在头脑清晰, 非常清楚实际代码到底发生了什么, 我们稍后再来处理类型问题
@Param
运行时没法知道参数的名字, 所以需要用参数装饰器去标记每一个参数, 记忆他们的位置和名字.
为了方便起见, 我们直接在原方法本体上起一个对象用来存储这些数据.
const QueryList = Symbol("Query List");
const Query = (name:string) => {
return (target: Object, propertyKey: string | symbol, parameterIndex: number) => {
if (!target[propertyKey][QueryList]) target[propertyKey][QueryList] = {};
target[propertyKey][QueryList][name] = parameterIndex; // key-index键值对
}
}
// 修改@Get, 读取参数列表
const Get = (url: string) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
desciptor.value = function (...args: unknown[]) {
const queryList = target[propertyKey][QueryList] || {};
Object.keys(paramList, key => {
queryList[key] = args[queryList[key]]; // 从key-index键值对还原出key-value
})
return this.get(url, queryList);
}
}
}
使用:
class Resource extends API { // extends仍然是必须的, 即使我们不再直接使用this.get, 但是装饰器内部替换的方法还在调用
@Get("/resource/list")
list(
@Param("page") page = 1,
@Param("limit") limit = 10,
) {}
}
// ...
// GET /api/resource/list?page=2&limit=30
resource.list(2, 30);
@其他的?
对于其他的, 例如@Payload
, @Param
, 实现方式也非常相近, 就是直接把参数index跟参数的name对应上(Payload没有name, 直接取出来合并到最后的Payload上), 然后存到方法体上, 最后在调用的时候从方法体上读取这些信息, 然后从参数列表里还原出他们的对应关系:
const ParamList = Symbol();
const Param = (name: string) => // ...
const Get = (url: name) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
desciptor.value = function (...args: unknown[]) {
// ...
let parsedUrl = url;
const paramList = target[propertyKey][ParamList] || {};
Object.keys(paramList, key => {
parsedUrl = parsedUrl.replaceAll(`{${key}}`, args[paramList[key]]);
})
return this.get(parsedUrl, queryList);
}
}
}
2. 类型体操
我们已经实现了所有的语法, 但是我们还没有解决类型问题.
@Get("/resource/{id}")
getById(
@Param("id") id: string,
@Query("page") page = 1,
@Query("limit") limit = 10
): Promise<ResourceDto> { } // 我们没有返回东西, 这会报错!
我们可以创建一个工具类型, 用于声明返回值, 他要做到两点
- 保存返回类型
- 允许不返回任何东西
这两点听起来好像有点矛盾, 但是实际上我们可以通过联合类型非常轻松的做到这一点:
type Returned<T> = void | Promise<T>; // void允许不返回任何数据, T保存了应当返回的类型
// ...
@Get("/resource/{id}")
getById(
@Param("id") id: string,
@Query("page") page = 1,
@Query("limit") limit = 10
): Returned<ResourceDto> { } // 这下可以不返回任何东西了
//...
const data = await resource.getById("1e45e4"); // Promise<ResourceDto> | void;
return data._id // 报错, void上不存在_id
// or
return data?.id // ok, but too sb
每次都拖个可选访问符也太蠢了, 想个办法把void
给去掉.
我们可以用类型推断从中提取类型:
type NoVoidAPI<T> = {
[P in keyof T]: T[P] extends (...args: infer A) => Returned<infer RR>
? (...args: A) => Promise<RR>
: T[P]
}
const createClient = <T extends BaseAPI>(
Inst: new (...args: any) => T
): ImplAPI<T> => {
return new Inst() as unknown as NoVoidAPI<T>;
};
const resource = creatClient(Resource);
const data = await resource.getById("1e45e4");
data._id // ok, _id: string
用Promise
做保存类型的容器可能会有点冲突, 我们可以捏一个没什么用的工具类型专门用来存储返回值:
// 这个玩意永远不会被用到, 因为我们的API封装在ts看来永远返回void(我们的方法体是空的)
interface ReturnedType<T> {
long_and_never_used_name_for_infer_type_from_this: T;
}
type Returned<T> = void | ReturnedType<T>;
完美.
3.复活赛打赢ESLint
我们的语法非常炫酷!
但ESLint有点碍事, 他可能会认为我们的args
完全没有被使用, 方法体还是空的屁事不干, 给你划一堆红线黄线
我们可以关掉这些提示, 但是我们不希望每次都要手动关, 也不希望这些规则作用到全局.
很简单, 为你的API源码文件单独建一个文件夹, 在里面放一个.eslintrc
单独定义这些规则
src
|- api
|- .eslintrc // 这里!
|- index.ts
|- ...
.eslintrc
{
"rules": {
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-unused-vars": "off"
}
}
榨批客来吃我一炮经验 :tieba9: