Express-router中的get方法是如何使用ts约束的?

632 阅读6分钟

本文正在参加「金石计划」

hey🖐! 我是小黄瓜😊😊。不定期更新,期待关注➕ 点赞,共同成长~

起源

在上一篇文章中,最后实现了一个小案例,使用装饰器实现get的接口请求,

详细可见:juejin.cn/post/721299…

下面是这个小Demo的简略版:

import { Router } from 'express'
export const router: Router = Router();
type MethodType = "get" | "post"

// 封装方法装饰器
function reqMethodDecorator(methodType: MethodType) {
  return function (path: string): MethodDecorator {
    return (targetPrototype, methodname) => {
      Reflect.defineMetadata('path', path, targetPrototype, methodname)
      Reflect.defineMetadata('methodtype', methodType, targetPrototype, methodname)
    }
  }
}

export const Get = reqMethodDecorator("get")

@Controller("/")
class FoodController {
  @Get("/showFood/:foodname/:price")
  showFood(req: Request, res: Response): void {
    res.setHeader("Content-Type", "text/html; charset=utf-8")
    let foodname = req.params.foodname
    let price = req.params.price
    res.write(`food:${foodname}`);
    res.write(`food:${price}`);
    res.write("very good");
    res.write("nice")

    res.end();
  }
}

export function Controller(rootPath: string): ClassDecorator {
  return (target) => {
    for(let methodName in target.prototype) {
      let routerPath = Reflect.getMetadata('path', target.prototype, methodName)
      let reqName: MethodType = Reflect.getMetadata('methodtype', target.prototype, methodName)
      const targetMethodfunc: RequestHandler = target.prototype[methodName];

      if(routerPath && reqName) {
        router[reqName](routerPath, targetMethodfunc)
      }
    }
  }
}

简单来说就是使用实例方法装饰器注册请求路径和请求方式,然后使用类装饰器获取所有的方法,通过定义在实例方法上的元数据来对信息进行组合和提取,完成get请求的实现逻辑。

get请求函数中,支持在url中使用 : 来传递参数,然后将获取到的foodnameprice这两个参数作为返回值写入到页面中。

@Controller("/")
class FoodController {
  @Get("/showFood/:foodname/:price")
  showFood(req: Request, res: Response): void {
    res.setHeader("Content-Type", "text/html; charset=utf-8")
    let foodname = req.params.foodname
    let price = req.params.price
    res.write(`food:${foodname}`);
    res.write(`food:${price}`);
    res.write("very good");
    res.write("nice")

    res.end();
  }
}

get的处理函数中,我们通过req这个参数获取到了传入的foodnameprice这两个属性,然后写入页面返回。

WX20230321-142212@2x.png

@Get("/showFood/:foodname/:price")

// 也就相等于

router.get('/showFood/:foodname/:price')

当我们把鼠标放到get方法上的时候,神奇的事情发生了,ts已经对转化后的结果进行了约束!

image.png

查找源码

那么express是如何实现对字符串进行自动提取的呢,首先找到router这个方法:

import { Router } from 'express'
export const router: Router = Router();

routerexpress中提供的Router方法的返回结果,使用Router约束,那么就来看看Router这个类型是怎样定义的:

  interface Router extends core.Router {}

可以看到在express内部Router继承了core.Router,由于core是有一整个文件导出,所以继续在core文件下寻找Router类型:

import * as core from 'express-serve-static-core';

Router继承自IRouter

export interface Router extends IRouter {}

IRouter接口中定义了所有的方法:

export interface IRouter extends RequestHandler {
    param(name: string, handler: RequestParamHandler): this;

    /**
     * Alternatively, you can pass only a callback, in which case you have the opportunity to alter the app.param()
     *
     * @deprecated since version 4.11
     */
    param(callback: (name: string, matcher: RegExp) => RequestParamHandler): this;

    /**
     * Special-cased "all" method, applying the given route `path`,
     * middleware, and callback to _every_ HTTP method.
     */
    all: IRouterMatcher<this, 'all'>;
    get: IRouterMatcher<this, 'get'>;
    post: IRouterMatcher<this, 'post'>;
    put: IRouterMatcher<this, 'put'>;
    delete: IRouterMatcher<this, 'delete'>;
    patch: IRouterMatcher<this, 'patch'>;
    options: IRouterMatcher<this, 'options'>;
    head: IRouterMatcher<this, 'head'>;

    // 省略...
}

这里我们只寻找get相关,进入到 IRouterMatcher接口:


export interface IRouterMatcher<
    T,
    Method extends 'all' | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head' = any
> {
    <
        Route extends string,
        P = RouteParameters<Route>,
        ResBody = any,
        ReqBody = any,
        ReqQuery = ParsedQs,
        LocalsObj extends Record<string, any> = Record<string, any>
    >(
        // (it's used as the default type parameter for P)
        // eslint-disable-next-line no-unnecessary-generics
        path: Route,
        // (This generic is meant to be passed explicitly.)
        // eslint-disable-next-line no-unnecessary-generics
        ...handlers: Array<RequestHandler<P, ResBody, ReqBody, ReqQuery, LocalsObj>>
    ): T;

     // 省略...

    (path: PathParams, subApplication: Application): T;
}

IRouterMatcher接口是一个重载函数,这里依然只关注与get相关,最后可以简化为:

<
    Route extends string,
    P = RouteParameters<Route>,
>(
    path: Route,
    ...handlers: Array<RequestHandler<P, ResBody, ReqBody, ReqQuery, LocalsObj>>
): any;

泛型Route就是我们传入的url地址,这里为参数path的约束,而泛型参数P是使用RouteParameters类型传入url处理约束。

RouteParameters类型中对url处理:

export interface ParamsDictionary {
  [key: string]: string;
}

type RemoveTail<S extends string, Tail extends string> = S extends `${infer P}${Tail}` ? P : S;
type GetRouteParameter<S extends string> = RemoveTail<
    RemoveTail<RemoveTail<S, `/${string}`>, `-${string}`>,
    `.${string}`
>;

// prettier-ignore
export type RouteParameters<Route extends string> = string extends Route
    ? ParamsDictionary
    : Route extends `${string}(${string}`
        ? ParamsDictionary //TODO: handling for regex parameters
        : Route extends `${string}:${infer Rest}`
            ? (
            GetRouteParameter<Rest> extends never
                ? ParamsDictionary
                : GetRouteParameter<Rest> extends `${infer ParamName}?`
                    ? { [P in ParamName]?: string }
                    : { [P in GetRouteParameter<Rest>]: string }
            ) &
            (Rest extends `${GetRouteParameter<Rest>}${infer Next}`
                ? RouteParameters<Next> : unknown)
            : {};

这就是传说中的类型体操了😂,接下来就一块拆解并学习它。

拆解

首先来看一下RouteParameters类型的定义:

type RouteParameters<Route extends string>

extends

extends在泛型参数中通常用于判断是否属于某个类型,例如:

type test1 = 1 extends number ? 'y' : 'n'
type test2 = '2' extends number ? 'y' : 'n'
type test3 = '3' extends string ? 'y' : 'n'

而在typescript中,还存在一种分布式条件判断,如果判断表达式左侧是使用泛型传入,而且参数类型是一个联合类型,就会出发联合类型:

type test44 = 1 | 3
type test4<T> = T extends 1 | 2 | 4 | 5 ? 'y' : 'n'
type test5 = test4<test44>

试想一下test5会是什么类型?可能大部分人都会说是n,那就来看一下: image.png

test5的类型被推断为"y" | "n",这是为什么呢?在typescript中,对联合类型的判断是分别进行的,比如上面实际执行是这样的:

type t1 = 1 extends 1 | 2 | 4 | 5 ? 'y' : 'n'
type t2 = 3 extends 1 | 2 | 4 | 5 ? 'y' : 'n'

type test5 = t1 | t2 // "y" | "n"

如果不想使用这种特性,可以使用[]包裹:

type test44 = 1 | 3
type test4<T> = [T] extends [1 | 2 | 4 | 5] ? 'y' : 'n'
type test5 = test4<test44>

image.png

RouteParameters泛型参数中 extendsRoute泛型参数约束为string

接下来看第一个判断,后续逻辑以xxx代替:

string extends Route
    ? ParamsDictionary
    : 
    // xxx

在泛型参数定义的时候已经被约束为Route extends string了,为啥这里还要再判断string extends Route?这里是为了判断是否是直接传入了一个string类型,因为只有传入url的字符串字面量类型我们才会进行处理。

ParamsDictionary其实是定义了索引签名类型,一个key数量不定,类型为string,值为string的对象:

export interface ParamsDictionary {
  [key: string]: string;
}

typescript 中使用 [key: any]来定义不确定数量的 key,在此对象上可以任意定义值:

let obj: ParamsDictionary = {
    name: 'gua',
    addr: 'abc'
}
obj.other = 'aa'

然后看一下下一个判断:

Route extends `${string}(${string}`
    ? ParamsDictionary //TODO: handling for regex parameters
    : 
    // xxx

这里主要判断 url字符串中是否包含(,如果存在(,依然返回ParamsDictionary。 下面就是到了核心的处理逻辑:

Route extends `${string}:${infer Rest}`
    ? (
    GetRouteParameter<Rest> extends never
        ? ParamsDictionary
        : GetRouteParameter<Rest> extends `${infer ParamName}?`
            ? { [P in ParamName]?: string }
            : { [P in GetRouteParameter<Rest>]: string }
    ) &
    (Rest extends `${GetRouteParameter<Rest>}${infer Next}`
        ? RouteParameters<Next> : unknown)
    : {};

在进入到主逻辑之前先来了解一下其中的几个辅助类型:GetRouteParameterRemoveTail

因为RemoveTail类型是最底层的调用,所以先来看一下这个类型:

type RemoveTail<S extends string, Tail extends string> = S extends `${infer P}${Tail}` ? P : S;

RemoveTail接收两个泛型参数:S 和 Tail,全都约束为string类型,然后使用条件类型判断S是否是一个模版类型的子类型。

模版类型

那么条件类型是怎样判断呢,咱们先从模版类型入手,extends的右侧是一个模版字符串的语法:

S extends `${infer P}${Tail}`

typescript中可以使用``模版语法符号进行类型匹配,比如:

type str1 = `hello ${string}`

const c1: str1 = 'hello mi'

这句话的意思是约束一个空格前为hello 的字符串字面量类型和空格后的任意string类型,如果我们定义一个别的字符串,那么会提示类型错误:

const c2: str1 = 'hihaha'

image.png

或者还可以更灵活一点,使用泛型来定义:

type str2<T extends string> = `hello ${T}`

const c3: str2<'gua'> = 'hello gua'

我们可以更佳精确的控制字符串字面量类型。

infer

而在这个类型约束中还用到了关键字infer,这也是在typescript中非常重要的内容。infer的功能是“提取”,可以提取字符串,数组,函数的置顶内容:

  • 字符串

获取字符串指定位置的字符串

type test2 = 'hello-gua' extends `${infer S}-${string}` ? S : never

image.png

  • 数组

获取数组第一项

type test1 = [1, 2, 3, 4] extends [infer R, ...unknown[]] ? R : never

image.png

获取最后一项

type test1 = [1, 2, 3, 4] extends [...unknown[], infer R] ? R : never

image.png

  • 函数

获取参数类型

type test5 = ((name: string) => any) extends ((name: infer A) => any) ? A : never

image.png

获取函数返回值类型

type test6 = ((name: string) => boolean) extends ((...args: any[]) => infer R) ? R : never

image.png

在看完模版类型和infer之后就可以来正式看一下我们的RemoveTail类型了,其实在了解完前面的知识之后再看RemoveTail就很好理解了:

type RemoveTail<S extends string, Tail extends string> = S extends `${infer P}${Tail}` ? P : S;

RemoveTail类型是实现的功能就是删除指定的字符串其中泛型Tail是要被删掉的字符串,使用infer提取剩下的字符串:

type test66 = RemoveTail<'hello-gua', '-gua'>

image.png

那么调用他的GetRouteParameter类型就更好理解了:

type GetRouteParameter<S extends string> = RemoveTail<
    RemoveTail<RemoveTail<S, `/${string}`>, `-${string}`>,
    `.${string}`
>;

使用RemoveTail依次匹配删除一段字符串中的 /(后面的字符串),-(后面的字符串),.(后面的字符串)。 因为我们并不知道一段字符串中三个符号在什么位置,所以调用三次RemoveTail删除三个符号后面的字符串内容。 假如有字符串hello-gua/abc.cde

type test77 = GetRouteParameter<'hello-gua/abc.cde'>

image.png

然后回到正题:

Route extends `${string}:${infer Rest}`
    ? (
    GetRouteParameter<Rest> extends never
        ? ParamsDictionary
        : GetRouteParameter<Rest> extends `${infer ParamName}?`
            // 处理可选属性
            ? { [P in ParamName]?: string }
            : { [P in GetRouteParameter<Rest>]: string }
    ) &
    (Rest extends `${GetRouteParameter<Rest>}${infer Next}`
        ? RouteParameters<Next> : unknown)
    : {};

首先url字符串提取 :后面的所有字符串,如果我们有字符串 /showFood/:foodname/:price,那么本次处理之后,剩下的Restfoodname/:price

接下来判断取出的值是否为never?显然这里不是,于是接着往下处理,调用GetRouteParameterGetRouteParameter会依次删除 /``.``-后面的字符串,于是最后只剩下foodname,最后取出foodname,判断是否包含?url,因为在url中支持配置可选属性:

router.get('/showFood/:foodname?/:price')

// 最后会提取为

{
  foodname?: string
} & {
  price: string
}

最后使用 in来映射为对象结构。

in

in为映射类型,右侧一般会跟一个联合类型,使用in操作符可以对该联合类型进行迭代。 其作用类似js中的for...in或者for...of

type Animals = 'pig' | 'cat' | 'dog'

type animals = {
    [key in Animals]: string
}
// type animals = {
//     pig: string; //第一次迭代
//     cat: string; //第二次迭代
//     dog: string; //第三次迭代
// }

最后终于处理完了&左侧的内容,也就是第一个属性,接下来继续递归调用RouteParameters向后处理,那么如何判断已经处理完的内容呢?依旧是模板类型做提取:

(Rest extends `${GetRouteParameter<Rest>}${infer Next}`)

相当于:

(Rest extends `foodname${infer Next}`)

提取到的Next/:price,然后继续递归处理。整体过程如下:

无标题-2023-03-23-1631.png

快乐的玩耍

那么如何应用到我们的日常开发中呢,比如现在我们也有一个实现解析字符串的功能:

type StrType = <
    Route extends string
>(
    path: Route,
) => RouteParameters<Route>;

const testfn: StrType = function (path) {
    const strAry = path.split('/')

    return strAry.reduce((cur, next)=>{
        if(next.includes(":")) {
            const key = next.slice(1)
            // 简单处理value为a
            cur[key] = 'a'
        }
        return cur
    }, {} as any)
}

const str = '/showPeople/:name/:age/:addr'
let p = testfn(str)

如果将这个类型应用到我们的方法中,那么我们在使用执行结果的返回值时,会自动获取typescript的智能提示: image.png

只能说学好typescript类型体操,真香! 😂😂 image.png

写在最后 ⛳

未来可能会更新typescriptreact基础知识系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳