decorator 装饰器
前置知识
面向切面编程(AOP)
AOP 即面向切面编程(Aspect Oriented Programming)
类似于“洋葱模型”,AOP 主要意图是将日志记录,性能统计,安全控制,异常处理等代码从业务逻辑代码种抽离出来,将它们独立到非核心业务逻辑的方法中,进而达到改变这些功能行为的时候不会影响核心业务逻辑的代码。
简而言之:就是“优雅”的把“辅助功能逻辑”从“核心业务逻辑”中分离、解耦出来。
控制反转(IoC)
IoC 即 控制反转(Inversion of Control) 是解耦的一种设计模式。
在传统的程序设计中,我们的代码直接控制所有的流程和对象的创建。而采用 IoC 后,这种控制权被反转了,意味着对象的创建和流程的控制不是由我们的代码直接管理,而是交给外部的容器或框架来处理。
AOP 可以通过 IoC 容器来实现。
依赖注入(DI)
DI 即 依赖注入(Dependency Injection),是 IoC 的一种具体实现。
依赖注入允许我们的代码在运行时接收它的依赖项,而不是硬编码它们,从而提高了代码的模块化和可测试性。
属性描述符
Object.defineProperty(obj, props, descriptor)
的作用是直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。该方法接收三个参数:
要定义属性的对象(obj)
要定义或修改的属性名或
Symbol
(props)要定义或修改的属性描述符(descriptor)
属性描述符有两种主要形式
数据描述符:是一个具有值的属性(value),该值可以是可写的,也可以是不可写的(writable)
存取描述符:由 getter 函数和 setter 函数所描述的属性。
一个属性描述符只能是这两者种的其中一种,不能同时是两者。
共享的属性描述符键值
configurable
:属性是否可以被删除或者是否可以再次修改其属性描述符,默认值为false
如果 configurable 设置为 true,则该属性的描述符可以被改变,也可以从所属对象上删除该属性。
如果 configurable 设置为 false,则除了直接修改属性的值和修改其 writable 属性为 false 之外,不能做其他修改。也就是说,一旦属性被设置为不可配置(configurable: false),就不能再将它变回可配置的了。
enumerable
:属性是否会出现在对象的枚举属性种,默认值为false
数据描述符特有键值
value
:该属性对应的值,默认值为undefined
writable
:属性是否可以被修改,默认值是false
存取描述符特有键值
get:一个给属性提供 getter 的方法。当访问该属性时,会调用此方法,并返回其返回值。默认为
undefined
set:一个给属性提供 setter 的方法。当属性值被修改时,会调用此方法,该方法将接收唯一的参数,即该属性的新值。默认为
undefined
定义
装饰器模式
装饰器模式(Decorator Pattern)是一种抽象的设计模式,核心思想是在不修改原有代码情况下,对功能进行扩展
遵循的设计模式原则
单一职责原则
开闭原则
装饰器
装饰器(Decorator)是一种特殊的装饰类函数,是一种对装饰器模式理念的具体实现。
它接受一个函数或类作为参数,并返回一个已经被修改或增强功能的函数或类的版本
装饰器工厂函数
装饰器工厂函数是返回一个装饰器的函数。这使得装饰器可以接受参数,因此可以在不同的场景下提供更多的灵活性。装饰器工厂函数先执行,其返回值(一个装饰器函数)随后应用于目标函数或类。
装饰器语法
@decorator
装饰器语法是一种便捷的语法糖,通过 @
来引用,需要编译后才能进行。
装饰器用法
装饰器语法@decorator
,不过目前还处于第 2 阶段提案中,使用它之前需要使用 TypeScript 或 Babel 模块编译成 ES5 或 ES6。
类装饰器
语法
// 类装饰器
function classDecorator(target) {
return // ...
};
参数:接受一个参数
target:类的构造器
返回值:如果类装饰器返回了一个值,她将会被用来代替原有的类构造器的声明
适用场景
给当前类添加一些属性和方法
继承当前类,并进行扩展
举例
可以添加一个 addToJsonString 方法给所有的类来新增一个 toString 方法
function addToJsonString(target) {
return class extends target {
toJsonString() {
return JSON.stringify(this)
}
}
}
@addToJsonString
class Demo {
one = 1
two = 2
}
console.log(new Demo().toJsonString()) // {"one":1,"two":2}
方法装饰器
语法
// 方法装饰器
function methodDecorator(target, propertyKey, descriptor) {
return // ...
};
参数
target: 对于静态成员来说是类的构造器,对于实例成员来说是类的原型链
propertyKey: 属性的名称
descriptor: 属性的描述器
返回值:如果返回了值,它会被用于替代属性的描述器。
使用场景
方法装饰器可以实现与 Before / After 钩子 相关的场景功能。
举例
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 Demo {
@logTime
run() {
console.log('start')
}
}
console.log(new Demo().run())
// start
// 耗时: 14ms
属性装饰器
语法
语法跟方法装饰器类似,只不过不要随意修改 decorator 的 value,因为属性装饰器会在代码运行前生效,所以修改了 value 可能不会生效。
使用场景
修改属性的描述符
举例
function readonly(target, name, descriptor) {
descriptor.writable = false
return descriptor
}
class Person {
@readonly
name = 'zhangsan'
}
const person = new Person()
console.log(person.name, Object.getOwnPropertyDescriptor(person, 'name'))
// zhangsan {value: 'zhangsan',writable: false,enumerable: true,configurable: true}
访问器装饰器
语法
语法跟方法装饰器类似,唯一的区别在于第三个参数 descriptor 描述器中有的 key 不同:
方法装饰器的描述符的 key 为:
value
writable
enumerable
configurable
访问器装饰器的描述符的 key 为:
get
set
enumerable
configurable
使用场景
可以在某个属性赋值的时候做加一层代理
举例
// 装饰器工厂函数
function addExtraNumber(num) {
// 返回的函数才是真正的装饰器
return function (target, propertyKey, descriptor) {
const original = descriptor.set
descriptor.set = function (value) {
const number = value + num
return original.call(this, number)
}
}
}
class Demo {
x = 1
@addExtraNumber(2)
set number(num) {
this.x = num
}
get number() {
return this.x
}
}
const test = new Demo()
test.number = 10
console.log(test.number) // 12
参数装饰器(TypeScript 实现)
语法
// 参数装饰器
function parameterDecorator(target, methedKey, parameterIndex) {
}
参数:接收三个参数
target: 对于静态成员来说是类的构造器,对于实例成员来说是类的原型链
methedKey: 方法的名称,注意!是方法的名称,而不是参数的名称
parameterIndex: 参数在方法中所处的位置的下标
返回:返回的值将会被忽略
使用场景
单独的参数装饰器能做的事情很有限,它一般都被用于记录可被其它装饰器使用的信息。
举例(需要 TypeScript 编译)
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"
装饰器的实现原理
class Demo {
run() {
console.log('start')
}
}
给 run 方法增加耗时计算
硬编码
class Demo {
run() {
const start = +new Date()
console.log('start')
const end = +new Date()
console.log(`耗时: ${end - start}ms`)
}
}
硬编码存在的问题:
理解成本高:统计耗时的相关代码与函数本身逻辑并无关系,对函数结构造成了破坏性的修改,影响到了对原函数本身的理解
维护成本高:如果后期还有更多类似的函数需要添加统计耗时的代码,在每个函数中都添加这样的代码非常低效,也大大提高了维护成本
手动实现装饰器模式
核心思路
Step1 备份原来类构造器 (Class.prototype) 的属性描述符 (Descriptor)
利用 Object.getOwnPropertyDescriptor 获取
Step2 编写装饰器函数逻辑代码
利用执行原函数前后钩子,添加耗时统计逻辑
Step3 用装饰器函数覆盖原来属性描述符的 value
利用 Object.defineProperty 代理
Step4 手动执行装饰器函数,装饰 Class(类) 指定属性
从而实现在不修改原代码的前提下,执行额外逻辑代码
代码实现
function decoratorLogTime(target, key) {
// Step1 备份原来类原型对象上的属性描述符 Descriptor
const oldDescriptor = Object.getOwnPropertyDescriptor(target, key)
const oldFun = oldDescriptor.value
// Step2 编写装饰器函数逻辑代码
const logTime = function(...arg) {
// Before 钩子
const start = +new Date()
try {
oldFun.apply(this, arg) // 调用原函数
} finally {
// After 钩子
let end = +new Date()
console.log(`耗时: ${end - start}ms`)
}
}
// Step3 将装饰器覆盖原来的属性描述符的 value
Object.defineProperty(target, key, {
...oldDescriptor,
value: logTime,
})
}
class Demo {
run() {
console.log('start')
}
}
// Step4 手动执行装饰器函数,装饰 Demo 的 run 函数
decoratorLogTime(Demo.prototype, 'run')
手动实现的装饰器需要优化
是否可以让装饰器更加关注业务逻辑?Step 1 和 Step3 是通用逻辑,每个装饰器都需要实现,可以抽离复用
是否可以让装饰器写法更简单?纯函数实现的装饰器,每装饰一个属性都要手动执行装饰器函数,详见 Step4 步骤。
针对上述优化点,装饰器草案中提出了装饰器语法糖,也就是 @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 Demo {
// Step4 利用 @ 语法糖装饰指定属性
@logTime
run() {
console.log('start')
}
}
装饰器的执行顺序
同种装饰器组合顺序:洋葱模型
如果同一个方法有多个装饰器,其执行顺序是怎样的?
答案:以方法装饰器为例,同种装饰器组合后,其顺序会像剥洋葱一样,先从外到内进入,然后由内向外执行。和 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
不同类型装饰器顺序
参数装饰器先执行,按照其被应用到的方法参数从最后一个到第一个依次执行。
方法装饰器和属性装饰器接着执行,按照它们声明的顺序从上到下执行。
类装饰器最后执行,如果有多个类装饰器,它们的执行顺序是从下到上(即与声明顺序相反)。
值得注意的是,装饰器工厂函数的调用顺序和装饰器的执行顺序是不同的。工厂函数是在代码解析阶段就被调用的,而装饰器本身的执行是在所有工厂函数调用之后,按照上述规则进行的。
装饰器的优缺点
优点
在不修改原有代码情况下,对功能进行扩展。也就是对扩展开放,对修改关闭。
优雅地把“辅助性功能逻辑”从“业务逻辑”中分离,解耦出来。(AOP 面向切面编程的设计理念)
装饰类和被装饰类可以独立发展,不会相互耦合
装饰模式是 Class 继承的一个替代模式,可以理解成组合
缺点
但是糖再好吃,也不要吃太多,容易坏牙齿的,滥用过多装饰器会导致很多问题:
理解成本:过多带业务功能的装饰器会使代码本身逻辑变得扑朔迷离
调试成本:装饰器层次增多,会增加调试成本,很难追溯到一个 Bug 是在哪一层包装导致的
注意事项
装饰器的功能逻辑代码一定是辅助性的
比如日志记录,性能统计等,这样才符合 AOP 面向切面编程的思想,如果把过多的业务逻辑写在了装饰器上,效果会适得其反。
装饰器语法尚未定案以及未被纳入 ES 标准,标准化的过程还需要很长时间
阅读
Last updated
Was this helpful?