【Nest源码系列】装饰器元数据的奇妙联合(上)

一间房子里面放了一张床,这个房子满足了用来睡觉的基本需求,在这个基础上新增了沙发、红酒杯、电视机,那么就可以实现坐在沙发上摇着红酒看综艺节目的理想状态。此时沙发、红酒杯、电视机起到了装饰器的效果,在不影响房间可以用来睡觉的前提下扩展了更多的功能,实现松耦合和易扩展,这就是装饰器。

令人迷惑的装饰器参数

装饰器是贯穿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中的装饰器无非也就这几种,它们都是通过装饰器工厂增强能力。

装饰器执行时机的先后顺序取决于声明的顺序,而参数装饰器是从最后一个开始往前执行,它们都是在解析阶段执行的。

下一节我们结合元数据来实现一个参数验证器。

相关推荐
万叶学编程3 小时前
Day02-JavaScript-Vue
前端·javascript·vue.js
前端李易安5 小时前
Web常见的攻击方式及防御方法
前端
PythonFun5 小时前
Python技巧:如何避免数据输入类型错误
前端·python
知否技术5 小时前
为什么nodejs成为后端开发者的新宠?
前端·后端·node.js
hakesashou5 小时前
python交互式命令时如何清除
java·前端·python
天涯学馆5 小时前
Next.js与NextAuth:身份验证实践
前端·javascript·next.js
HEX9CF5 小时前
【CTF Web】Pikachu xss之href输出 Writeup(GET请求+反射型XSS+javascript:伪协议绕过)
开发语言·前端·javascript·安全·网络安全·ecmascript·xss
ConardLi5 小时前
Chrome:新的滚动捕捉事件助你实现更丝滑的动画效果!
前端·javascript·浏览器
ConardLi5 小时前
安全赋值运算符,新的 JavaScript 提案让你告别 trycatch !
前端·javascript