装饰器模式简介
装饰器模式是一种重要的设计模式,它能够以对客户透明的方式动态地给一个对象附加上更多的责任,为明晰它的概念,请看下面的例子:
在这个例子中,对于使用床的人而言,“睡觉”这个动作没有发生改变,床还是那张床,只不过我们通过床垫、被絮、按摩机增加了额外的功能。在程序设计中,它们就可以分别以装饰器的形式进行设计,再通过组合使床拥有全部的装饰特征,程序中类之间的关系使用uml类图表示如下:
前端领域同样存在装饰器,但是JavaScript的装饰器提案历经一波三折,目前仍处于Stage 2阶段,而且在语法和实现上经历了较大的改版,距离正式成为ECMA语言标准尚需时日。在TypeScript愈发流行的今天,它已推出了这个实验性功能,一些框架如angular、nestjs都已经大量使用了装饰器,下面我们一起探索一下它。
起步
新建一个node项目,并使用tsc工具生成tsconfig.json配置文件,笔者使用的tsc版本为4.0.3
。
mkdir decorator-tour && cd decorator-tour && npm init -y && tsc --init
为了使Typescript编译器支持装饰器,需要在tsconfig.json的compilerOptions选项中设置"experimentalDecorators": true
。
// tsconfig.json
{
"compilerOptions": {
...
"experimentalDecorators": true,
...
}
}
新建index.ts文件,并给出最初的代码
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet(name: string): string {
console.log(`welcome, ${name}!`);
return "Hello";
}
}
const g = new Greeter('msg');
g.greet('tom');
执行tsc && node index.js
后控制台将打印出welcome, tom!
。
核心玩法
装饰方法
Greeter实例的greet方法表示“欢迎”之意,但是一般的“欢迎”应该配合上“微笑”,这里我们希望在调用greet之前再打印一行"smile"文本,那怎么操作呢?当然可以直接在greet方法里加入打印"smile"的代码,但这并不好,试想在Greeter类中可能将要实现其他的方法,比如guide、interpret等,它们都需要先打印"smile"然后执行功能代码,这种情况下Greeter类的很多方法都有相同的需求,就可以将打印"smile"这个功能提取成一个装饰器。
function smile(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
console.log('smile');
}
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
@smile
greet(name: string): string {
console.log(`welcome, ${name}!`);
return "Hello";
}
}
const g = new Greeter('msg');
g.greet('tom');
在上面的代码中,smile是一个装饰器,@smile
语法规定了greet方法将使用smile装饰器,而装饰器smile本身其实是一个函数,它接收target(被装饰的对象),propertyKey(被装饰的属性)和descriptor(属性的描述)作为参数,本例中target表示Greeter的原型对象,即Greeter.prototype,propertyKey是"greet",而descriptor是Greeter.prototype.greet的属性描述对象,类似下面这样:
{
value: [Function],
writable: true,
enumerable: true,
configurable: true
}
smile方法本身没有返回任何值,只是执行了简单的打印"smile"的功能,在执行过程中是怎么把装饰器和原函数串联起来的呢?我们不妨分析一下typescript经过tsc编译后的代码:
'use strict'
var __decorate =
(this && this.__decorate) ||
function (decorators, target, key, desc) {
var c = arguments.length, // 本例中为4
r = // 本例中为greet方法的属性描述对象
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]))
// 这一步执行了smile函数,然后由于smile函数返回undefined,故将原本的r赋值给r
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r
// 返回r
return c > 3 && r && Object.defineProperty(target, key, r), r
}
function smile(target, propertyKey, descriptor) {
console.log('smile')
}
var Greeter = (function () {
function Greeter(message) {
this.greeting = message
}
Greeter.prototype.greet = function (name) {
console.log('welcome, ' + name + '!')
return 'Hello'
}
// @smile装饰器被解析成这一行
__decorate([smile], Greeter.prototype, 'greet', null)
return Greeter
})()
var g = new Greeter('msg')
g.greet('tom')
在上面的代码中,我们发现@smile装饰器被解析成了__decorate([smile], Greeter.prototype, 'greet', null)
,__decorate方法先获取了greet方法的属性描述对象r,然后执行smile装饰器函数,最后又通过Object.defineProperty对greet方法进行了重定义,但是此处进行重定义时属性描述对象r并未发生变化,所以greet方法没有变化;如果smile函数返回一个属性描述对象,则原greet方法是可以被覆盖的,比如如下定义smile装饰器,则最终greet方法被覆盖,运行tsc && node index.js
将得到hijacked method
。
function smile(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
return {
value: function(name: string): string {
console.log('hijacked method');
return '';
},
writable: true,
enumerable: true,
configurable: true
}
}
上面我们用到的装饰器语法是@smile
的形式,实际上装饰器也是可以带参数的,比如现在需求发生了变化,需要根据不同场景确定"smile"的打印次数,greet方法执行的时候需要打印3次smile,打印的次数可以作为参数传递给smile装饰器灵活控制,如何实现呢?熟悉闭包的同学肯定已经想到了。
function smile(times: number) {
return function(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
for (let i = 0; i < times; i++) {
console.log('smile');
}
}
}
经过上面的改造,smile装饰器需接收1个参数,通过@smile(3)
的形式使用装饰器即可在调用greet方法时打印3次"smile"。
装饰参数
不光方法可以被装饰,方法的参数同样也可以,写法如下:
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
@smile(3)
@checkParam
greet(@startsWith('t') name: string): string {
console.log(`welcome, ${name}!`);
return "Hello";
}
}
例子中的startsWith
是一个参数装饰器,它限制greet方法的name参数必须以字符't'开头,需要checkParam
装饰器配合工作,接下来先看下startsWith
装饰器的实现:
const startsWithKey = '__startswith';
function startsWith(prefix: string) {
return function(target: any, // Greeter.prototype
propertyKey: string, // 'greet'
paramsIndex: number // 参数的序号,本例中name参数序号为0
) {
const startsWithConstraints = Reflect.getOwnMetadata(
startsWithKey,
target,
propertyKey
) || {} as Record<number,string>;
startsWithConstraints[paramsIndex] = prefix;
Reflect.defineMetadata(
startsWithKey,
startsWithConstraints,
target,
propertyKey
);
}
}
上面的代码用到了Reflect-metadata,它是一个ECMA提案,可以在对象和对象的属性上定义元数据,用法类似下面这样:
const o = { a: 1, b: 2 };
Reflect.defineMetadata("meta_key", "meta_value", o, 'a'); // 定义元数据
Reflect.getOwnMetadata("meta_key", o, 'a'); // "meta_value" 获得元数据
目前需要借助reflect-metadata
库进行polyfill。
// 命令行安装
// npm install reflect-metadata -S
// index.ts 引入该库
import "reflect-metadata";
回过头来看startsWith的实现,它返回的装饰器函数有三个参数,分别是target(被装饰的对象),propertyKey(被装饰的属性)和paramsIndex(参数的次序),本例中target表示Greeter的原型对象,即Greeter.prototype,propertyKey是"greet",paramsIndex是name参数的次序0。该函数设置了greet方法的metadata,key为一个常数startsWithKey
, value是一个[[参数次序:开头字符]]
的映射。到这里我们发现startsWith装饰器只是收集了映射,但是并未进行校验,这是由于参数装饰器并不能得到运行时调用方法的实参,校验操作需要在一个额外的方法装饰器checkParam
中进行。
function checkParam(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const method = descriptor.value;
// 对greet方法进行改写,先进行参数校验
descriptor.value = function() {
// 获得startsWith装饰器收集到的[[参数次序:开头字符]]映射
const startsWithConstraints = Reflect.getOwnMetadata(
startsWithKey,
target,
propertyKey
) || {};
// 对定义了startsWith装饰器的参数进行校验
Array.prototype.slice.call(arguments).forEach((arg, index) => {
const prefix = startsWithConstraints[index];
if (prefix && !arg.startsWith(prefix)) {
throw new Error(`argument ${index} must start with ${prefix}`);
}
})
return method.apply(this, arguments);
};
}
checkParam装饰器先通过descriptor.value
得到Greeter.prototype.greet方法,然后对这个方法进行改写,增加了参数校验。校验之前从greet方法的metadata中取出了startsWith装饰器收集到的[[参数次序:开头字符]]映射,然后分别对定义了startWith装饰器的参数进行校验。
下面测试一下校验失败的场景:
const g = new Greeter('msg');
g.greet('rtom');
运行程序后命令行会得到一个报错:
throw new Error("argument " + index + " must start with " + prefix);
^
Error: argument 0 must start with t
...
装饰类的构造器
类似方法装饰器,类的构造器也可以被装饰,写法是直接在class关键字上方添加装饰器代码,装饰器的实现比较简单,只有一个参数target,指代的是构造器方法本身。
function activate(target: any) {
target.active = true; // target指代Greeter函数
}
@activate
class Greeter {
static active = false;
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet(name: string): string {
console.log(`welcome, ${name}!`);
return "Hello";
}
}
console.log(Greeter.active); // true
在上方的例子中,activate装饰器的target参数指代Greeter构造器,该装饰器修改了Greeter类的static属性active的值。值得注意的是我们并未实例化这个类,但装饰器代码已经发挥了作用。
应用
至此我们对Typescript的装饰器有了一定的了解,通过使用装饰器可以定制类构造器、方法,甚至方法参数的行为。下面我们举两个例子来实际看一下。
依赖注入
首先聊聊什么是依赖注入,vue中就有这个概念,provide/inject是解决组件之间的通信问题的利器,不受层级结构的限制。其核心思想是外层组件通过provide选项声明可同享的属性,内层组件通过inject选项指定待注入的属性,这样外层组件的属性值就可以同步到内层组件了。我们大致可以这样理解依赖注入的步骤:首先收集需要共享的数据,然后标记需要使用这些数据的对象,最后从共享数据中挑选出该对象需要的数据交给它。
接下来我们模仿nest.js的做法实现一个简单的依赖注入。
class FlowerService {
strew () {
console.log('strew flower');
}
}
class Greeter {
constructor(
private readonly flower: FlowerService
) {
}
greet(name: string): string {
console.log(`welcome, ${name}!`);
this.flower.strew();
return "Hello";
}
}
// 期待的操作是 const g = create(Greeter);
const g = new Greeter(new FlowerService());
g.greet('tom');
这是一段给"欢迎"方法添加"撒花"动作的代码,"撒花"作为一个类FlowerService独立出来。现在创建Greeter对象的时候是主动将FlowerService实例化并传入,我们期待的是将FlowerService作为依赖,通过某种方式注入到Greeter类中,然后通过const g = create(Greeter)
的工厂函数创建Greeter的实例,下面是具体实现步骤:
首先实现provide,这里直接对FlowerService类进行装饰,将它的构造器放到一个weakmap中存起来备用。
const providerMap = new WeakMap();
// ----- Provider -----
function provider(target: any) {
providerMap.set(target, null);
}
@provider
class FlowerService {
strew () {
console.log('strew flower');
}
}
接下来是一个关键的问题,程序怎么知道Greeter类需要FlowerService这个依赖呢?我们发现Greeter构造函数的形参flower就是FlowerService的实例,那程序有办法拿到构造函数的入参类型吗?这需要在tsconfig.json的compilerOptions配置中开启一个额外的选项:
// tsconfig.json
{
"compilerOptions": {
...
"emitDecoratorMetadata": true,
...
}
}
然后我们给Greeter函数添加一个装饰器inject,这个装饰器可以什么也不做。
function inject(target: any) {
// do nothing
}
@inject
class Greeter {
constructor(
private readonly flower: FlowerService
) {
}
greet(name: string): string {
...
}
}
下面是tsc编译得到的js代码,Greeter被添加了两个装饰器,一个是我们自己定义的inject,另外一个调用了__metadata
函数,该函数通过Reflect.metadata返回一个装饰器,该装饰器设置了Greeter的metadata,操作类似:Reflect.defineMetadata("design:paramtypes", [FlowerService], Greeter); // 定义元数据
,我们发现Greeter构造器的参数类型就以metadata的形式被保存到"design:paramtypes"这个key中了。
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
...
function inject(target) {
}
var Greeter = (function () {
function Greeter(flower) {
this.flower = flower;
}
Greeter.prototype.greet = function (name) {
...
};
Greeter = __decorate([
inject,
// __metadata函数返回一个装饰器,该装饰器设置了Greeter的metadata
__metadata("design:paramtypes", [FlowerService])
], Greeter);
return Greeter;
}());
最后一步是创建对象,我们定义一个函数create(target),首先通过Reflect.getOwnMetadata("design:paramtypes", target)
获取目标构造器target的入参类型,然后在providerMap中查找是否存在该入参类型,如果有则说明该类型是一个provider,将该类型实例化后传给目标构造器target,最后创建target实例并返回。值得注意的是,provider可能自身也依赖其他provider,故需要处理一下依赖的递归收集。
function create(target: any) {
// 获取函数的入参类型
const paramTypes = Reflect.getOwnMetadata(
"design:paramtypes",
target
) || [];
const deps = paramTypes.map((type: any) => {
const instance = providerMap.get(type);
if (instance === null) {
// 递归收集依赖
providerMap.set(type, create(type));
}
return providerMap.get(type);
})
return new target(...deps);
}
这样简单的依赖注入就实现了,下面是完整代码。
import "reflect-metadata";
const providerMap = new WeakMap();
// ----- Provider -----
function provider(target: any) {
providerMap.set(target, null);
}
@provider
class FlowerService {
strew () {
console.log('strew flower');
}
}
// ----- Inject -----
function create(target: any) {
// 获取函数的入参类型
const paramTypes = Reflect.getOwnMetadata(
"design:paramtypes",
target
) || [];
const deps = paramTypes.map((type: any) => {
const instance = providerMap.get(type);
if (instance === null) {
// 递归收集依赖
providerMap.set(type, create(type));
}
return providerMap.get(type);
})
return new target(...deps);
}
// 必须要inject一下,ts解析出构造器的入参类型
function inject(target: any) {
}
@inject
class Greeter {
constructor(
private readonly flower: FlowerService
) {
}
greet(name: string): string {
console.log(`welcome, ${name}!`);
this.flower.strew();
return "Hello";
}
}
const g = create(Greeter);
g.greet('tom');
// 命令行输出
// welcome, tom!
// strew flower
约束类的静态方法
要让Foo类实现Bar接口,我们通常这样写class Foo implements Bar
,Bar接口里面约束了实例的属性和方法,如:
interface Bar {
work: () => void
}
class Foo implements Bar {
work() {
// do something
}
}
但约束Foo的静态属性要怎么做呢?首先interface不支持添加static关键字,下面这种写法是不被允许的:
interface Bar {
static life: number;
work: () => void;
}
我们知道static属性其实最终是添加在构造函数上的,改成下面这种写法才可行:
interface Bar {
work: () => void
}
interface StaticBar {
life: number;
}
const Foo: StaticBar = class implements Bar {
static life: number;
work() {
// do something
}
}
但是这种方式改变了class声明的写法,感觉不是十分优雅,下面是使用装饰器的写法:
interface Bar {
work: () => void
}
type WithStatic<T, U> = {
new(): T;
} & U;
type BarWithStatic = WithStatic<Bar, { life: number }>;
// 通过装饰器重写了构造函数的类型
function staticImplements<T>() {
return <U extends T>(constructor: U) => {};
}
@staticImplements<BarWithStatic>()
class Foo {
static life: number;
work() {
// do something
}
}
这里的装饰器staticImplements
没有做任何逻辑上的操作,它只是声明了构造函数的类型,这样静态属性自然就具备了类型声明。
参考
- 官方文档
- 如何用 Decorator 装饰你的 Typescript?
- How to define static property in TypeScript interface
- A practical guide to TypeScript decorators
- decorator与依赖注入
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!