在本章,会继续精进Nest.js,首先会学习IOC控制反转,这是一种设计思想,把对象的创建和依赖关系交给容器管理,而不是手动创建和管理。
- 传统方式:手动创建new A(),new B()。
- IOC方式:需求由容器解决。
现在我有A类和B类两个类,A类中有getName()实例方法,我若想在B类中调用A类的getName()实例方法,那么传统方式需要我们在B类中去手动实例化A类,然后再从实例对象中调用getName()实例方法。
ts
class A {
getName() {
return 'A'
}
}
class B {
constructor() {
// 手动实例化
let a = new A()
console.log(a.getName())
}
}
那么,在B类中手动实例化A类并调用对应实例方法的行为是会导致两个类产生高度耦合的关系,假设我再次手动实例化B类,会导致依赖关系混乱与维护困难,例如无法对A进行模拟或者替换;每个A实例状态都独立,难以统一管理;还有存在内存泄漏等风险。
IOC控制反转就是为了解决上述的高度耦合问题,即对A类与B类之间的关系进行解耦。
首先我们需要创建一个Container类,在Container类中存放所有实例化的操作,然后其余类如果需要相关类的实例化从而调用对应的方法,就由Container类来分配。
ts
class A {
getName() {
return 'A依赖注入生效'
}
}
class B {
}
class Container{
// 存储所有需要的实例化操作
}
那么,在Container类(容器)中要如何存储实例化操作?这需要依赖第三方库reflect-metadata,通过npm命令下载。
ts
npm install reflect-metadata
当我们在该文件中导入第三方库reflect-metadata之后,可以使用Reflect.getMetadata()静态方法。需要说明的是,Reflect标准内置对象中并没有getMetadata()静态方法,因此这个静态方法不是原生,而是来自第三方库reflect-metadata。
ts
import 'reflect-metadata'
class Container {
private providers = new Map<any, any>()
get(clsz: new (...args: any[]) => any) {
const paramsTypes = Reflect.getMetadata('design:paramtypes', clsz)
}
}
直接使用Reflect.getMetadata()静态方法是没有效果的,我们还要在需要依赖的类上,添加一个@Injectable()装饰器。
装饰器是一个特殊的函数,使用方式是放在类的头上。例如:现在我创建一个Injectable函数,然后使用在A类上。
ts
// 装饰器本质上是一个函数
const Injectable = () => {
return (target: any) => {
console.log('Injectable装饰器读取到:',target)
return target
}
}
// 使用 @ 符号应用装饰器
@Injectable()
class A {
getName() {
return 'A依赖注入生效'
}
}
装饰器的执行时机是在编译时,即运行文件的时候就自动调用,因此当我执行index.ts文件时,Injectable函数就会调用,并且打印出读取到的A类信息,如图5-1所示。使用装饰器的好处是什么?是可以在不破坏A类代码本身的结构基础上,对A类进行额外的拓展。

图5-1 装饰器的基础使用
当我们所使用的Injectable函数处于以下两种情况之一时,会被称为装饰器:
(1)被用于装饰器模式,即可以附加到类、方法、属性等上,以修改或增强其行为。
(2)或者该函数符合装饰器的调用签名,即它接受一个目标对象(根据装饰的对象类型不同,参数也不同)并可能返回一个新的描述符或修改后的目标。
而Injectable装饰器既然可以读取到A类,那么就可以在A类的原型链上添加新的实例方法。例如以下操作:使用Injectable装饰器对A类添加一个getName2的实例方法并调用执行。
ts
const Injectable = () => {
return (target: any) => {
target.prototype.getName2 = () => {
return 'getName2'
}
console.log(target.prototype.getName2())
return target
}
}
@Injectable()
class A {
getName() {
return 'A依赖注入生效'
}
}
装饰器的方法使用如图5-2所示,可以在装饰器上添加方法并使用,若需要参数,还可以从@Injectable()的实参位置传入。

图5-2 装饰器的方法使用
但装饰器存在一个问题,若Injectable装饰器的方法和A类中的方法名称一样,就会出现Injectable装饰器方法覆盖A类实例方法的问题。因为装饰器在编译时执行,则先执行装饰器代码实现原型链覆盖并执行getName()方法,而后调用实例对象a的getName()方法,因此执行两次装饰器中的getName()方法。
ts
const Injectable = () => {
return (target: any) => {
target.prototype.getName = () => {
return 'getName2'
}
console.log(target.prototype.getName())
return target
}
}
@Injectable()
class A {
getName() {
return 'A依赖注入生效'
}
}
const a = new A()
console.log(a.getName())
// getName2
// getName2
为了解决装饰器覆盖原方法的问题,TypeScript推出了元编程的概念,它希望我们避免直接修改原型。解决方案是使用元数据反射(Reflect Metadata)来实现元编程。而元数据反射的底层确实使用了WeakMap来存储元数据,这样可以避免内存泄漏(弱引用),并且不会直接影响对象的原型链。
那么什么是元数据反射?元数据反射是一种在对象上添加元数据(关于数据的数据)的方式,这些元数据不会直接成为对象的一部分,而是存储在独立的WeakMap中。这样,我们可以给对象附加额外的信息,而不改变其结构。
那么如何使用元数据反射?即通过Reflect.getMetadata('design:paramtypes', clazz)静态方法(需要安装第三方库)。
首先要说明Reflect.getMetadata('design:paramtypes', clazz)静态方法有两个参数,其中参数1有三个内置选项:
(1)design:paramtypes 获取构造函数的参数类型。
(2)design:returntype 获取构造函数的返回类型。
(3)design:type 获取构造函数的类型。
第1个参数是一个固定的字符串,字符串可以有以上三种内置选项,或者自定义也可以。
第2个参数是目标类,我们要读取哪个哪个类的元数据。因此说如果不在目标类上使用装饰器的话,Reflect.getMetadata()静态方法就没有目标类可以读取元数据,自然是无法生效的。
我们来使用一下元数据反射,以下要做的需求:
- 通过元数据反射,在B类中使用A类的实例方法。
ts
// tsconfig.json中打开这两个选项,从而实现Reflect.getMetadata()静态方法的使用
"experimentalDecorators": true
"emitDecoratorMetadata": true
思路是很清晰的,我们通过design:paramtypes获取构造函数的参数类型来实现。步骤如下:
(1)在A类与B类使用装饰器,而装饰器执行时,TypeScript会生成元数据。
(2)在容器中读取类的构造函数参数类型信息(拿到类本身)。
(3)拿到类之后,通过类创建实例对象完成了依赖注入,塞到Map的键值对里,键是被读取的类,值是被读取类中的构造函数参数类型的实例对象。
在B类的构造函数中设置一个参数,该参数的类型是A类,装饰器被应用,TypeScript编译器为被装饰B类生成元数据,我们通过Reflect.getMetadata()静态方法配合第一个参数design:paramtypes可以从B类读取到构造函数的参数a的类型A类,当装饰器读取到A类后,通过A类创建实例对象instance,然后和B类一起存到providers中。
ts
// 完整IOC控制反转实现
import 'reflect-metadata'
const Injectable = () => {
return (target: any) => {
return target
}
}
@Injectable()
class A {
getName() {
return 'A依赖注入生效'
}
}
@Injectable()
class B {
constructor(private a: A) {
console.log(this.a.getName())
}
}
class Container {
private providers = new Map<any, any>()
get(clsz: new (...args: any[]) => any) {
const parmsTypes = Reflect.getMetadata('design:paramtypes', clsz) || []
// 递归创建实例对象,完成了依赖注入
const instance = new clsz(...parmsTypes.map((type: any) => this.get(type)))
this.providers.set(clsz, instance)
return instance
}
}
const container = new Container()
// 多次请求B类,会直接从providers中返回缓存的实例
container.get(B)
其实当我们完成递归创建实例对象之后,B类就能够调用A类的getName()实例方法了。因为做到了B实例的a属性现在指向A实例。
ts
// 当调用 container.get(B) 时:
// 1. 创建 A 实例
const aInstance = new A();
// aInstance 是 A 类的一个具体实例,有 getName() 方法
// 2. 创建 B 实例并注入
const bInstance = new B(aInstance); // aInstance 作为参数传递
// 3. B 构造函数内部执行
class B {
// TypeScript 语法糖展开为:
// constructor(a: A) {
// this.a = a; // this.a 被赋值为传入的 aInstance
// }
// 所以实际上:
// this.a = aInstance
// 因此:
// this.a.getName() 等于 aInstance.getName()
}
通过@Injectable()装饰器,TypeScript在编译时为B类记录了构造函数参数类型(A类)。在运行时,依赖注入容器读取这些元数据,递归创建A类的实例,并将其注入到B类的构造函数中。因此,B类的实例可以通过 this.a.getName() 访问A类的实例方法。
后续将实例对象放入providers(Map数据结构)中是利用Map结构的键唯一特性,当我们第一次请求B类时,容器会创建B类的实例,并且发现B类 依赖A类,于是先创建或获取A类的实例,然后将B类和其实例存入providers。下次再请求B类时,就直接从providers中返回缓存的实例,而不会再次创建。
接下来,我们来优化一下容器注入,假如有多个不同类(B类、B1类、B2类、B3类)同时注入了A类,那么这会不断的重复注入到Map中,因为Map结构只保证Key唯一。这就没有必要,因此我们可以在最前面加一个判断,如果在Map中已经存在注入的类(例如A类),那么我们就直接返回注入的类就行。
ts
class Container {
private providers = new Map()
get(clsz: new (...args: any[]) => any) {
// 优化:防止注入重复
if (this.providers.has(clsz)) {
return this.providers.get(clsz)
}
const parmsTypes = Reflect.getMetadata('design:paramtypes', clsz) || []
const instance = new clsz(...parmsTypes.map((type: any) => this.get(type)))
this.providers.set(clsz, instance)
return instance
}
}
以上就是IOC控制反转实现的全过程,最核心和精彩的代码就以下这条:完成了拆解高耦合依赖的工作。
ts
const instance = new clsz(...parmsTypes.map((type: any) => this.get(type)))