一间房子里面放了一张床,这个房子满足了用来睡觉的基本需求,在这个基础上新增了沙发、红酒杯、电视机,那么就可以实现坐在沙发上摇着红酒看综艺节目的理想状态。此时沙发、红酒杯、电视机起到了装饰器的效果,在不影响房间可以用来睡觉的前提下扩展了更多的功能,实现松耦合和易扩展,这就是装饰器。
令人迷惑的装饰器参数
装饰器是贯穿Nest开发的重要部分,无论你编写模块、控制器、服务以及数据访问,都有对应的装饰器为你提供支持,比如这些:
通过调试,可以看到@UseGuards()源码是这样定义的,它接受三个参数:target、key、descriptor
还有@Controller()类装饰器和@Body()参数装饰器是这样的:
这样看好像没什么问题,我们知道目前浏览器不支持装饰器功能,还处于草案阶段,需要借助typescript的编译器或者babel进行编译才能执行,但你去翻一下ES6文档或ECMAScript提案,会发现stage-3的装饰器参数跟Nest源码中的并不相同,它们是这样的:
类装饰器:
typescript
type ClassDecorator = (value: Function, context: {
kind: "class";
name: string | undefined;
addInitializer(initializer: () => void): void;
}) => Function | void;
方法装饰器:
typescript
type ClassMethodDecorator = (value: Function, context: {
kind: "method";
name: string | symbol;
access: { get(): unknown };
static: boolean;
private: boolean;
addInitializer(initializer: () => void): void;
}) => Function | void;
属性装饰器:
typescript
type ClassFieldDecorator = (value: undefined, context: {
kind: "field";
name: string | symbol;
access: { get(): unknown, set(value: unknown): void };
static: boolean;
private: boolean;
}) => (initialValue: unknown) => unknown | void;
它们只接受两个参数,value和context,其中value在不同的装饰器类型下表现不同,如在类中表示被修饰的类, 在方法中表示被修饰的方法 ;context是含有丰富信息的上下文对象。
于是,有朋友可能产生了以下问题:
- 为什么会这两种版本的装饰器?出现这个差异的原因是什么?
- 虽然装饰器还在stage阶段,但事实上有很多的框架/库基于Babel或Tsc提供了装饰器的能力,它们各自是如何看齐ES规范的?目前各自的支持程度又到了哪个阶段?
要回答这些问题,要从装饰器发展史说起!
装饰器一路走来经历了什么
我们要知道,一个JavaScript特性正式进入规范是需要经历下面五个阶段,从 Stage 0(Strawman,初稿)开始,经 Stage 1(提案)、Stage 2(草案)、Stage 3(候选提案),最后到 Stage 4(Finished,过审提案)结束。
装饰器基于这个流程之上的时间顺序是这样的:
- 2014-04-10:Yehuda Katz向TC39提出装饰器,提案进入了
stage 0
阶段 - 2015-03-24:装饰器进入到
stage 1
阶段,当时是放在GitHub
的这个仓库的,后面统一迁移到了现在这个仓库 - 2015-07-20:TypeScript 1.5 就开始支持了第一阶段的装饰器,通过
--experimentalDecorators
这个配置来开启。当时Angular和MobX也用上了这个TypeScript特性。 - 2016-07-28:提案进入了
stage 2
阶段 - 2022-03-28:提案进入了
stage 3
阶段,同时metadata也被单独出来作为一个题案,目前也进入了stage 3
阶段 - 2023-03-16:TypeScript5.0支持了第三阶段的装饰器!
由于babel在前端领域中占有重要的份额,我觉得有必要在这里扩展补充:
- 2015-03-31: Babel 5.0.0 支持了
stage 1
的装饰器 - 2015-11-29:Babel 6 中是用 Logan Smyth 写的扩展插件支持了
stage 1
的装饰器 - 2018-08-27:Babel 7.0.0通过官方
@babel/plugin-proposal-decorators
支持第二阶段装饰器 - 截止目前为止,官方插件目前支持以下版本:
-
- "legacy":第一阶段装饰器
- "2018-09":第二阶段装饰器
- "2021-12":原第二阶段装饰器的升级版
- "2022-03":第三阶段装饰器
- "2023-05":第三阶段装饰器升级版
- "2023-01":第三阶段装饰器升级版
我们可以通过babel配置或者babel repl来切换这个版本
现在是不是对装饰器有更加清晰的认识了?我们再来看看Nest中tsconfig.json
中的配置,默认开启了experimentalDecorators
配置,表示使用stage 1
提案的装饰器
如果设置为false,那默认使用最新stage 3
的装饰器,是有两个参数。另外,需要把emitDecoratorMetadata
元数据配置也设为false,因为它是依赖experimentalDecorators
的,这个会在后面讲IOC
思想的时候再详细介绍。
以samples下的01-cats-app
为例,把tsconfig.json
这两个属性修改为这样:
重新npm run start:dev
启动服务
编译报错提示在运行时接收到了2个参数,实际上@Controller() 预期接收1个,而@Post() 预期接收3个,明显是使用了stage 3
最新提案的参数。
装饰器类型
从前面Nest源码我们可以发现,不同类型的装饰器接收的参数不一样,作用也不同,常用的装饰器有五中类型,分别是:
- 类装饰器
- 方法装饰器
- 访问器装饰器
- 属性装饰器
- 参数装饰器
我们来过一下:
类装饰器
装饰器可以用来装饰整个类
typescript
/**
* 类装饰器
*/
const doc: ClassDecorator = (target: Function) => {
console.log("---------------类装饰器-----------------");
target.prototype.name = 'jmin'
console.log(target);
console.log("---------------类装饰器-----------------");
}
@doc
class App {
constructor() {
}
}
const app: Record<string, any> = new App()
console.log('app name: ' + app.name);
上面代码中,@doc用来装饰App类,同时往原型链上添加一个属性为name,taget为修饰的类。
我们通过CodePen 来编译ES6代码,它是一个社区驱动的在线代码编辑器,支持编辑 HTML、CSS 和 JavaScript。它也支持 Babel,能够将 ES6 代码转换成 ES5 并进行实时预览。
由于本节案例中我们使用typescript编码,注意需要将语言类型切换为typescript。来看看效果:
方法的装饰
装饰器可以用来装饰类的方法
typescript
/**
* 方法装饰器
*/
const log: MethodDecorator = (target: Object, propertyKey: string | Symbol, descriptor: PropertyDescriptor) => {
console.log("---------------方法装饰器-----------------");
console.log(target);
console.log(propertyKey);
console.log(descriptor);
console.log("---------------方法装饰器-----------------");
}
class User {
@log
getName() {
return 'jmin';
}
}
上面代码中,@log装饰器函数修饰User类方法getName,其中target为'装饰'类的原型,上例是User.prototype,由于这时候类还没有被实例化,只能去装饰原型对象,这是不同于类装饰器的,来看看运行效果:
由于User类原型还未挂载属性方法,所以是{}
访问器装饰器
访问器装饰器可以用来修饰类的setter和getter函数,跟类方法装饰器类似,唯一区别在于它们的描述器中有的key不同
方法装饰器的描述器的key为:
- value
- writable
- enumerable
- configurable
访问器装饰器的描述器的key为:
- get
- set
- enumerable
- configurable
typescript
/**
* 访问器装饰器
*/
const immutable: ParameterDecorator = (target: any, propertyKey: string | Symbol, descriptor: PropertyDescriptor) => {
console.log('-------------访问器装饰器------------------');
console.log(target);
console.log(propertyKey);
console.log(descriptor);
console.log('-------------访问器装饰器------------------');
}
class User {
private _name = 'jmin'
@immutable
get name() {
return this._name;
}
}
执行结果
属性的装饰
装饰器也可以用来修饰类属性
typescript
/**
* 属性装饰器
*/
const prop: PropertyDecorator = (target: Object, propertyKey: string | Symbol) => {
console.log('------属性装饰器-------');
console.log(target);
console.log(propertyKey);
console.log('------属性装饰器-------');
}
class User {
@prop
name: string = 'jmin'
}
上面代码中,@prop用于修饰Use类的name属性,此时target为装饰类的原型对象,propertyKey为修饰的属性
参数的装饰
还可以装饰类方法参数
typescript
/**
* 参数装饰器
*/
const param: ParameterDecorator = (target: Object, propertyKey: string | Symbol | undefined, index: number) => {
console.log('-------------参数装饰器------------------');
console.log(target);
console.log(propertyKey);
console.log(index);
console.log('-------------参数装饰器------------------');
}
class User {
getName(@param name: string) {
return name;
}
}
上面代码中,@param用于装饰类方法getName的参数,其中target为装饰类的原型,propertyKey为装饰的类方法,index为装饰方法的参数索引位置,来看看效果:
但这些装饰器只有自带的几个参数信息,比如类装饰器中只有一个target参数并不够用怎么办呢?那就要用到工厂函数来增强装饰器能力了。比如这样:
typescript
/**
* 类装饰器
*/
const doc: ClassDecorator = (module) => {
return (target: Function) => {
console.log("---------------类装饰器-----------------");
target.prototype.module = module
console.log(target);
console.log("---------------类装饰器-----------------");
}
}
@doc('user')
class App {
constructor() {
}
}
const app: Record<string, any> = new App()
console.log('app module: ' + app.module);
上面代码通过装饰器工厂函数增强之后,@doc可以接受外部参数,使得App被实例化的时候有能力获取更多的信息来组织代码,到这里,你是不是已经明白了Nest中的装饰器是如何实现的?它在依赖注入中扮演着重要角色。
执行时机与顺序
我们重新把前面装饰类方法时执行结果拿过来
你也许会发现,即使类没有被实例化,但装饰器依旧会执行,这是因为装饰器是在解析阶段执行的。
对于属性/方法/访问器装饰器而言,它们的执行顺序取决于声明的先后顺序。
然而,同一方法中不同参数的装饰器的执行顺序是相反的, 最后一个参数的装饰器会最先被执行。
less
function func(key: string): any {
console.log("evaluate: ", key);
return function () {
console.log("call: ", key);
};
}
class C {
method(
@func("Parameter Foo") foo,
@func("Parameter Bar") bar
) {}
}
执行结果为:
sql
evaluate: Parameter Foo
evaluate: Parameter Bar
call: Parameter Bar
call: Parameter Foo
参数装饰器优先于方法装饰器执行。
less
function func(key: string): any {
console.log("evaluate: ", key);
return function () {
console.log("call: ", key);
};
}
class C {
@func("Method method")
method(
@func("Parameter Bar") bar
) {}
}
执行结果为:
sql
evaluate: Method method
evaluate: Parameter Bar
call: Parameter Bar
call: Method method
了解完这几个概念后再来看看Nest中的装饰器就很好理解了,把前面令人迷惑的代码再贴过来。
现在你是不是知道了不同装饰器是有不同的工厂来处理的,接着获取到不同的元数据 通过Reflect.defineMetadata
这个API存起来,等到需要的时候再拿出来使用。
小结
通过看Nest中装饰器的源码实现,发现参数个数跟最新提案的不一样,以这个契机我们顺藤摸瓜了解了规范的五个阶段,Babel通过官方@babel/plugin-proposal-decorators
插件提供支持,TypeScript则通过experimentalDecorators
实现了对规范的兼容和支持,我们可以通过里面的配置实现切换不同版本。
接着介绍5个常用的装饰器类型,在Nest中的装饰器无非也就这几种,它们都是通过装饰器工厂增强能力。
装饰器执行时机的先后顺序取决于声明的顺序,而参数装饰器是从最后一个开始往前执行,它们都是在解析阶段执行的。
下一节我们结合元数据来实现一个参数验证器。