一文读懂@Decorator装饰器——理解VS Code源码的基础(下)
导语 | 本人在读VS Code源码的时候,发现其用了大量的@Decorator装饰器语法,由于对装饰器的语法比较陌生,它成为了我理解VS Code的拦路虎。其实不止VS Code,Angular、Node.js框架Nest.js、TypeORM、Mobx(5) 和Theia等都深度用到了装饰器语法,为了读懂各大优秀开源项目,让我们先一起来把@Decorator装饰器的原理以及用法彻底弄懂。
一、@Decorator装饰器语法糖
《一文读懂@Decorator装饰器——理解VS Code源码的基础(上)》中手写的装饰器函数存在两个可优化的点:
是否可以让装饰器函数更关注业务逻辑?
《一文读懂@Decorator装饰器——理解VS Code源码的基础(上)》中Step1, Step2是通用逻辑的,每个装饰器都需要实现,简单来说就是可复用的。
是否可以让装饰器写法更简单?
纯函数实现的装饰器,每装饰一个属性都要手动执行装饰器函数,详细内容见《一文读懂@Decorator装饰器——理解VS Code源码的基础(上)》中Step4步骤。
针对上述优化点,装饰器草案中有一颗特别甜的语法糖,也就是@Decorator,它能够帮你省去很多繁琐的步骤来用上装饰器。只需要在想使用的装饰器前加上@符号,装饰器就会被应用到目标上。
(一)@Decorator语法糖的便捷性
下面我们用@Decorator的写法,来实现同样的功能,看看代码量可以精简多少:
// Step2 编写装饰器函数业务逻辑代码
function logTime(target, key, descriptor) {
const oldMethed = descriptor.value
const logTime = function (...arg) {
let start = +new Date()
try {
return oldMethed.apply(this, arg) // 调用之前的函数
} finally {
let end = +new Date()
console.log(`耗时: ${end - start}ms`)
}
}
descriptor.value = logTime
return descriptor
}
class GuanYu {
// Step4 利用 @ 语法糖装饰指定属性
@logTime
attack() {
console.log('挥了一次大刀')
}
// Step4 利用 @ 语法糖装饰指定属性
@logTime
run() {
console.log('跑了一段距离')
}
}
const guanYu = new GuanYu()
guanYu.attack()
// [LOG]: 挥了一次大刀
// [LOG]: 耗时: 3ms
guanYu.run()
// [LOG]: 跑了一段距离
// [LOG]: 耗时: 3ms
为了让大家更直观了解上述代码是否可以编译后正常执行,我们可以从TypeScript Playground(https://www.typescriptlang.org/play) 直接看到编译后的代码以及运行结果。
注意!为了方便理解,记得关闭配置emitDecoratorMetadata禁止输出元数据,元数据是另一个比较复杂的知识点,我们本篇文章先跳过。关闭后编译的代码会更简单!
我们打开上面代码的在线Playground链接,点击Run运行按钮,即可看到其正常运行和输出结果:
对比纯手写的装饰器,用@Decorator语法糖可以省去2个重复的步骤:
Step1 备份原来类构造器(Class.prototype)的属性描述符(Descriptor)
const oldDescriptor = Object.getOwnPropertyDescriptor(targetPrototype, key)
Step3 用装饰器函数覆盖原来属性描述符的value
Object.defineProperty(targetPrototype, key, {
...oldDescriptor,
value: logTime
})
开发者仅需两步即可实现装饰器的功能,可以更专注于装饰器本身的业务逻辑:
Step2 编写装饰器函数业务逻辑代码
function logTime(target, key, descriptor) {
const oldMethed = descriptor.value
const logTime = function (...arg) {
let start = +new Date()
try {
return oldMethed.apply(this, arg) // 调用之前的函数
} finally {
let end = +new Date()
console.log(`耗时: ${end - start}ms`)
}
}
descriptor.value = logTime
return descriptor
}
Step4 利用@语法糖装饰指定属性
@logTime
attack() {
console.log('挥了一次大刀')
}
(二)分析@Decorator语法糖编译后的代码【重点】
@Decorator语法糖很甜,但却不能直接食用。因为装饰器目前仅仅是ECMAScript的语言提案,还处于stage-2阶段。
(https://github.com/tc39/proposal-decorators)
无论是最新版的Chrome浏览器还是Node.js都不能直接运行带有@Decorator语法糖的代码。
我们需要借助TypeScript或者Babel的能力,将源码编译后才能正常运行。而在TypeSciprt Playground上,我们可以直接看到编译后代码。
为了更清晰容易理解,我们把编译后的业务代码先注释掉,只看装饰器实现的相关代码:
"use strict";
// Part1 装饰器工具函数(__decorate)的定义
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
function logTime(target, key, descriptor) {
// ...
}
class GuanYu {
// ...
}
// Part2 装饰器工具函数(__decorate)的执行
__decorate([logTime], GuanYu.prototype, "attack", null);
__decorate([logTime], GuanYu.prototype, "run", null);
// ...
上述代码核心点是两个部分,一个是定义,一个是执行。定义部分较为复杂,我们先从执行入手:
Part2 装饰器工具函数(__decorate)的执行会传入以下4个参数:
装饰器业务逻辑函数
类的构造器
类的构造器属性名
属性描述符(可以为null)
为了方便理解Part1装饰器工具函数__decorate的定义,我们需要来精简__decorate的函数代码,让它变成最简单的样子,而精简代码的前提是收集条件:
条件1 (this&&this.__decorate) 可删除
这里的this是指window对象,这一步的含义是避免重复定义__decorate函数,属于辅助代码,可删掉。
条件2 c<3===false
Part1的c=arguments.length代表参数的个数,由Part2我们知道工具函数会传入4个参数,因此在本次案例中c<3参数个数小于3的情况不存在,即c<3===false。
条件3 c>3===true
本次案例中c>3参数大于3的情况存在,即c>3===true。
条件4 desc===null
同时在Part1我们知道第四个参数desc传入的值就是null,即desc===null。
条件5 typeof Reflect!=="object"
Reflect反射是ES6的语法,本文为了更容易理解,暂不引入新的ES6特性和语法,让环境默认为ES5,即不存在Reflect对象,即typeof Reflect!=="object"。
有了上述条件后,我们可以进一步精简__decorate的方法:
代码片段1:
r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc
// 根据 c < 3 === false , desc === null 条件
// 精简后
r = desc = Object.getOwnPropertyDescriptor(target, key)
// r 和 desc 此时代表的是属性的描述符 Descriptor
代码片段2:
if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
// 根据 c < 3 === false , c > 3 === true 条件
// 精简后
if (d = decorators[i]) r = d(target, key, r) || r;
代码片段3:
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
// 为了方便理解,本案例暂认为 Reflect 不存在
// 精简后
// 空
代码片段4:
return c > 3 && r && Object.defineProperty(target, key, r), r;
// 根据 c > 3 === true, r 是属性描述符,必定存在
// 精简后
Object.defineProperty(target, key, r)
return r;
精简后整体代码:
var __decorate = function (decorators, target, key, desc) {
var c = arguments.length;
// 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]) {
// 执行 d,并传入 target 类构造器,key 属性名,r 属性描述符
r = d(target, key, r) || r;
}
}
// Step3 用装饰器函数覆盖原来属性描述符
Object.defineProperty(target, key, r)
return r;
};
代码经过精简之后核心原理还是和我们《一文读懂@Decorator装饰器——理解VS Code源码的基础(上)》中手写一个装饰器函数的原理是一样的。
Step1 备份原来类构造器 (Class.prototype) 的属性描述符(Descriptor)
利用Object.getOwnPropertyDescriptor获取
Step3 用装饰器函数覆盖原来属性描述符的value
利用Object.defineProperty代理
TypeScript对装饰器编译后的代码,只不过是把装饰器可复用的逻辑抽离成一个工具函数,方便复用而已。
分析到这里,是不是对@Decorator装饰器最根本的实现有了更深入的了解?
从上面的例子,我们也进一步验证了:
Decorator Pattern装饰器模式的设计理念:在不修改原有代码情况下,对功能进行扩展。
Decorator装饰器的具体实现,本质是函数,参数有target, key, descriptor。
@Decoretor是装饰器的一种语法糖,只是一种便捷写法,编译后本质还是一个函数。
二、带参数的装饰器:装饰器工厂函数
在《一文读懂@Decorator装饰器——理解VS Code源码的基础(上)》的「记录函数耗时」例子中,如果我们希望在日志前面加个可变的标签,如何实现?
答案是使用带参数的装饰器。
重点:logTime是个高阶函数,可以理解成装饰器工厂函数,其接收参数执行后,返回一个装饰器函数。
function logTime(tag) { // 这是一个装饰器工厂函数
return function(target, key, descriptor) { // 这是装饰器
const oldMethed = descriptor.value
const logTime = function (...arg) {
let start = +new Date()
try {
return oldMethed.apply(this, arg)
} finally {
let end = +new Date()
console.log(`【${tag}】耗时: ${end - start}ms`)
}
}
descriptor.value = logTime
return descriptor
}
}
class GuanYu {
@logTime('攻击')
attack() {
console.log('挥了一次大刀')
},
@logTime('奔跑')
run() {
console.log('跑了一段距离')
}
}
// ...
编译后:
// ...
__decorate([logTime('攻击')], GuanYu.prototype, "attack", null);
__decorate([logTime('奔跑')], GuanYu.prototype, "run", null);
// ...
看了编译后的代码,我们就很容易知道带参数装饰器的具体实现原理,无非是直接先执行装饰器工厂函数,此时传入对应参数,然后返回一个新的装饰器业务逻辑的函数。
三、五种装饰器
我们前面学了那么多装饰器的内容,其实只学了一种装饰器:方法装饰器。
而装饰器一共有5种类型可被我们使用:
类装饰器
属性装饰器
方法装饰器
访问器装饰器
参数装饰器
先来个全家福,然后我们逐一攻破:
// 类装饰器
@classDecorator
class GuanYu {
// 属性装饰器
@propertyDecorator
name: string;
// 方法装饰器
@methodDecorator
attack (
// 参数装饰器
@parameterDecorator
meters: number
) {}
// 访问器装饰器
@accessorDecorator
get horse() {}
}
(一)类装饰器
类型声明:
// 类装饰器
function classDecorator(target: any) {
return // ...
};
@参数:只接受一个参数
@返回:如果类装饰器返回了一个值,她将会被用来代替原有的类构造器的声明
因此,类装饰器适合用于继承一个现有类并添加一些属性和方法。
例如我们可以添加一个addToJsonString方法给所有的类来新增一个toString方法:
function addToJSONString(target) {
return class extends target {
toJSONString() {
return JSON.stringify(this);
}
};
}
@addToJSONString
class C {
public foo = "foo";
public num = 24;
}
console.log(new C().toJSONString())
// [LOG]: "{"foo":"foo","num":24}"
(二)方法装饰器
已经在前面章节介绍过利用方法装饰器来实现「记录函数耗时」功能,现在我们重新复习下。
类型声明:
// 方法装饰器
function methodDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
return // ...
};
@参数:
target: 对于静态成员来说是类的构造器,对于实例成员来说是类的原型链。
propertyKey: 属性的名称。
descriptor: 属性的描述器。
(https://developer.mozilla.org/zh- CN/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyDescriptor)
@返回:如果返回了值,它会被用于替代属性的描述器。
利用方法装饰器我们可以实现更多的具体场景,比如「打印Request的请求参数和结果」功能:
function loggerParamsResult(target, propertyKey, descriptor) {
const original = descriptor.value;
descriptor.value = async function (...args) {
let result
let error
try {
result = await original.call(this, ...args);
} catch(e) {
error = new Error(e)
}
if (error) {
console.error('请求失败!')
console.error('请求参数: ', ...args)
console.error('失败原因: ', error)
} else {
console.log('请求成功!')
console.log('请求参数', ...args)
console.log('请求结果: ', result)
}
return result;
}
}
class App {
@loggerParamsResult
request(data) {
return new Promise((resolve, reject) => {
const random = Math.random() > 0.5
if (random) {
resolve(random)
} else {
reject(random)
}
})
}
}
const app = new App();
app.request({ url: 'https://www.tencent.com'});
// [LOG]: "请求成功!"
// [LOG]: "请求参数", {
// "url": "https://www.tencent.com"
// }
// [LOG]: "请求结果: ", true
// [ERR]: "请求失败!"
// [ERR]: "请求参数: ", {
// "url": "https://www.tencent.com"
// }
// [ERR]: "失败原因: ", false
总结:无论是「记录函数耗时」还是「打印Request的请求参数和结果」,本质都是在实现Before/After钩子,因此我们只需要记住方法装饰器可以实现与Before/After钩子相关的场景功能。
(三)属性装饰器
类型声明:
// 属性装饰器
function propertyDecorator(target: any, propertyKey: string) {
}
@参数: 只接受两个参数,少了descriptor描述器
target: 对于静态成员来说是类的构造器,对于实例成员来说是类的原型链。
propertyKey: 属性的名称。
@返回: 返回的结果将被忽略
利用属性装饰器,我们可以实现一个非常简单的属性监听功能,当属性改变时触发指定函数:
function observable(fnName) { // 装饰器工厂函数
return function (target: any, key: string): any { // 装饰器
let prev = target[key];
Object.defineProperty(target, key, {
set(next) {
target[fnName](prev, next);
prev = next;
}
})
}
}
class Store {
@observable('onCountChange')
count = -1;
onCountChange(prev, next) {
console.log('>>> count has changed!')
console.log('>>> prev: ', prev)
console.log('>>> next: ', next)
}
}
const store = new Store();
store.count = 10
// [LOG]: ">>> count has changed!"
// [LOG]: ">>> prev: ", undefined
// [LOG]: ">>> next: ", -1
// [LOG]: ">>> count has changed!"
// [LOG]: ">>> prev: ", -1
// [LOG]: ">>> next: ", 10
(四)访问器装饰器
访问器装饰器总体上讲和方法装饰器很接近,唯一的区别在于第三个参数descriptor描述器中有的key不同:
方法装饰器的描述器的key为:
value
writable
enumerable
configurable
访问器装饰器的描述器的key为:
get
set
enumerable
configurable
类型声明:
// 访问器装饰器
function methodDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
return // ...
};
例如,我们可以将某个属性在赋值的时候做一层代理,额外相加一个值:
function addExtraNumber(num) { // 装饰器工厂函数
return function (target, propertyKey, descriptor) { // 装饰器
const original = descriptor.set;
descriptor.set = function (value) {
const newObj = {}
Object.keys(value).forEach(key => {
newObj[key] = value[key] + num
})
return original.call(this, newObj)
}
}
}
class C {
private _point = { x: 0, y: 0 }
@addExtraNumber(2)
set point(value: { x: number, y: number }) {
this._point = value;
}
get point() {
return this._point;
}
}
const c = new C();
c.point = { x: 1, y: 1 };
console.log(c.point)
// [LOG]: {
// "x": 3,
// "y": 3
// }
(五)参数装饰器
类型声明:
// 参数装饰器
function parameterDecorator(target: any, methodKey: string, parameterIndex: number) {
}
@参数:接收三个参数
target: 对于静态成员来说是类的构造器,对于实例成员来说是类的原型链。
methodKey: 方法的名称,注意!是方法的名称,而不是参数的名称。
parameterIndex: 参数在方法中所处的位置的下标。
@返回:返回的值将会被忽略
单独的参数装饰器能做的事情很有限,它一般都被用于记录可被其它装饰器使用的信息。
function Log(target, methedKey, parameterIndex) {
console.log(`方法名称 ${methedKey}`);
console.log(`参数顺序 ${parameterIndex}`);
}
class GuanYu {
attack(@Log person, @Log dog) {
console.log(`向 ${person} 挥了一次大刀`)
}
}
// [LOG]: "方法名称 attack"
// [LOG]: "参数顺序 0"
(六)装饰器参数总结
四、装饰器顺序
(一)同种装饰器组合顺序:洋葱模型
如果同一个方法有多个装饰器,其执行顺序是怎样的?
答案:以方法装饰器为例,同种装饰器组合后,其顺序会像剥洋葱一样,先从外到内进入,然后由内向外执行。和Koa的中间件顺序类似。
function dec(id){
console.log('装饰器初始化', id);
return function (target, property, descriptor) {
console.log('装饰器执行', id);
}
}
class Example {
@dec(1)
@dec(2)
method(){}
}
// 装饰器初始化 1
// 装饰器初始化 2
// 装饰器执行 2
// 装饰器执行 1
其原理,看编译后的代码就非常清楚:
重点:
dec(1), dec(2)初始化时就执行。
for(var i=decorators.length-1; i>=0;i--) 是从右向左,倒叙执行。
// 由于本段代码不存在 c < 3 (参数少于3个) 的情况,为了方便理解已精简了部分不可能执行的代码
var __decorate = function (decorators, target, key, desc) {
var c = arguments.length,
r = desc = Object.getOwnPropertyDescriptor(target, key),
d;
for (var i = decorators.length - 1; i >= 0; i--)
if (d = decorators[i]) r = d(target, key, r) || r;
Object.defineProperty(target, key, r)
return r;
};
function dec(id) {
console.log('装饰器初始化', id);
return function (target, property, descriptor) {
console.log('装饰器执行', id);
};
}
class Example {
method() { }
}
__decorate([
dec(1),
dec(2)
], Example.prototype, "method", null);
// 装饰器初始化 1
// 装饰器初始化 2
// 装饰器执行 2
// 装饰器执行 1
(二)不同类型装饰器顺序:有规则有规律
实例成员:(参数>方法) 访问器 属性 装饰器 (按顺序)
静态成员:(参数>方法) 访问器 属性 装饰器 (按顺序)
构造器:参数装饰器
类装饰器
多种装饰器优先级为:实例成员最高,内部成员里面的装饰器则按定义顺序执行,依次排下来,类装饰器最低。
function f(key: string): any {
// console.log("初始化: ", key);
return function () {
console.log("执行: ", key);
};
}
@f("8. 类")
class C {
@f("4. 静态属性")
static prop?: number;
@f("5. 静态方法")
static method(@f("6. 静态方法参数") foo) {}
constructor(@f("7. 构造器参数") foo) {}
@f("2. 实例方法")
method(@f("1. 实例方法参数") foo) {}
@f("3. 实例属性")
prop?: number;
}
// "执行: ", "1. 实例方法参数"
// "执行: ", "2. 实例方法"
// "执行: ", "3. 实例属性"
// "执行: ", "4. 静态属性"
// "执行: ", "6. 静态方法参数"
// "执行: ", "5. 静态方法"
// "执行: ", "7. 构造器参数"
// "执行: ", "8. 类"
五、装饰器总结
(一)应用场景
装饰器很像是组合一系列函数,类似于高阶函数和类。合理利用装饰器对一些非内部逻辑相关的代码进行封装提炼,能够帮助我们快速完成重复性的工作,节省时间,极大提高开发效率。
类装饰器
可添加额外的方法和属性,比如:扩展toJSONString方法。
方法装饰器
可实现Before/After钩子功能,比如:记录函数耗时,打印request参数结果,节流防抖。
属性装饰器
可监听属性改变触发其他事件,比如:实现count监听器。
访问器装饰器
参数装饰器
当然,还有更多可以使用装饰器的场景等着我们去发现:
运行时类型检查
依赖注入
(二)优点
在不修改原有代码情况下,对功能进行扩展。也就是对扩展开放,对修改关闭。
优雅地把“辅助性功能逻辑”从“业务逻辑”中分离,解耦出来。(AOP 面向切面编程的设计理念)
装饰类和被装饰类可以独立发展,不会相互耦合。
装饰模式是Class继承的一个替代模式,可以理解成组合。
(三)缺点
但是糖再好吃,也不要吃太多,容易坏牙齿的,滥用过多装饰器会导致很多问题:
理解成本:过多带业务功能的装饰器会使代码本身逻辑变得扑朔迷离。
调试成本:装饰器层次增多,会增加调试成本,很难追溯到一个Bug是在哪一层包装导致的。
(四)注意事项
装饰器的功能逻辑代码一定是辅助性的。
比如日志记录,性能统计等,这样才符合AOP面向切面编程的思想,如果把过多的业务逻辑写在了装饰器上,效果会适得其反。
装饰器语法尚未定案以及未被纳入ES标准,标准化的过程还需要很长时间。
由于装饰器语法未来制定的标准可能与当前的装饰器实现方案有所不同,Mobx6出于兼容性的考虑,放弃了装饰器的用法,并建议使用makeObservable/makeAutoObservable来进行代替。
(https://zh.mobx.js.org/observable-state.html)
详情请查看:https://zh.mobx.js.org/enabling-decorators.html
装饰器提案进度:https://github.com/tc39/proposal-decorators
作者简介
阮易强(easonruan)
腾讯高级前端开发工程师
腾讯高级前端开发工程师,曾负责过「粤省事」、「穗康」等大型小程序项目,目前是WeDa微搭低代码平台(专有版)的核心开发人员,有丰富低代码平台研发经验。
推荐阅读
一文读懂@Decorator装饰器——理解VS Code源码的基础(上)