装饰器模式简介
装饰器模式是一种重要的设计模式,它能够以对客户透明的方式动态地给一个对象附加上更多的责任,为明晰它的概念,请看下面的例子:
在一个末日生存游戏里,你有一个安全屋和一张床,每次睡觉都可以恢复一定的精力,但是初始的床什么都没有,每天睡在木板上只能恢复20点精力,某天你出外搜寻了材料并制作了一个床垫,将床垫放在床上,每次睡觉可以额外恢复20点精力,之后你又找到了被絮和三件套,甚至还给床进行改装增加了按摩功能,每次睡觉可以额外恢复的精力越来越多了…
在这个例子中,对于使用床的人而言,“睡觉”这个动作没有发生改变,床还是那张床,只不过我们通过床垫、被絮、按摩机增加了额外的功能。在程序设计中,它们就可以分别以装饰器的形式进行设计,再通过组合使床拥有全部的装饰特征,程序中类之间的关系使用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');
这段代码声明了一个向顾客问好的类Greeter
,Greeter.greet
方法表示“欢迎”之意,实现上就是打印一段欢迎文本出来。
执行tsc && node index.js
后控制台将打印出welcome, tom!
。
装饰器核心玩法
装饰方法
“欢迎”应该配合上“微笑”,下面的需求是在调用greet之前先打印一行"smile"文本,怎么操作呢?当然可以直接在greet方法里加入打印"smile"的代码,但这并不好,试想未来可能实现Greeter.guide
,Greeter.interpret
等函数,它们都需要先打印"smile"然后执行功能,这种情况下Greeter类的很多方法都有相同的需求,就可以将打印"smile"这个功能提取成一个装饰器。
function smile(
target: Greeter,
propertyKey: string,
descriptor: PropertyDescriptor
) {
return {
...descriptor,
value: function(name: string) {
console.log('smile');
// 调用被装饰的方法
return descriptor.value(name);
}
};
}
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');
g.greet('tom');
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
}
那么在执行过程中是怎么把装饰器和原方法串联起来的呢?我们不妨分析一下经过tsc编译后的代码:
'use strict'
function smile(target, propertyKey, descriptor) {
return __assign(__assign({}, descriptor), { value: function (name) {
console.log('smile');
return descriptor.value(name);
} });
}
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
的实现。
var __decorate =
(this && this.__decorate) ||
function (decorators, target, key, desc) {
var c = arguments.length, // 本例中为4
r = // 本例中为greet方法的属性描述对象
c < 3
? target
: desc === null
// *** 逻辑走到这里,desc等于greet方法的属性描述对象
? (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函数返回一个新的description
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r
// 重新给 Greeter.prototype.greet 方法赋值
return c > 3 && r && Object.defineProperty(target, key, r), r
}
原理其实很清楚,__decorate
先获取了greet方法的属性描述对象r,然后执行smile装饰器函数,最后将smile返回的属性描述对象赋值给greet方法;我们注意到smile调用了被装饰的方法,那如果smile函数中不调用被装饰的方法会怎么样呢?比如如下定义smile装饰器,则最终greet方法被覆盖,运行tsc && node index.js
将得到hijacked method
。
function smile(
target: Greeter,
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: Greeter,
propertyKey: string,
descriptor: PropertyDescriptor
) {
return {
...descriptor,
value: function(name: string) {
for (let i = 0; i < times; i++) {
console.log('smile');
}
return descriptor.value(name);
}
}
}
}
经过上面的改造,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
装饰器配合工作。
在讲解装饰器代码之前,需要先了解一点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
装饰器的实现:
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
);
}
}
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: Greeter) {
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实例中,下面是具体实现步骤:
首先实现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
没有做任何逻辑上的操作,它只是声明了构造函数的类型,这样静态属性自然就具备了类型声明。