装饰模式
先简单介绍一下装饰模式:动态地给一个对象添加额外的职责,同时不改变其结构。是比继承更有弹性的替代方案。
《Design Patterns: Elements of Reusable Object-Oriented Software》#196
举个🌰,一个人,可以在冬天的时穿羽绒服,也可以在下雨天套上雨衣。所有这些外在的服装并没有改变人的本质,但是它们却拓展了人的基本抗性。——一起读透TS装饰器
优点
- 相比较于类的继承来扩展功能,对对象进行包裹更加的灵活。
- 装饰类和被装饰类相互独立,耦合度较低。
缺点
- 没有继承结构清晰。
- 层数较多时,难以理解和管理。
推荐文章
TS 中的装饰器1
以下所有内容均使用旧版本装饰器!!!
装饰器可以修改类的行为, 常用于元编程和代码复用。
装饰器语法
装饰器是一种特殊类型的声明,本质上是一个普通的函数,通过语法 @Decorator
加到类、方法、访问器、属性或参数上。
装饰器的基本语法如下:
|
|
装饰器函数的定义如下:
|
|
装饰器的类型
装饰器类型定义,详见 decorators.legacy.d.ts;
|
|
类装饰器
类装饰器应用于类构造函数,可以用来监视、修改或替换类定义。
参数:仅接受一个参数,即类的构造函数。
返回值:如果返回非空则替换原来的类。
如果返回了一个和被装饰类毫无关系的类怎么办!!乱棍打死💢~
如上述tips所言,TS无法为装饰器提供类型保护。
|
|
方法装饰器
方法装饰器应用于方法,可以用来监视、修改或替换方法定义。
参数
target
: 原型对象,修饰静态成员时则为构造函数。propertyKey
: 方法名。descriptor
: 方法的描述符。
返回值:如果返回了一个非空的值result
,则会调用Object.defineProperty(target, result)
。
|
|
访问器装饰器
访问器装饰器本质上就是方法装饰器,不同的地方在于第三个参数属性描述符上
。
同名访问器不允许使用相同的装饰器分别修饰,详情见 Accessor Decorators;
可以看到访问器装饰器的描述符中同时有 getter
和 setter
,如果都应用相同的装饰器,会出现装饰多次,显然是错误的——好比我穿了一件羽绒服,然后又穿了一件相同的羽绒服。
属性装饰器
属性装饰器应用于类的属性,可以用来修改属性的元数据。由于运行装饰器的时候,类还没有实例化,如果我们严格按照定义使用,属性装饰器只能收集信息!但是结合下面说的 返回值 bug(feature?) 还是可以实现很多好玩的功能。
参数
target
: 原型对象,修饰静态成员时则为构造函数。propertyKey
: 属性名。descriptor
: 属性描述符(由于实例没有初始化,没有办法获取到属性描述符,会得到undefined)。
虽然
TS
定义中不存在,但转译到JS
却有传参,不可以通过判断参数长度来区分属性装饰器和方法装饰器。
返回值:如果返回了一个非空的值result
,则会调用Object.defineProperty(target, result)
。
虽然
TS
定义中不使用返回值,但转译到JS
的时候返回值会和方法装饰器一样处理。
参数装饰器
参数装饰器应用于方法参数,可以用来修改参数的元数据。
不使用骚操作(使用方法名获取到方法,然后修改原型或构造函数上的方法)的话,大概就只能信息收集!
参数
target
: 原型对象,修饰静态方法参数时则为构造函数propertyKey
: 参数所在的方法名parameterIndex
: 该参数在入参中的索引
TS 装饰器详情
前置知识
Descriptor
每个对象都有一组不可见的属性,其中包含于该属性关联的元数据,称为“描述符”。Descriptor
包含以下属性:
属性 | 描述 | 默认值 |
---|---|---|
value | 与属性关联的值(仅限数据描述符)。 | undefined |
writable | 布尔值,属性值是否可以更改(仅限数据描述符)。 | false |
get | 与属性关联的 getter 函数,没有则为 undefined(仅限访问器描述符)。 | undefined |
set | 与属性关联的 setter 函数,没有则为 undefined(仅限访问器描述符)。 | undefined |
configurable | 布尔值,表示属性的描述符是否可以更改(writable 为 true 时,属性值可以被修改,且 writable 可以被修改为 false)或属性是否可以被删除。 | false |
enumerable | 布尔值,表示访问器是否可以被枚举。 | false |
[Web Dev] Property descriptors
原型链
属性装饰器、访问器装饰器、方法装饰器传入的 target
参数,在静态成员下,是类构造函数,普通则为类的原型。当使用装饰器对类作骚操作的时候就需要注意当前的修改对象是谁以及如何生效。
在 JavaScript 中,原型链(prototype chain)是对象属性继承的一种机制。每个 JavaScript 对象(除了 null 之外)都有一个与之关联的原型对象,当你试图访问对象的某个属性时,JavaScript 会首先检查该对象本身是否拥有这个属性。如果没有,它会顺着原型链往上查找,直到找到这个属性或到达 null(表示链的尽头)。
原型链的结构
- 每个对象都有一个特殊的属性 __proto__,指向它的原型对象(prototype)。注意 __proto__ 是实现中的一个内部属性,而 prototype 是函数对象特有的属性。
- 一个对象的原型对象又有它自己的原型对象,这样就形成了一条链,称为原型链。
@startuml
skinparam rectangle<<behavior>> {
roundCorner 25
}
rectangle MyClass构造函数 as MC
rectangle MyClass原型对象 as MP
rectangle MyClass实例 as MI
MC -right-> MP :prototype
MP -left-> MC :constructor
MI -up-> MP :__proto__
MC .down.> MI :new
rectangle Function构造函数 as FC
rectangle "f(){ [native code] }" as FP
FC -right-> FP :prototype
FC -right-> FP :__proto__
FP -left-> FC :constructor
FC .down.> MC :new
MC -up-> FP :__proto__
rectangle Object构造函数 as OC
rectangle Object原型对象 as OP
rectangle null
OC -right-> OP :prototype
OP -left-> OC :constructor
OP -up-> null :__proto__
OC .down.> MP :new
MP -up-> OP :__proto__
FP -up-> OP :__proto__
OC -down-> FP :__proto__
@enduml
逐步验证上图:详解 prototype 与 __proto__
JSObject以及JSFunction的关系可以参考(更新)从 Chrome 源码看 JS Object 的实现 中的插图.
好的,现在我们已经知道 1 + 1 = 2,接下来我们来解方程吧!
|
|
demo
在 ⚙ -> 配置 中打开Console, 查看执行结果。
代码分析
生成的关键代码(代码简化后);
|
|
执行顺序
装饰器的执行分为两个阶段。
(1)评估(evaluation):计算@符号后面的表达式的值,得到的应该是函数。
(2)应用(application):将评估装饰器后得到的函数,应用于所装饰对象。
这版本装饰器的评估和应用是先后一起发生的。
@startuml
title 装饰器执行顺序图
start
:类声明、命名,静态块;
floating note
同一个目标的多个装饰器顺序按照先定义
(从上到下、从左到右)后应用的顺序,
如果是装饰器工厂则先定义先生成。
end note
group 实例成员
note left
方法装饰器、访问器
装饰器和属性装饰器
为同优先级,谁先声
明谁先应用。
end note
split
:属性装饰器;
split again
:(set)参数装饰器;
:访问器装饰器;
split again
:参数装饰器;
:方法装饰器;
end split
end group
group 静态成员
note left
静态成员装饰器获取
到的 target 和实例
成员不一样。
end note
split
:属性装饰器;
split again
:(set)参数装饰器;
:访问器装饰器;
split again
:参数装饰器;
:方法装饰器;
end split
end group
:(constructor)参数装饰器;
:类装饰器;
end
@enduml
TypeScript Handbook 装饰器应用顺序。对于装饰器工厂,装饰器在对应装饰器被使用前生成(无间隔)。如果对具体源码感兴趣,可以查看对应内容:
- 装饰器编译 TypeScript源码v5.6.3, legacyDecorators;
- 函数及参数装饰器执行顺序源码
- 静态成员和实例成员执行顺序源码
如果启用了
emitDecoratorMetadata
,Metadata
应用时机在用户装饰器之前。所以用户装饰器可以安全的访问design:type
,design:paramtypes
,design:returntype
等信息,详情见 reflect-metadata2.
推荐文章
TS 装饰器应用
纸上得来终觉浅,绝知此事要躬行
功能增加(如日志、路由)
首先我们简单的创建一个 http 服务,同时声明好路由控制器。
|
|
分别实现方法装饰器、类装饰器。
AllMethod:作用是允许任意方法请求该二级路由,实际开发中可以用工厂创建,同时将请求方法和路由一起存入
metaData
。Controller:由于类装饰器最后运行,因此我们可以拿到方法上保存的
metaData
,并增加统一的路由前缀后注册到路由控制器上。
|
|
最后编写我们的用户控制器类,分别注册 GET /user/query/:id
以及 GET /user/exists/:name
俩个接口。
|
|
按照上述的代码即可编写简单一个简单的服务框架啦。也可以用上述方式配合 express等框架啦。
代码依赖 router、reflect-metadata。
VS Code
中的 git extension 也采用类似的方法装饰器实现注册多个command
。方法装饰器还可以实现如下功能:
- 返回值缓存
- 参数校验
- 权限控制
- …
DI(依赖注入)3
如何实现依赖注入?其实就是解决俩个主要问题,“依赖什么”以及“如何找到依赖”。不同于 Java
有内置的查询所有类的方法,在 TS
中我们需要自己实现一个全局的单例作为容器,并给依赖一个键,这样就解决了“如何找到依赖”的问题。“依赖什么”只需要在使用的时候指定前面说的键即可。
实现依赖注入需要解决很多细节上问题,例如循环依赖。本文只展示技术应用,不做完整的校验。
私推荐一下 VSCode 的依赖注入方式源码,以及别人写的解读博客 VSCode For Web 深入浅出 – 依赖注入设计、详解依赖注入的原理与实现。
|
|
- @inject:装饰器用于将类注册到容器中。
- @injected:装饰器用于从容器中获取依赖并注入到类的属性中。
推荐博文的结尾也有一个简单的依赖注入的实现,和上述实现在属性装饰器部分有区别。一个是注入对象立即绑定到原型上,所有实例共享一个依赖;一个是使用时绑定到实例上,每个实例一个不同的依赖。实际开发中一般俩个都可能是合理的场景!
|
|
我们首先定义了一个 IService
接口,它包含一个 write
方法。之所以抽象接口出来,是为了减少被注入类和服务类具体实现之间的耦合。
接下来,我们定义了一个 AService
类,它实现了 IService
接口。我们使用 @inject("IService")
装饰器将 AService
类注册为 IService
的实现。最后使用 @injected("IService")
装饰器将 IService
的实例注入到 service
属性中。
构造函数注入 vs 属性注入
从依赖注入的角度来看,主要有两种方式:构造函数注入和属性注入。构造函数注入通过构造函数参数装饰器实现依赖收集,然后在实例化服务时进行注入;而属性注入则是直接将依赖注入到类的属性中。
私更喜欢构造函数注入的方式,下面简单介绍一下它相对于属性注入的优缺点。
优点:
- 增加明确依赖 :阅读代码时,可以快速从构造函数中看到当前类完成职能依赖的其他对象,依赖关系更加集中、明确。
bad smell
4 明确 :当构造函数参数越来越多的时候,应该考虑当前类是否违反【单一职责】3!- 初始化流程更合理 :在当前类初始化之前需要初始化依赖对象,好比穿衣服之前需要先有衣服。
- 性能更佳 :参数注入不依赖原型进行,访问以及初始化速度更好。本篇所写属性注入对比参数注入 benchmark。
- 方便测试 :使用参数装饰器可以使得类测试可以脱离框架,模拟参数传入即可。
- 注入的后处理 :可以在构造函数内编写注入对象的后处理,例如轮胎传入之后检查轮胎的动平衡。可以看看 VSCode ExtensionService。
- 减少多构造函数 :在
TypeScript
中没有真正的多构造函数,同时不方便在构造函数注入的场景中使用多构造函数。当多个构造函数设计出现的时候,应该考虑当前类是否违反【单一职责】。 - 依赖固定 :如果时通过属性注入的方式,注入的对象可以被装饰器内的逻辑动态改变。参数装饰器则在创建的那一刻就固定(这个优缺点因人而异)。
缺点:
- 构造函数参数过多 :
bad smell
是优点也是缺点,实际需求中确实会出现构造函数过多且不方便拆分。参考 VSCode AbstractTaskService 代码,足有36个参数! - 继承和扩展困难 :继承需要增加维护依赖的逻辑。参考 VSCode TaskService,为了增加一行销毁逻辑,写了一百多行代码。
- 参数不可选 :同【减少多构造函数】一样,也不方便实现构造参数可选这一点。