🎛️NestJS 学习 —— IoC & TS装饰器
00 分钟
2023-4-5
2023-4-12
type
status
date
slug
summary
tags
category
icon
password
Edited
Apr 12, 2023 01:51 PM
Created
Apr 12, 2023 08:57 AM
原文

TS 装饰器

TS装饰器的那些事儿

首先我们需要知道,JS与TS中的装饰器不是一回事,JS中的装饰器目前依然停留在 stage 2 阶段,并且目前版本的草案与TS中的实现差异相当之大(TS是基于第一版,JS目前已经第三版了),所以二者最终的装饰器实现必然有非常大的差异。
其次,装饰器不是TS所提供的特性(如类型、接口),而是TS实现的ECMAScript提案(就像类的私有成员一样)。TS实际上只会对stage-3以上的语言提供支持,比如TS3.7.5引入了可选链(Optional chaining)与空值合并(Nullish-Coalescing)。而当TS引入装饰器时(大约在15年左右),JS中的装饰器依然处于 stage-1 阶段。其原因是TS与Angular团队PY成功了,Ng团队不再维护 AtScript,而TS引入了注解语法(Annotation)及相关特性。
但是并不需要担心,即使装饰器永远到达不了stage-3/4阶段,它也不会消失的。有相当多的框架都是装饰器的重度用户,如AngularNestMidway等。对于装饰器的实现与编译结果会始终保留,就像JSX一样。如果你对它的历史与发展方向有兴趣,可以读一读 是否应该在production里使用typescript的decorator?(贺师俊贺老的回答)
为什么我们需要装饰器?在后面的例子中我们会体会到装饰器的强大与魅力,基于装饰器我们能够快速优雅的复用逻辑提供注释一般的解释说明效果,以及对业务代码进行能力增强。同时我们本文的重点:依赖注入也可以通过装饰器来非常简洁的实现。现在我们可能暂时体会不到 强大简洁 这些关键词,不急,安心读下去。我会尝试通过这篇文章让你对TS装饰器整体建立起一个认知,并在日常开发里也爱上使用装饰器。

装饰器与注解

由于我本身并没学习过Java以及Spring IoC,因此我的理解可能存在一些偏差,还请在评论区指出错误之处~
装饰器与注解实际上也有一定区别,由于并没有学过Java,这里就不与Java中的注解进行比较了。而只是说我所认为的二者差异:
  • 注解 应该如同字面意义一样, 只是为某个被注解的对象提供元数据(metadata)的注入,本质上不能起到任何修改行为的操作,需要scanner去进行扫描获得元数据并基于其去执行操作,注解的元数据才有实际意义。
  • 装饰器 没法添加元数据,只能基于已经由注解注入的元数据来执行操作,来对类、方法、属性、参数进行某种特定的操作。
但实际上,TS中的装饰器通常是同时包含了这两种效能的,它可能消费元数据的同时也提供了元数据供别的装饰器消费。

不同类型的装饰器及使用

在开始前,你需要确保在tsconfig.json中设置了experimentalDecorators与emitDecoratorMetadata为true。
首先要明确地是,TS中的装饰器实现本质是一个语法糖,它的本质是一个函数,如果调用形式为@deco(),那么这个函数应该再返回一个函数来实现调用。
其次,你应该明白ES6中class的实质,如果不明白,推荐阅读我的这篇文章: 从Babel编译结果看ES6的Class实质

类装饰器

我们发现,在以单纯装饰器方式@addProp调用时,不管用它来装饰哪个类,起到的作用都是相同的,因为其中要复用的逻辑是固定的。我们试试以@addProp()的方式来调用:
现在我们想要添加的属性值就可以由我们决定了, 实际上由于我们拿到了原型对象,还可以进行花式操作,能够解锁更多神秘姿势~

方法装饰器

方法装饰器的入参为 类的原型对象 属性名 以及属性描述符(descriptor),其属性描述符包含writable enumerable configurable ,我们可以在这里去配置其相关信息。
注意,对于静态成员来说,首个参数会是类的构造函数。而对于实例成员(比如下面的例子),则是类的原型对象
你是否觉得有点想起来Object.defineProperty()? 的确方法装饰器也是借助它来修改类和方法的属性的,你可以去TypeScript Playground看看TS对上面代码的编译结果。

属性装饰器

类似于方法装饰器,但它的入参少了属性描述符。原因则是目前没有方法在定义原型对象成员同时去描述一个实例的属性(创建描述符)。
属性与方法装饰器有一个重要作用是注入与提取元数据,这点我们在后面会体现到。

参数装饰器

参数装饰器的入参首要两位与属性装饰器相同,第三个参数则是参数在当前函数参数中的索引
参数装饰器与属性装饰器都有个特别之处,他们都不能获取到描述符descriptor,因此也就不能去修改其参数/属性的行为。但是我们可以这么做:给类原型添加某个属性,携带上与参数/属性/装饰器相关的元数据,并由下一个执行的装饰器来读取。 (装饰器的执行顺序请参见下一节)
当然像例子中这样直接在原型上添加属性的方式是十分不推荐的,后面我们会使用ES7的Reflect Metadata来进行元数据的读/写。

装饰器工厂

假设现在我们同时需要四种装饰器,你会怎么做?定义四种装饰器然后分别使用吗?也行,但后续你看着这一堆装饰器可能会感觉有点头疼...,因此我们可以考虑接入工厂模式,使用一个装饰器工厂来为我们根据条件吐出不同的装饰器。
首先我们准备好各个装饰器函数:
(不建议把功能也写在装饰器工厂中,会造成耦合)
接着,我们实现一个工厂函数来根据不同条件返回不同的装饰器:
(注意,这里在TS类型定义上似乎有些问题,所以需要带上顶部的@ts-nocheck,在后续解决了类型报错后,我会及时更新的TAT)

多个装饰器声明

装饰器求值顺序来自于TypeScript官方文档一节中的装饰器说明。
类中不同声明上的装饰器将按以下规定的顺序应用:
  1. 参数装饰器,然后依次是_方法装饰器_,访问符装饰器,或_属性装饰器_应用到每个实例成员。
  1. 参数装饰器,然后依次是_方法装饰器_,访问符装饰器,或_属性装饰器_应用到每个静态成员。
  1. _参数装饰器_应用到构造函数。
  1. _类装饰器_应用到类。
注意这个顺序,后面我们能够实现元数据读写,也正是因为这个顺序。
当存在多个装饰器来装饰同一个声明时,则会有以下的顺序:
  • 首先,由上至下依次对装饰器表达式求值,得到返回的真实函数(如果有的话)
  • 而后,求值的结果会由下至上依次调用
(有点类似洋葱模型)

Reflect Metadata

基本元数据读写

Reflect Metadata是属于ES7的一个提案,其主要作用是在声明时去读写元数据。TS早在1.5+版本就已经支持反射元数据的使用,目前想要使用,我们还需要安装reflect-metadata与在tsconfig.json中启用emitDecoratorMetadata选项。
你可以将元数据理解为用于描述数据的数据,如某个对象的键、键值、类型等等就可称之为该对象的元数据。我们先不用太在意元数据定义的位置,先做一个简单的阐述:
为类或类属性添加了元数据后,构造函数的原型(或是构造函数,根据静态成员还是实例成员决定)会具有[[Metadata]]属性,该属性内部包含一个Map结构,键为属性键,值为元数据键值对
reflect-metadata提供了对Reflect对象的扩展,在引入后,我们可以直接从Reflect对象上获取扩展方法。
文档见 reflect-metadata,但不用急着看,其API命令还是很语义化的
可以看到,我们给类D与D内部的方法hello都注入了元数据,并通过getMetadata(metadataKey, target)这个方式取出了存放的元数据。
Reflect-metadata支持命令式(Reflect.defineMetadata)与声明式(上面的装饰器方式)的元数据定义
我们注意到,注入在类上的元数据在取出时target为这个类D,而注入在方法上的元数据在取出时target则为实例d。原因其实我们实际上在上面的装饰器执行顺序提到了,这是由于注入在方法、属性、参数上的元数据实际上是被添加在了实例对应的位置上,因此需要实例化才能取出。

内置元数据

Reflect允许程序去检视自身,基于这个效果,我们可以在装饰器运行时去检查其类型相关信息,如目标类型、目标参数的类型以及方法返回值的类型,这需要借助TS内置的元数据metadataKey来实现,以一个检查入参的例子为例:
访问符装饰器的属性描述符会额外拥有get与set方法,其他与属性装饰器相同
这个例子来自于TypeScript官方文档,但实际上不能正常执行。因为在经过装饰器处理后,set方法的this将会丢失。但我猜想官方的用意只是展示design:type的用法。
在这个例子中,我们基于Reflect.getMetadata('design:type', target, propertyKey);获取到了装饰器对应声明的属性类型,并确保在setter被调用时检查值类型。
这里的 design:type 即是TS的内置元数据,你可以理解为TS在编译前还手动执行了@Reflect.metadata("design:type", Point)。TS还内置了**design:paramtypes(获取目标参数类型)design:returntype(获取方法返回值类型)**这两种元数据字段来提供帮助。但有一点需要注意,即使对于基本类型,这些元数据也返回对应的包装类型,如number -\> [Function: Number]

IoC

IoC、依赖注入、容器

IoC的全称为 Inversion of Control,意为控制反转,它是OOP中的一种原则(虽然不在n大设计模式中,但实际上IoC也属于一种设计模式),它可以很好的解耦代码。
在不使用IoC的情况下,我们很容易写出来这样的代码:
乍一看可能没什么,但实际上类C会强依赖于A、B,造成模块之间的耦合。要解决这个问题,我们可以这么做:用一个第三方容器来负责管理容器,当我们需要某个实例时,由这个容器来替我们实例化并交给我们实例。以Injcetion为例:
现在A、B、C之间没有了耦合,甚至当某个类D需要使用C的实例时,我们也可以把C交给IoC容器。
我们现在能够知道IoC容器大概的作用了:容器内部维护着一个对象池,管理着各个对象实例,当用户需要使用实例时,容器会自动将对象实例化交给用户。
再举个栗子,当我们想要处对象时,会上Soul、Summer、陌陌...等等去一个个找,找哪种的与怎么找是由我自己决定的,这叫 控制正转。现在我觉得有点麻烦,直接把自己的介绍上传到世纪佳缘,如果有人看上我了,就会主动向我发起聊天,这叫 控制反转
DI的全称为Dependency Injection,即依赖注入。依赖注入是控制反转最常见的一种应用方式,就如它的名字一样,它的思路就是在对象创建时自动注入依赖对象。再以Injection的使用为例:
我们不需要在构造函数中去手动this.userModel = xxx了,容器会自动帮我们做这一步。

实例: 基于IoC的路由简易实现

我们在最开始介绍了MidwayJS的路由机制,大概长这样:
@provide()来自于底层的IoC支持Injection,Midway在应用启动时会去扫描被@provide()装饰的对象,并装载到容器中,这里不是重点,可以暂且跳过,我们主要关注如何将装饰器路由解析成路由表的形式)
我们要解析的路由如下:
首先思考controllerget/post装饰器,我们需要使用这几个装饰器注入哪些信息:
  • 路径
  • 方法(方法装饰器)
首先是对于整个类,我们需要将path: "/user"这个数据注入:
而后是方法装饰器,我们选择一个高阶函数(柯里化)去吐出各个方法的装饰器,而不是为每种方法定义一个。
接下来我们要做的事情就很简单了:
  • 拿到注入在类上元数据的根路径
  • 拿到每个方法上元数据的方法、路径
  • 拼接,生成路由表
生成的结果大概是这样:
基于这种思路,我们可以很容易的写一个使Koa支持IoC路由的工具。如果你有兴趣,不妨扩展一下。比如说路由还有可能长这样:
新增了几个地方:
  • 全局中间件
  • 路由级别中间件
  • 路由传参
要不要试试整活?
这个例子是否属于IoC机制的体现可能会有争议,但我个人认为Reflect Metadata的设计本身就是IoC的体现。如果你有别的看法,欢迎在评论区告知我。

依赖注入工具库

我个人了解并使用过的TS依赖注入工具库包括:
  • Injection,MidwayJS团队出品,是MidwayJS底层IoC的能力支持
其中TypeDI也是我日常使用较多的一个,如果你使用基本的Koa开发项目,不妨试一试TypeORM \+ TypeORM-TypeDI-Extensions 。我们再看看上面呈现过的Injection的例子:
实际上,一个依赖注入工具库必定会提供的就是 从容器中获取实例注入对象到容器中的两个方法,如上面的provideinject,TypeDI的ServiceInject
 

参考链接

  1. 走近MidwayJS:初识TS装饰器与IoC机制 - 掘金 (juejin.cn)
 
上一篇
NestJS 学习 —— 入门
下一篇
数据结构与算法 —— 技巧

评论
Loading...