现在的前端项目中,装饰器作为装饰器模式的语法糖得到了越来越广泛的应用。本文会先介绍装饰器的具体用法,然后会将 Javascript 中的装饰器和其他语言常见的装饰器用法进行比较,展示 Javascript 中装饰器的不同之处。
什么是 Decorator
Decorator 装饰器是 ECMAScript 的一个语言功能提案,目前(文章书写时间 2022-02-09 13:54:26)还处于 stage-2 阶段。但是借助 TypeScript 或者 Babel,已经有大量的优秀开源项目深度使用上它了。比如:VS Code, Angular, Nest.js, Mobx 等等。Decorator 装饰器本质上是一种特殊的函数,在定义类、类元素时通过 @
来引用该函数,可以对被装饰的目标进行注入添加新的功能而无需从根本上改变其外部行为。
一个 Decorator 的例子:
1 | "my-class") ( |
Decorator 的作用
主要的功能
Decorator 有四个主要的功能
- 它可以用具有相同语义的匹配值替换正在修饰的值。(例如,装饰器可以用另一个方法替换一个方法,用另一个字段替换一个字段,用另一个类替换一个类,等等)。
- 它可以将元数据与正在修饰的值相关联。然后可以从外部读取这些元数据,并将其用于元编程(metaprogramming, 编写能改变语言语法特性或者运行时特性的程序)和自省(introspection, 检查对象的一种能力)。
- 它可以通过元数据提供对正在修饰的值的访问。对于公共值(public),他们可以通过值的名称来实现这一点。对于私有值(private),它们接收访问器函数(accessor functions),然后可以选择共享这些函数。
- 它可以初始化被修饰的值,在值被完全定义后运行额外的代码。如果该值是类的成员,则每个实例都会进行一次初始化。
生命周期
- 装饰器表达式(在
@
符号后的内容)的求值过程中穿插着计算属性名。 - 在类定义期间,在对方法进行评估之后,但在将构造函数和原型组合在一起之前,会调用装饰器(作为函数)。
- 在调用了所有装饰器之后,装饰器会同时被应用(修改构造函数和原型)。
应用场景
常见应用场景:
日志记录,性能统计,类型检查
Decorator的局限性
使用装饰器语法特性的确可以带来很多方便,但还是有许多需要注意的地方,一方面是由于 Javascript 语言实现上的特性,一方面是装饰器语法本身的问题,还有一方面是语法方案本身进度的问题。
不支持方法装饰器
有关方法装饰器可参见 function-decorators-and-annotations 。其他语言像 python 是支持方法装饰器的。但是因为 Javascript 中的方法会变量提升,而装饰器并不会提升(详见Why decorator should not be hoisted),所以 Javascript Decorator 草案初版不打算支持方法装饰器。详见Allow decorating functions。
不支持编译时装饰器
像 java 之类语言,装饰器(java一般叫注解 Annotation)可以在编译时产生不影响输出的代码。但 Javascript 不存在输出字节码这步,原生当然不支持编译时装饰器。不过工具如 Typescript 其实理论上是可以支持的。
无法被tree-shaking
目前从实现上来说,无论是 babel-plugin-transform-decorators
还是 Typescript 本质上都是一个 expression 。构建工具都没办法知道这个 expression 做了什么,所以被装饰的类没有办法被 tree-shaking 。例如下面的 TestClass 就无法被 tree-shaking 。
1 | class TestClass { |
代码压缩工具如 Terser 或 Uglify.js 支持用 /*@__PURE__*/
comment来声明方法为 pure 。
1 | const x = /*#__PURE__*/i_am_dropped_if_x_is_not_used() |
Typescript 中可以使用 /** @class */
注释。
1 | var TestClass = /** @class */(function () { |
我们可以在构建工具利用字符替换将这个注释替换成自动替换成 pure 。
1 | plugins: [ |
过多的装饰器增加调试成本
装饰器层次增多,会增加调试成本,很难追溯到一个 Bug 是在哪一层包装导致的。
装饰器语法仍处于草案状态
当前的 tc39 装饰器语法草案 与 Babel “legacy” decorators 和 Typescript “experimental” decorators 实现均不同。虽然 Babel 7 已经支持了 TC39 2018.11 会议上通过的草案,但其实现仍旧面临了性能上的问题。所以 tc39 的建议是,如果在之前已经使用了装饰器语法,那么继续按照老的方案使用。
Typescript 中的 Decorator 实现
目前 Typescript 中的 Decorator 实现是基于 2014 年的 JavaScript 装饰器语法草案。详细实现参见 Typescript “experimental” decorators。
Decorator 例子
假设我们要实现一个 Person
的类,这个类包括两个方法 walk
与 run
,我们通过装饰器记录 walk
与 run
消耗的时间。
1 | function logTime(target, key, desc) { |
__decorate 方法解析
上述代码经过 Typescript 转换后变为如下代码(emitDecoratorMetadata=false 不生成装饰器元数据):
可以在 Typescript Playground 看到完整代码
1 | ; |
上述代码有关装饰器实现的核心点是两个部分,一个是定义,一个是执行。
Part1 装饰器工具函数
__decorate
的执行执行会传入以下 4 个参数:
- 装饰器业务逻辑函数
- 类的构造器
- 类的构造器属性名
- 属性描述符(可以为null)
Part2 装饰器工具函数
__decorate
的定义首先,将该函数主要逻辑无关的部分剥离
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17var __decorate = function (decorators, target, key, desc) {
// Step1 备份原来类构造器 (Class.prototype) 的属性描述符 (Descriptor)
var r = desc = Object.getOwnPropertyDescriptor(target, key),
var d;
for (var i = decorators.length - 1; i >= 0; i--) {
// d 为装饰器业务逻辑函数
if (d = decorators[i]) {
// Step2 执行 d,并传入 target 类构造器,key 属性名,r 属性描述符
r = d(target, key, r) || r;
}
}
// Step3 用装饰器函数覆盖原来属性描述符
Object.defineProperty(target, key, r);
return r;
};我们可以看到,Typescript 对装饰器编译后的代码,是将装饰器逻辑抽离成了一个工具函数,这个工具函数主要包括三个部分:
- 使用
Object.getOwnPropertyDescriptor
备份原来的类构造器的属性描述符 - 执行装饰器业务逻辑函数,并传入类构造器、属性名、属性描述符
- 使用
Object.defineProperty
将装饰器函数覆盖原来的属性描述符
- 使用
Decorator 类型
Typescript 中的装饰器一共有5种类型:
- 类装饰器(Class)
- 类属性装饰器(Class Field / Property)
- 类方法装饰器(Class Method)
- 类访问器装饰器(Calss Accessor)
- 参数装饰器(Parameter)
需要注意的是,在最新版 tc39 stage2 草案中,参数装饰器暂时没有被安排在内。并且 tc39 的草案与 Typescript 的实现有较大差别。
Typescript 中各种装饰器的入参和返回值可参见以下描述:
1 | interface TypedPropertyDescriptor<T> { |
Decorator 执行顺序
同一个类的方法有多个装饰器,其执行顺序为先从外到内进入,然后由内向外执行。
1 | function dec(id) { |
同一个类有不同类型的装饰器时,按照以下顺序执行:
- 按照 实例 >> 静态 >> 构造函数 > 类 的优先级顺序执行
- 同一优先级内的(参数|方法/访问器/属性)装饰器按照出现的先后顺序执行
- 同一个方法既有方法装饰器又有参数装饰器时,参数装饰器先执行
1 | function d(value: string) { |