近年来,由于 Node.js、JavaScript 已经成为 web 前端和后端应用程序的"通用开发语言"。这促成了诸如 Angular、React 和 Vue 等优秀项目的出现,他们提高了开发者的工作效率,并能够创建快速、可测试和可扩展的前端应用程序。然而,尽管 Node (和服务器端 JavaScript)拥有大量优秀的软件库、辅助程序和工具,但没有一个能够有效地解决我们所面对的主要问题,即 架构。
Nest 提供了一个开箱即用的应用程序体系架构,允许开发者及其团队创建高度可测试、可扩展、松散耦合且易于维护的应用程序。这种架构深受 Angular 的启发。 ------ Nest.js 官网
Nest.js 的核心原理其实就是通过装饰器
给 class 或者对象添加元数据,然后初始化的时候取出这些元数据,进行依赖的分析,然后创建对应的实例对象就可以了。它的核心就是 IOC 容器,也就是自动扫描依赖,创建实例对象并且自动依赖注入。Nest 的 Controller、Module、Service
等等所有的装饰器都是通过 Reflect.meatdata
给类或对象添加元数据的,然后初始化的时候取出来做依赖的扫描,实例化后放到 IOC 容器里。
所以我们需要知道两个比较新的特性:
装饰器
Reflect.meatdata
这第一篇先来搞懂装饰器。
0、什么是装饰器(Decorators)?
TypeScript 是这样描述装饰器的:
装饰器提供了一种为类声明和成员添加
注解
和元编程
语法的方法。装饰器是一种特殊类型的声明,可以附加到类声明 、方法 、访问器 、属性 或参数 。装饰器使用 形式
@expression
,其中expression
必须求值为将在运行时调用的函数,其中包含有关装饰声明的信息。
装饰器本质上是一种特殊的函数被应用在于:
- 类
- 类属性
- 类方法
- 类访问器
- 类方法的参数
一句话:装饰器就是一个接收特定参数的函数,使用
@函数名
可以对一些类,属性,方法等进行装饰来实现一些 运行时 的 hook 拦截机制。
先强调一下:当前的装饰器只适用于类和类的成员/方法,不适用于普通函数!主要原因是存在函数提升。
所以应用装饰器其实很像是组合一系列函数,类似于高阶函数和类。 通过装饰器我们可以轻松实现代理模式来使代码更简洁以及实现其它一些更有趣的能力。
装饰器的语法十分简单,只需要在想使用的装饰器前加上@
符号,装饰器就会被应用到目标上:
js
function simpleDecorator() {
console.log('---hi I am a decorator---')
}
@simpleDecorator
class A {}
一共有5种装饰器可被我们使用:
- 类装饰器
- 属性装饰器
- 方法装饰器
- 访问器装饰器
- 参数装饰器
让我们来快速认识一下这五种装饰器:
js
// 类装饰器
@classDecorator
class Bird {
// 属性装饰器
@propertyDecorator
name: string;
// 方法装饰器
@methodDecorator
fly(
// 参数装饰器
@parameterDecorator
meters: number
) {}
// 访问器装饰器
@accessorDecorator
get egg() {}
}
有个问题:什么是元编程
?比较晦涩难懂的说法是:
- 我们不编写处理用户数据的代码(编程)。
- 我们编写的代码是处理用户数据的代码(元编程)。
通俗来讲:元编程 (meta-programming) 是通过操作 程序实体 (program entity),在 编译时 (compile time) 计算出 运行时 (runtime) 需要的常数、类型、代码的方法。它的诞生是源于:需要非常灵活的代码来适应快速变化的需求,同时保证性能。
与一般代码的区别是:
- 一般代码的操作对象是数据。
- 元编程的操作对象是代码 :code as data。
- 如果编程的本质是抽象,那么元编程就是更高层次的抽象。
需要注意的是,在 TypeScript 5.x 之前,装饰器还只是作为一个实验性功能,使用时需要在tsconfig.json
中开启以下配置:
json
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
- 第一个,
experimentalDecorators
打开装饰器支持。 - 第二个,
emitDecoratorMetadata
,发出包所需的数据reflect-metadata
。这个包使我们能够通过记录有关类、属性、方法和参数的元数据,在装饰器中做一些更强大的事情。
1、类装饰器
1.1 类装饰器函数
装饰器函数只有一个参数:类的构造函数
,类型签名:
ts
type ClassDecorator = <TFunction extends Function>
(target: TFunction) => TFunction | void;
来看下面这个例子:
ts
function logConstructor(constructor: Function) {
const ret = {
constructor,
extensible: Object.isExtensible(constructor),
frozen: Object.isFrozen(constructor),
sealed: Object.isSealed(constructor),
values: Object.values(constructor),
properties: Object.getOwnPropertyDescriptors(constructor),
members: {}
} as any;
for (const key of Object.getOwnPropertyNames(constructor.prototype)) {
ret.members[key] = constructor.prototype[key];
}
console.log(`ClassDecoratorExample `, ret);
}
@logConstructor
class ClassDecoratorExample {
constructor(x: number, y: number) {
console.log(`ClassDecoratorExample(${x}, ${y})`);
}
method() {
console.log(`method called`);
}
}
new ClassDecoratorExample(3, 4).method()
@logConstructor
就是装饰器函数,该装饰器不使用任何参数,因此不需要括号。它打印出有关构造函数的一些信息。在大多数情况下,我们使用对象类中的方法来查询被装饰类的数据。在某些情况下,查询的对象是 constructor.prototype
,因为该对象包含了附加到类中的方法的实现细节。
运行上面的代码,会得出以下输出:
ts
ClassDecoratorExample {
constructor: [class ClassDecoratorExample],
extensible: false,
frozen: false,
sealed: false,
values: [],
properties: {
length: {
value: 2,
writable: false,
enumerable: false,
configurable: false
},
name: {
value: 'ClassDecoratorExample',
writable: false,
enumerable: false,
configurable: false
},
prototype: {
value: {},
writable: false,
enumerable: false,
configurable: false
}
},
members: {
constructor: [class ClassDecoratorExample],
method: [Function: method]
}
}
ClassDecoratorExample(3, 4)
method called
如果不使用类装饰器函数中的必填参数,会发生什么情况?
ts
function Decorator() {
console.log('In Decorator');
}
@Decorator
class FooClass {
foo: string;
}
在这种情况下,会出现编译时错误:
shell
error TS1329: 'Decorator' accepts too few arguments to be used as a decorator here. Did you mean to call it first and write '@Decorator()'?
1.2 带参数的类装饰器
装饰器也可以接受参数,这需要遵循一种不同的模式,即装饰器工厂。下面是一个简单的类装饰器示例,它不仅展示了如何传递参数,还帮助我们理解了使用多个装饰器时的执行顺序。
ts
function withParam(path: string) {
console.log(`outer withParam ${path}`);
return (target: Function) => {
console.log(`inner withParam ${path}`);
};
}
@withParam('first')
@withParam('middle')
@withParam('last')
class ExampleClass {
// ...
}
外层函数 withParam
接收与装饰器一起使用的参数列表。内层函数是装饰器函数,是实现所需签名的地方,内部函数是装饰器的实际实现。withParam(parameter)
是一个表达式,它返回的函数具有正确的类装饰器签名。这使得内部函数成为装饰器函数,而外部函数则是生成该函数的工厂函数。
在这个示例中,我们加了三次 withParam
,打印出的信息是:
yaml
outer withParam first
outer withParam middle
outer withParam last
inner withParam last
inner withParam middle
inner withParam first
记住,工厂函数是自上而下执行的,而装饰器函数则是自下而上执行的。
1.3 类装饰器应用
让我们来看看类装饰器的一种可能的实际用途。也就是说,一个框架可能会保存某些类型的类的列表。我们来模仿一个网络应用框架,其中某些类包含 URL
路由功能。每个路由器类都处理特定 URL 前缀的路由,以及该路由的特定配置。
ts
const registeredClasses = [];
function Router(path: string, options ?: object) {
return (constructor: Function) => {
registeredClasses.push({
constructor, path, options
});
};
}
@Router('/')
class HomePageRouter {
// routing functions
}
@Router('/blog', {
rss: '/blog/rss.xml'
})
class BlogRouter {
// routing functions
}
console.log(registeredClasses);
Router
是一个生成类装饰器的工厂函数,可将类添加到 registeredClasses
数组中。该函数包含两个选项,其中 path
是 URL
路径前缀,options
是一个可选的配置对象。
运行后的输出结果如下:
ts
[
{
constructor: [class HomePageRouter],
path: '/',
options: undefined
},
{
constructor: [class BlogRouter],
path: '/blog',
options: { rss: '/blog/rss.xml' }
}
]
从构造函数对象开始,可以获得大量我们想要得到的额外数据。由于类装饰器最后运行,因此可以选择让类装饰器对类中包含的任何方法或属性进行操作。此外,更常见的数据存储方法不是像这样使用数组,而是使用 Reflection Metadata API
,后面也会介绍这个API。
1.4 使用类装饰器修改类
类装饰器还可以应用于类构造函数,对类定义进行修改等。
ts
function reportableClassDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
return class extends constructor {
reportingURL = "http://www...";
};
}
@reportableClassDecorator
class BugReport {
type = "report";
title: string;
constructor(t: string) {
this.title = t;
}
}
const bug = new BugReport("Needs dark mode") as any;
console.log(bug.title); // "Needs dark mode"
console.log(bug.type); // "report"
console.log(bug.reportingURL); // http://www...
请注意,装饰器不会改变 TypeScript 类型,因此类型系统并不知道新属性
reportingURL
,所以我们需要做类型断言,否则会报错。
1.5 小结
-
装饰器接收类对象,我们可以从中访问大量数据。
-
装饰器函数在类对象创建时执行,而不是在类实例构建时执行。这意味着,要直接影响生成的实例,我们必须创建一个匿名子类。
-
使用匿名子类可能比较麻烦。访问任何添加的方法或属性都需要跳过重重障碍,而在匿名子类中,重载的方法或属性都是透明执行的。
2、类属性装饰器
装饰器可以加到 TypeScript 类中的属性或字段上,像这样:
ts
class ContainingClass {
@Decorator(?? optional parameters)
name: type;
}
2.1 类装饰器函数
属性装饰器附加于类定义中的属性。在 JavaScript 中,属性是与对象相关联的值。最简单的属性只是对象中声明的一个字段。
属性装饰器函数接收两个参数:
- 对于静态成员,是类的构造函数;对于实例成员,是类的原型。
- 给出属性名称的字符串
来看下面的例子:
ts
function logProperty(target: Object, member: string): any {
console.log(`PropertyExample logProperty ${target} ${member}`);
}
class PropertyExample {
@logProperty
name!: string;
}
const pe = new PropertyExample();
if (!pe.hasOwnProperty('name')) {
console.log(`No property 'name' on pe`);
}
pe.name = "Stanley Steamer";
if (!pe.hasOwnProperty('name')) {
console.log(`No property 'name' on pe`);
}
console.log(pe);
输出结果:
ts
PropertyExample logProperty [object Object] name
No property 'name' on pe
PropertyExample { name: 'Stanley Steamer' }
尽管 name 属性已在该类中明确定义,但对 hasOwnProperty
的首次调用却返回 false
,表明该属性不存在。
让我们逐行分析代码的执行过程和输出结果:
- 首先,代码定义了一个名为
logProperty
的装饰器函数,它会在属性被赋值时打印日志。 - 然后,代码定义了一个名为
PropertyExample
的类,该类具有一个装饰器@logProperty
应用在name
属性上。 - 接下来,代码创建了一个
PropertyExample
的实例pe
。 - 在检查
pe
是否具有名为name
的属性时,由于name
属性是在类中通过装饰器定义的,而不是在实例上直接定义的,因此pe.hasOwnProperty("name")
返回false
,并打印了"No property 'name' on pe"。 - 然后,代码给
pe
的name
属性赋值为"Stanley Steamer"。 - 再次检查
pe
是否具有名为name
的属性时,由于name
属性已经被赋值,因此pe.hasOwnProperty("name")
返回true
,不会打印任何内容。 - 最后,代码打印了
pe
对象,显示{ name: 'Stanley Steamer' }
。
为了进一步探讨这个问题,开打印一下 PropertyDescriptor
对象:
ts
function GetDescriptor() {
return (target: Object, member: string) => {
const prop = Object.getOwnPropertyDescriptor(target, member);
console.log(`Property ${member} ${prop}`);
};
}
class Student {
@GetDescriptor()
year!: number;
}
const stud1 = new Student();
console.log(Object.getOwnPropertyDescriptor(stud1, "year"));
stud1.year = 2022;
console.log(Object.getOwnPropertyDescriptor(stud1, "year"));
对象类有两个与属性的 PropertyDescriptor
对象相关的函数,即 getOwnPropertyDescriptor
和 defineProperty
。本脚本在执行装饰器时调用 getOwnPropertyDescriptor
,在创建对象实例后调用 getOwnPropertyDescriptor
,然后在为属性赋值后调用 getOwnPropertyDescriptor
。
让我们运行这个脚本:
ts
Property year undefined
undefined
{ value: 2022, writable: true, enumerable: true, configurable: true }
在为属性赋值之前,我们无法获取描述符。
让我们逐行分析代码的执行过程和输出结果:
- 首先,代码定义了一个名为
GetDescriptor
的函数,它返回一个装饰器函数。装饰器函数在属性被访问时获取属性的描述符,并打印日志。 - 接下来,代码定义了一个名为
Student
的类,它具有一个装饰器@GetDescriptor
应用在year
属性上。 - 然后,代码创建了一个
Student
的实例stud1
。 - 在装饰器函数中,通过调用
Object.getOwnPropertyDescriptor(target, member)
来获取stud1
对象上year
属性的描述符。由于year
属性尚未被赋值,因此描述符中的value
属性为undefined
。 - 然后,代码打印了获取到的属性描述符,输出
{ value: undefined, writable: true, enumerable: true, configurable: true }
。 - 接下来,代码给
stud1
的year
属性赋值为2023
。 - 再次通过
Object.getOwnPropertyDescriptor(stud1, "year")
获取stud1
对象上year
属性的描述符。这次描述符中的value
属性为2023
,表示属性已经被成功赋值。 - 最后,代码打印了获取到的属性描述符,输出
{ value: 2023, writable: true, enumerable: true, configurable: true }
。
在第一次打印属性描述符时,由于属性尚未被赋值,value
属性为undefined
;而在第二次打印属性描述符时,value
属性被成功赋值为2023
。
TypeScript 文档是这么说的:
注意 由于 TypeScript 中属性装饰器的初始化方式,属性描述符(Property Descriptor)不能作为属性装饰器的参数。这是因为目前还没有在定义原型成员时描述实例属性的机制,也没有观察或修改属性初始化器的方法。
换句话说,属性描述符函数是在 PropertyDescriptor
对象存在之前执行的。
2.2 类属性装饰器应用
在装饰器函数中,我们会得到一个目标对象、属性名称以及传递给装饰器函数的任何参数。我们无法覆盖或修改属性的行为,我们能做的就是从装饰器中记录数据,就像我们在上面类装饰器应用示例中那样。
举一个数据验证框架的例子。我们可以将装饰器附加到描述可接受值的属性上,然后验证框架将使用这些设置来确定某个值是否可接受:
ts
const registered = [];
function IntegerRange(min: number, max: number) {
return (target: Object, member: string) => {
registered.push({
target, member,
operation: {
op: 'intrange',
min, max
}
});
}
}
function Matches(matcher: RegExp) {
return (target: Object, member: string) => {
registered.push({
target, member,
operation: {
op: 'match',
matcher
}
});
}
}
上面是两个属性装饰器工厂函数。第一个函数记录了一个验证操作,确保值是一个整数,在给定的值范围内。另一个操作是根据正则表达式进行字符串匹配。这两个操作的数据都记录在注册数组中。
ts
class StudentRecord {
@IntegerRange(1900, 2050)
year: number;
@Matches(/^[a-zA-Z ]+$/)
name: string;
}
const sr1 = new StudentRecord();
console.log(registered);
输出结果:
ts
[
{
target: {},
member: 'year',
operation: { op: 'intrange', min: 1900, max: 2050 }
},
{
target: {},
member: 'name',
operation: { op: 'match', matcher: /^[a-zA-Z ]+$/ }
}
]
可见,通过属性装饰器在另一个位置记录有关属性的任何数据都非常容易。在看一些框架设计中,其他函数可以查阅这些数据并做一些有用的事情。
2.3 使用 Object.defineProperty 的盲区
有些博客上一些关于属性装饰器的教程文章建议使用 Object.defineProperty
来实现运行时数据验证。该建议的缺陷就像我们刚才演示的那样 ------ PropertyDescriptor
对象对属性装饰器函数不可用。下面讨论一下使用 defineProperty 的错误建议。从装饰器函数开始:
ts
function ValidRange(min: number, max: number) {
return (target: Object, member: string) => {
console.log(`Installing ValidRange on ${member}`);
let value: number;
Object.defineProperty(target, member, {
enumerable: true,
get: function() {
console.log("Inside ValidRange get");
return value;
},
set: function(v: number) {
console.log(`Inside ValidRange set ${v}`);
if (v < min || v > max) {
throw new Error(`Not allowed value ${v}`);
}
value = v;
}
});
}
}
此装饰器用于数值属性,并强制最小值和最大值之间的有效范围。它使用 get/set
函数调用 defineProperty
,其中 set
函数强制执行范围。在数据存储方面,函数会将值存储在局部变量中。这看起来简单明了,不是吗?
下面进行测试:
ts
class Student {
@ValidRange(1900, 2050)
year!: number;
}
const stud_1 = new Student();
const stud_2 = new Student();
stud_1.year = 1901;
stud_2.year = 1911;
console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);
stud_1.year = 2030;
console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);
// stud_1.year = 1899;
// console.log(stud_1.year);
console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);
stud_2.year = 2022;
console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);
stud_2.year = 2023;
console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);
定义了一个类,并生成两个实例。我们为其中一个实例赋值,然后打印输出这些值。如果你想看看数据验证的操作,注释掉赋值 1899 的那一行,你会看到它抛出一个异常。
打印结果:
ts
Installing ValidRange on year
Inside ValidRange set 1901
Inside ValidRange set 1911
Inside ValidRange get
Inside ValidRange get
stud1 1911 stud_2 1911
Inside ValidRange set 2030
Inside ValidRange get
Inside ValidRange get
stud1 2030 stud_2 2030
Inside ValidRange get
Inside ValidRange get
stud1 2030 stud_2 2030
Inside ValidRange set 2022
Inside ValidRange get
Inside ValidRange get
stud1 2022 stud_2 2022
Inside ValidRange set 2023
Inside ValidRange get
Inside ValidRange get
stud1 2023 stud_2 2023
每次设置或检索值时,我们都会打印出来。我们看到,stud_1
和 stud_2
分别被赋值为 1901 和 1911,但打印出来的两个值都是 1911。无论我们给哪个变量分配新值,另一个变量都显示相同的值。
这是怎么回事?问题出在装饰器函数内部的数据存储上。在构建类定义时,该函数只对给定类中的每个属性执行一次。该函数不会在每次创建类的实例时执行,只会在创建定义时执行。存储数据的局部变量 value 只创建一次。该实例位于装饰器函数的堆栈框架内,每个类的每个属性只执行一次。
这就意味着,使用 @ValidRange
在所有属性实例之间共享存储在 value 中的数据。这是因为在使用 @ValidRange
时,属性的数据存储由装饰器管理,而不是由 JavaScript 管理。
在本例中,我们有一个名为 Student
的类,它有一个名为 year
的属性,该属性使用 @ValidRange
进行了装饰。正如我们所演示的,两个 year
实例共享同一个值。
要验证这种行为,请在 Student 类中添加以下字段:
ts
@ValidRange(0, 150)
age: number;
增加另一个由 @ValidRange
管理的属性。我们会看到同样的数据共享问题吗?年龄的值是否与年份的值相同?
测试一下:
ts
stud_1.year = 1901;
stud_2.year = 1911;
stud_1.age = 20;
console.log(`stud_1 ${stud_1.year} ${stud_1.age} stud_2 ${stud_2.year} ${stud_2.age}`);
这将为其中一个 Student
实例的年龄赋值,然后打印两个实例的年龄:
ts
Installing ValidRange on year
Installing ValidRange on age
Inside ValidRange set 1901
Inside ValidRange set 1911
Inside ValidRange set 20
Inside ValidRange get
Inside ValidRange get
Inside ValidRange get
Inside ValidRange get
stud_1 1911 20 stud_2 1911 20
年份和年龄属性的值在它们之间是不同的,但在Student
实例之间是共享的。我们只分配了一次值,但注意到两个实例都打印了相同的值。
为了进一步演示,请生成一个新的 Student 实例:
ts
const stud_3 = new Student();
然后,不要为该实例赋值,而是添加一条 console.log
语句:
ts
console.log(`stud_3 ${stud_3.year} ${stud_3.age}`);
输出结果:
ts
stud_3 1911 20
尽管 stud_3
没有分配任何值,但它显示了相同的值。
Student.year
属性的每个实例都共享相同的值,Student.age
属性的每个实例也是如此。这是因为 @ValidRange
管理的是数据存储,而不是 JavaScript。造成这种情况的原因是使用 Object.defineProperty
的方式不正确。JavaScript 确实为我们提供了很多工具来解决这些问题。
在执行属性装饰器函数时,JavaScript 尚未创建 PropertyDescriptor
对象。覆盖该属性描述符的 get/set
函数会非常强大。如果创建了自己的属性描述符,并认为已经实现了运行时数据验证的目标,那将会产生误导。
后下面会讲到访问者装饰器,通过覆盖正确属性描述符中的
get/set
函数来实现运行时数据验证。
2.4 小结
我们可以将装饰器附加到属性上。这意味着我们可以记录附加到每个属性的装饰器的信息,然后对这些数据进行处理。但是,我们却不知道如何访问 PropertyDescriptor
以及如何使用 get/set
函数。原因在于类装饰器函数的执行时机。
如果我们能覆盖 PropertyDescriptor
中的 get/set
方法,就会有很多可能性。但是,对于属性而言,在为属性赋值之前,该对象是不存在的。
我们可以在数据结构中记录装饰器信息。例如,class-validator
包中有 @IsInt
或 @Min
或 @Max
这样的装饰器来验证属性值。我们知道,它必须将这些信息记录到数据结构中,当应用程序调用 validate
函数时,它必须检查这些数据,以便知道如何验证类实例。