近年来,由于 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
这第一篇先来搞懂装饰器。
1、什么是装饰器(Decorators)?
先用代码来看看装饰器的强大魔力:
- 没有装饰器的时候:
- 有装饰器之后:
直观上,代码都简洁了不少 ~
getHomeAddress
函数违反了单一责任原则,因为它执行了本应由不同函数处理的多项任务。具体来说,该函数负责验证参数、授权用户、执行业务逻辑和处理错误。虽然该函数的主要目的是调用 homeAddressApi
并返回结果,但它还执行了这些额外的任务,从而使函数变得更加复杂和难以维护。
更好的方法是将验证、授权和错误处理逻辑委托给单独的函数,让 getHomeAddress
只专注于检索家庭住址的业务逻辑。
通过使用 TypeScript 装饰器,我们可以将原始代码转换为更精简的版本,从而分离关注点并提高代码的可维护性。使用单独的装饰器来处理验证、授权和错误处理,getHomeAddress
函数能够只关注检索家庭住址的业务逻辑。这种分工使代码更简洁、更易于维护,也更容易理解和修改。getHomeAddress
函数不再杂乱无章地包含无关的代码,因此使用起来更加简单。
1.1 装饰器定义
装饰器是 TypeScript 的一项强大功能,允许开发人员修改或扩展类、方法、访问器和属性的行为。它们提供了一种优雅的方式来添加功能或修改现有构造的行为,而无需改变其原始实现。
装饰器在 TypeScript 和 JavaScript 生态系统中有着丰富的历史。装饰器的概念受到 Python 和其他编程语言的启发,这些语言使用类似的构造来修改或扩展类、方法和属性的行为。最初的 JavaScript 装饰器提案是在 2014 年提出的,此后又开发了多个版本的提案,目前的提案处于 ECMAScript 标准化进程的第 3 阶段。
TypeScript 5.0 版本正式支持第 3 阶段装饰器提案。该提案有四个阶段,这意味着它可以快速稳定,无需对 API 进行重大更改。
TypeScript 是这样描述装饰器的:
装饰器提供了一种为类声明和成员添加
注解
和元编程
语法的方法。装饰器是一种特殊类型的声明,可以附加到类声明、方法、访问器、属性或参数。装饰器使用 形式
@expression
,其中expression
必须求值为将在运行时调用的函数,其中包含有关装饰声明的信息。
装饰器本质上是一种特殊的函数被应用在于:
- 类
- 类属性
- 类方法
- 类访问器
- 类方法的参数(在
TypeScript 5.x
中不再包含)
一句话:装饰器就是一个接收特定参数的函数,使用
@函数名
可以对一些类,属性,方法等进行装饰来实现一些 运行时 的 hook 拦截机制。
先强调一下:当前的装饰器只适用于类和类的成员/方法,不适用于普通函数!主要原因是存在函数提升。
所以应用装饰器其实很像是组合一系列函数,类似于高阶函数和类。 通过装饰器我们可以轻松实现代理模式来使代码更简洁以及实现其它一些更有趣的能力。
装饰器的语法十分简单,只需要在想使用的装饰器前加上@
符号,装饰器就会被应用到目标上:
@simpleDecorator1
class MyClass {
@simpleDecorator2
method() {
// ...
}
}
// 你也可以放在一行
@simpleDecorator class MyClass {
@simpleDecorator1 @simpleDecorator2 method() {
// ...
}
}
那装饰器能干嘛呢?常用的比如:
- 日志记录:装饰器可用于记录方法调用。
- 验证:装饰器可用于在设置方法参数或对象属性之前对其进行验证。
- 缓存:装饰器可用于缓存函数调用的结果,以提高性能。
- 授权:装饰器可用于根据用户角色或权限限制对方法的访问。
1.2 装饰器的历史
装饰器模式是一种经典的设计模式,它可以在不修改被装饰者(如某个函数、某个类等)源码的前提下,为被装饰者增加 / 移除某些功能(收集用户定义的类/函数的信息,例如用于生成路由表,实现依赖注入等等、也可以对用户定义的类/函数进行增强,增加额外功能)。一些现代编程语言在语法层面都提供了对装饰器模式的支持,并且各语言中的现代框架都大量应用了装饰器。
在之前,我们想要在 TypeScript
中使用装饰器,需要在 tsconfig
中添加 --experimentalDecorators
标志,这其实就是 TypeScript
对最原始的处于 stage1
阶段的装饰器提案的支持,在 TypeScript 5.0
中,将对全新的处于 stage3
阶段的装饰器提案提供支持,开箱即用。
装饰器提案从提出到进入 stage3
阶段,中间经历了大约 9
年的时间,在这期间又经历了多项重大改动。为啥这样一个提案要经历这么长的时间?中间都经历了些什么?下面我们先来回顾一下它的历史。
以下是历史描述:
-
各小组如何开展自己的项目,又如何就 TC39 建议开展合作。
-
TC39 提案是如何通过 the TC39 process 的各个阶段(从 0 开始到 4 结束,即提案准备添加到 ECMAScript 时)取得进展的。在这一过程中,提案发生了许多变化。 按时间顺序记述的相关事件:
-
2014-04-10: 装饰器由 Yehuda Katz 向 TC39 提出。该提案已进入第 0 阶段。
- 卡茨(Katz’s)的提案是与罗恩-巴克顿合作提出的。关于该提案的讨论最早可追溯到 2013 年 7 月。
-
2014-10-22(ngEurope 会议,巴黎):Angular 团队宣布,Angular 2.0 将使用 AtScript 编写,并编译为 JavaScript(通过 Traceur)和 Dart。计划包括将 AtScript 基于 TypeScript,同时添加以下内容:
- 三种注解:
- 类型注解
- 字段注解明确声明字段。
- 元数据注解的语法与装饰器相同,但只添加元数据,不改变注解构造的工作方式。
- 运行时类型检查
- 类型自省
- 三种注解:
-
2015-01-28: Yehuda Katz 和 Jonathan Turner 报告说,Katz 和 TypeScript 团队正在交换意见。
-
2015-03-05(ng-conf,盐湖城):Angular 团队和 TypeScript 团队宣布 Angular 将从 AtScript 切换到 TypeScript,TypeScript 将采用 AtScript 的部分功能(尤其是装饰器)。
-
2015-03-24: 装饰器提案已进入第一阶段。当时,他们在 GitHub 上有一个仓库(由 Yehuda Katz 创建),后来被移到了现在的位置。
-
2015-07-20: TypeScript 1.5 发布后,在
--experimentalDecorators
标志后面支持第 1 阶段装饰器。一些 JavaScript 项目(如 Angular 和 MobX)使用了 TypeScript 的这一功能,这让 JavaScript 看起来已经有了装饰器。
到目前为止,TypeScript 还不支持较新版本的装饰器 API。Ron Buckton 提出的拉取请求提供了对第 3 阶段装饰器的支持,可能会在 v4.9 之后的版本中发布。
-
2016-07-28: 在 Yehuda Katz 和 Brian Terlson 的介绍之后,提案进入了第二阶段。
-
2017-07-27: 丹尼尔-艾伦伯格(Daniel Ehrenberg)在几个月前加入该提案后,举办了他的第一次装饰公司推介会。几年来,他一直推动着公司的发展。
-
后来,克里斯-加勒特(Chris Garrett)加入了该提案,并帮助它进入了第 3 阶段,即 2022-03-28 日。装饰器元数据被转移到另一个提案中,该提案从第 2 阶段开始。
-
2023-01-26
:TypeScript 5.0 beta 版本,支持stage3
阶段的装饰器写法。
可以发现,到达 stage3
阶段花了很长的时间,主要是因为各种利益权衡的问题,很难让各方达成一致,包括其他功能(例如类成员和私有状态)的交互以及性能等方面。
1.3 装饰器分类
1.3.1 TypeScript 5.x 之前
一共5种装饰器可被我们使用:
- 类装饰器
- 属性装饰器
- 方法装饰器
- 访问器装饰器
- 参数装饰器
通常是这样的:
// 类装饰器
@classDecorator
class Bird {
// 属性装饰器
@propertyDecorator
name: string;
// 方法装饰器
@methodDecorator
fly(
// 参数装饰器
@parameterDecorator
meters: number
) {}
// 访问器装饰器
@accessorDecorator
get egg() {}
}
1.3.2 TypeScript 5.x 之后
装饰器的种类为:
- 类装饰器
- 属性装饰器
- 自动访问器装饰器(从 v4.9 引入)
getter/setter
访问器装饰器- 类方法装饰器
与stage 2 的区别是,stage 3 当前就不支持
参数装饰器
了。参数 Decorator
已经从 Decorator 主体的 Proposal 中分离出来,还在讨论中:GitHub 问题 - 参数装饰器。
通常是这样的:
@classDecorator
class Foo {
@fieldDecorator
name: string = "foo";
@accessorDecorator
accessor hoge: number = 0;
@getterDecorator
get bar(): string {
return "bar";
}
@setterDecorator
set bar(v: string) {
// ...
}
@methodDecorator
greet() {
console.log("hello!!")
}
}
1.4 元编程
在一些装饰器的教程中,通常会遇到一个名词:元编程。什么是元编程
?比较晦涩难懂的说法是:
- 我们不编写处理用户数据的代码(编程)。
- 我们编写的代码是处理用户数据的代码(元编程)。
通俗来讲:元编程 (meta-programming) 是通过操作 程序实体 (program entity),在 编译时 (compile time) 计算出 运行时 (runtime) 需要的常数、类型、代码的方法。它的诞生是源于:需要非常灵活的代码来适应快速变化的需求,同时保证性能。
与一般代码的区别是:
- 一般代码的操作对象是数据。
- 元编程的操作对象是代码:code as data。
- 如果编程的本质是抽象,那么元编程就是更高层次的抽象。
1.5 注意事项
需要注意的是,在 TypeScript 5.x 之前,装饰器还只是作为一个实验性功能,使用时需要在tsconfig.json
中开启以下配置:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
从 v5.0 开始,不需要此设置,默认情况下可以使用 stage 3 的装饰器。
- 第一个,
experimentalDecorators
打开装饰器支持。 - 第二个,
emitDecoratorMetadata
,发出包所需的数据reflect-metadata
。这个包使我们能够通过记录有关类、属性、方法和参数的元数据,在装饰器中做一些更强大的事情。
1.6 TS 4.x
和TS 5.x
的装饰器有什么区别?
需要注意的是:
TypeScript 5.0 为装饰器添加了一些新的实现,使其与 ECMAScript 提议的下一阶段保持一致。
装饰器可帮助您自定义类及其成员(以及方法、属性和 set/get 访问器)的功能。装饰器还可以扩展这些功能。装饰器在 TypeScripts 中处于试验阶段已有数年时间,但 TypeScript 5.0 Final 对装饰器的使用方式进行了一些修改,因此如果你在之前的实验性阶段使用过装饰器,建议你查看一下修改内容。
根据微软发布的博客说明,新的装饰器与
--emitDecoratorMetadata
不兼容,并且不再允许装饰参数。
如果你已经使用 TypeScript 有一段时间了,那么你可能已经知道它对 "实验性 "装饰器的支持已经有很多年了。虽然这些实验性装饰器非常有用,但它们是装饰器提案的旧版本模型,并且始终需要一个名为 --experimentalDecorators
的编译器选择标志。在 TypeScript 中尝试使用装饰器时,如果没有这个标记,就会提示错误信息。
在可预见的未来,--experimentalDecorators
将继续存在;不过,如果没有该标志,装饰器现在将成为所有新代码的有效语法。在 --experimentalDecorators
之外,它们将以不同的方式进行类型检查和输出。由于类型检查规则和发射方式完全不同,虽然可以编写装饰器来同时支持新旧装饰器的行为,但任何现有的装饰器函数都不可能这样做。
这项新的装饰器提议与--emitDecoratorMetadata
不兼容,而且不允许装饰参数。未来的 ECMAScript 提议可能会有助于弥合这一差距。
最后一点:目前,装饰器提案要求类装饰器必须在导出关键字之后(如果有的话)。
export @register class Foo {
// ...
}
export
@Component({
// ...
})
class Bar {
// ...
}
TypeScript 将在 JavaScript 文件中执行此限制,但不会在 TypeScript 文件中执行。这样做的部分原因是出于现有用户的考虑 —— 官方希望在最初的 "实验性" 装饰器和标准化装饰器之间提供一条稍微容易一些的迁移路径。
2、类装饰器
2.1 类装饰器函数
装饰器函数只有一个参数:类的构造函数
,类型签名:
// TypeScript 5.x 之前
type ClassDecorator = <TFunction extends Function>
(target: TFunction) => TFunction | void;
// TypeScript 5.x 之后
// 类装饰器接受两个参数:`value`(当前类本身)和`context`(上下文对象)。
// 其中,`context`对象的`kind`属性固定为字符串`class`。
type ClassDecorator = (
value: Function,
context: {
kind: 'class';
name: string | undefined;
addInitializer(initializer: () => void): void;
}
) => Function | void;
我们可以额外从 context
中取到一些信息:
kind
:被修饰的结构类型,包括'class'、'method'、'getter'、'setter'、'accessor'、'field'name
:被修饰的实体名称addInitializer
:一个初始化完成后的回调函数,运行的时机会取决于装饰器的种类:-
- 类装饰器:在类被完全定义并且所有静态字段都被初始化之后运行。
- 非静态类元素装饰器:在实例化期间运行(实例字段被初始化之前)。
- 静态类元素装饰器:在类定义期间运行(在定义静态字段之前但在定义其他所有其他类元素之后)。
来看下面这个例子:
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
,因为该对象包含了附加到类中的方法的实现细节。
运行上面的代码,会得出以下输出:
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
如果不使用类装饰器函数中的必填参数,会发生什么情况?
function Decorator() {
console.log('In Decorator');
}
@Decorator
class FooClass {
foo: string;
}
在这种情况下,会出现编译时错误:
error TS1329: 'Decorator' accepts too few arguments to be used as a decorator here. Did you mean to call it first and write '@Decorator()'?
2.2 带参数的类装饰器
装饰器也可以接受参数,这需要遵循一种不同的模式,即装饰器工厂。下面是一个简单的类装饰器示例,它不仅展示了如何传递参数,还帮助我们理解了使用多个装饰器时的执行顺序。
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
,打印出的信息是:
outer withParam first
outer withParam middle
outer withParam last
inner withParam last
inner withParam middle
inner withParam first
记住,工厂函数是自上而下执行的,而装饰器函数则是自下而上执行的。
2.3 类装饰器应用
让我们来看看类装饰器的一种可能的实际用途。也就是说,一个框架可能会保存某些类型的类的列表。我们来模仿一个网络应用框架,其中某些类包含 URL
路由功能。每个路由器类都处理特定 URL 前缀的路由,以及该路由的特定配置。
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
是一个可选的配置对象。
运行后的输出结果如下:
[
{
constructor: [class HomePageRouter],
path: '/',
options: undefined
},
{
constructor: [class BlogRouter],
path: '/blog',
options: { rss: '/blog/rss.xml' }
}
]
从构造函数对象开始,可以获得大量我们想要得到的额外数据。由于类装饰器最后运行,因此可以选择让类装饰器对类中包含的任何方法或属性进行操作。此外,更常见的数据存储方法不是像这样使用数组,而是使用 Reflection Metadata API
,后面也会介绍这个API。
类装饰器还可以这么用,比如一个收集类的实例:
type Constructor<T = {}> = new (...args: any[]) => T;
class InstanceCollector {
instances = new Set();
install = <Class extends Constructor>(
Value: Class,
context: ClassDecoratorContext<Class>
) => {
const _this = this;
return class extends Value {
constructor(...args: any[]) {
super(...args);
_this.instances.add(this);
}
};
};
}
const collector = new InstanceCollector();
@collector.install
class Calculator {
add(a: number, b: number): number {
return a + b;
}
}
const calculator1 = new Calculator();
const calculator2 = new Calculator();
console.log('instances: ', collector.instances);
2.4 使用类装饰器修改类
类装饰器还可以应用于类构造函数,对类定义进行修改等。
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
,所以我们需要做类型断言,否则会报错。
2.5 小结
-
装饰器接收类对象,我们可以从中访问大量数据。
-
装饰器函数在类对象创建时执行,而不是在类实例构建时执行。这意味着,要直接影响生成的实例,我们必须创建一个匿名子类。
-
使用匿名子类可能比较麻烦。访问任何添加的方法或属性都需要跳过重重障碍,而在匿名子类中,重载的方法或属性都是透明执行的。
3、类属性装饰器
TypeScript 5 以后的新的类属性装饰器具有下面的类型签名:
type ClassFieldDecorator = (
value: undefined,
context: {
kind: 'field';
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown, set: (value: unknown) => void };
addInitializer(initializer: () => void): void;
}
) => (initialValue: unknown) => unknown | void;
相比类方法装饰器,主要有下面两个地方不同:
access
中可以同时拿到setter
和getter
方法,但是类方法装饰器只能拿到getter
方法。- 返回值类型不同,在类属性装饰器中,可以通过返回一个方法来改变属性的初始值
initialValue
装饰器可以加到 TypeScript 类中的属性或字段上,像这样:
class ContainingClass {
@Decorator(?? optional parameters)
name: type;
}
3.1 类装饰器函数
属性装饰器附加于类定义中的属性。在 JavaScript 中,属性是与对象相关联的值。最简单的属性只是对象中声明的一个字段。
属性装饰器函数接收两个参数:
- 对于静态成员,是类的构造函数;对于实例成员,是类的原型。
- 给出属性名称的字符串
来看下面的例子:
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);
输出结果:
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
对象:
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
。
让我们运行这个脚本:
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
对象存在之前执行的。
3.2 类属性装饰器应用
在装饰器函数中,我们会得到一个目标对象、属性名称以及传递给装饰器函数的任何参数。我们无法覆盖或修改属性的行为,我们能做的就是从装饰器中记录数据,就像我们在上面类装饰器应用示例中那样。
举一个数据验证框架的例子。我们可以将装饰器附加到描述可接受值的属性上,然后验证框架将使用这些设置来确定某个值是否可接受:
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
}
});
}
}
上面是两个属性装饰器工厂函数。第一个函数记录了一个验证操作,确保值是一个整数,在给定的值范围内。另一个操作是根据正则表达式进行字符串匹配。这两个操作的数据都记录在注册数组中。
class StudentRecord {
@IntegerRange(1900, 2050)
year: number;
@Matches(/^[a-zA-Z ]+$/)
name: string;
}
const sr1 = new StudentRecord();
console.log(registered);
输出结果:
[
{
target: {},
member: 'year',
operation: { op: 'intrange', min: 1900, max: 2050 }
},
{
target: {},
member: 'name',
operation: { op: 'match', matcher: /^[a-zA-Z ]+$/ }
}
]
可见,通过属性装饰器在另一个位置记录有关属性的任何数据都非常容易。在看一些框架设计中,其他函数可以查阅这些数据并做一些有用的事情。
3.3 使用 Object.defineProperty 的盲区
有些博客上一些关于属性装饰器的教程文章建议使用 Object.defineProperty
来实现运行时数据验证。该建议的缺陷就像我们刚才演示的那样 —— PropertyDescriptor
对象对属性装饰器函数不可用。下面讨论一下使用 defineProperty 的错误建议。从装饰器函数开始:
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
函数强制执行范围。在数据存储方面,函数会将值存储在局部变量中。这看起来简单明了,不是吗?
下面进行测试:
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 的那一行,你会看到它抛出一个异常。
打印结果:
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 类中添加以下字段:
@ValidRange(0, 150)
age: number;
增加另一个由 @ValidRange
管理的属性。我们会看到同样的数据共享问题吗?年龄的值是否与年份的值相同?
测试一下:
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
实例的年龄赋值,然后打印两个实例的年龄:
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 实例:
const stud_3 = new Student();
然后,不要为该实例赋值,而是添加一条 console.log
语句:
console.log(`stud_3 ${stud_3.year} ${stud_3.age}`);
输出结果:
stud_3 1911 20
尽管 stud_3
没有分配任何值,但它显示了相同的值。
Student.year
属性的每个实例都共享相同的值,Student.age
属性的每个实例也是如此。这是因为 @ValidRange
管理的是数据存储,而不是 JavaScript。造成这种情况的原因是使用 Object.defineProperty
的方式不正确。JavaScript 确实为我们提供了很多工具来解决这些问题。
在执行属性装饰器函数时,JavaScript 尚未创建 PropertyDescriptor
对象。覆盖该属性描述符的 get/set
函数会非常强大。如果创建了自己的属性描述符,并认为已经实现了运行时数据验证的目标,那将会产生误导。
后下面会讲到访问者装饰器,通过覆盖正确属性描述符中的
get/set
函数来实现运行时数据验证。
3.4 小结
我们可以将装饰器附加到属性上。这意味着我们可以记录附加到每个属性的装饰器的信息,然后对这些数据进行处理。但是,我们却不知道如何访问 PropertyDescriptor
以及如何使用 get/set
函数。原因在于类装饰器函数的执行时机。
如果我们能覆盖 PropertyDescriptor
中的 get/set
方法,就会有很多可能性。但是,对于属性而言,在为属性赋值之前,该对象是不存在的。
我们可以在数据结构中记录装饰器信息。例如,class-validator
包中有 @IsInt
或 @Min
或 @Max
这样的装饰器来验证属性值。我们知道,它必须将这些信息记录到数据结构中,当应用程序调用 validate
函数时,它必须检查这些数据,以便知道如何验证类实例。