Zapic's Blog
装饰器, HTTP请求与Typescript
2023-04-16
教程

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> { } // 我们没有返回东西, 这会报错!

我们可以创建一个工具类型, 用于声明返回值, 他要做到两点

  1. 保存返回类型
  2. 允许不返回任何东西

这两点听起来好像有点矛盾, 但是实际上我们可以通过联合类型非常轻松的做到这一点:

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