TS装饰器和reflect-metadata

目前 decorator 草案仍然处于 stage2 阶段,将来 API 可能会有破坏性改动,所以本文内容以目前的 ts 中的 decorator 定义为主。

装饰器的功能

装饰器是一个函数,可使用类声明,方法, 访问符,属性或参数上,被装饰的对象会作为参数传入。在装饰器中可以对目标(target)进行一些操作。

假设有个User类,里面有个eat方法代表吃饭。假设有一天需要日志记录吃饭情况,于是在方法中里面加入日志代码。

1
2
3
4
5
6
7
8
9
10
11
class User {
eat(food: string) {
console.log('User eat start');
// ...
console.log('User eat end');
}

sleep() {
// ...
}
}

后来又需要记录睡觉(sleep)的情况,则在 sleep 方法写入相似的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
class User {
eat(food: string) {
console.log('User eat start');
// ...
console.log('User eat end');
}

sleep() {
console.log('User sleep start');
// ...
console.log('User sleep end');
}
}

对于User来说,他只是会执行吃饭和睡觉的动作,而日志记录并不是他自身关注点,这样违反了单一职责的原则,同时写大量的 log 代码无疑是非常冗余的。

通过装饰器,我们可以将日志记录进行统一封装,然后“装饰”在需要记录的方法上面,方法内部不需要任何改动就可以实现日志记录的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// logger是一个装饰器工厂
const logger = (type: string) => (
target: Object,
propertyKey: string,
descriptor: TypedPropertyDescriptor<any>
) => {
const value = descriptor.value;
descriptor.value = function() {
console.log(
`${type} -> start ${propertyKey} with:`,
Array.prototype.slice.apply(arguments)
);
const result = value.apply(target, arguments);
console.log(`${type} -> end ${propertyKey} return:`, result);
};
};

class User {
@logger('User')
eat(food: string) {
// ...
return food === 'fish' ? 'bad' : 'good';
}

@logger('User')
sleep() {
// ...
}
}

const user = new User();

user.eat('fish');
user.eat('meat');
user.sleep();

由于装饰器是声明式的写法,不会对原来代码进行侵入式的修改。如果要移除此功能只需要删除装饰器即可,是不是很方便?

装饰器组合

装饰器可以多个组合使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const f = () => {
console.log('f()');
return () => {
console.log('fd()');
};
};

const g = () => {
console.log('g()');
return () => {
console.log('gd()');
};
};

@f()
@g()
class Foo {}

输出如下

1
2
3
4
f()
g()
gd()
fd()

这个执行顺序是不是有点眼熟? 如果你用过函数组合就能看出和 compose(f(),g())(Foo) 是相同的执行顺序。

此外,针对在类的不同位置声明的装饰器,执行顺序有如下规则。

  1. 参数装饰器,然后依次是方法装饰器,访问符装饰器,或属性装饰器应用到每个实例成员。
  2. 参数装饰器,然后依次是方法装饰器,访问符装饰器,或属性装饰器应用到每个静态成员。
  3. 参数装饰器应用到构造函数。
  4. 类装饰器应用到类。

装饰器类型

类装饰器

类装饰器表达式会在运行时当作函数被调用,类的构造函数作为其唯一的参数。如果类装饰器返回了一个新的值,而新的值会替换原有的构造函数。

从类装饰器的实现上看,传入一个类返回一个新的值,如果这个值也是一个类,是不是和 react 的高阶组件很像呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const wrapComp = Comp => {
return class extends React.Component {
render() {
return <Comp {...this.props} />;
}
};
};

class SomeComp extends React.Component {
render() {
return null;
}
}

export default wrapComp(SomeComp);

所以高阶组件本质上也是一个装饰器,可以用装饰器的方法进行使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
@connect(
mapState,
mapDispatch
)
@wrapComp
@withRouter
class SomeComp extends React.Component {
render() {
return null;
}
}

export default SomeComp;

等价于

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class SomeComp extends React.Component {
render() {
return null;
}
}

export default connect(
mapState,
mapDispatch
)(wrapComp(withRouter(SomeComp)));

// 或者

export default compose(
connect(
mapState,
mapDispatch
),
wrapComp,
withRouter
)(SomeComp);

方法装饰器

方法装饰器表达式会在运行时当作函数被调用,传入下列 3 个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。
  3. 成员的属性描述符。

上面的 logger 就是一个方法装饰器。

如果方法装饰器返回一个值,它会被用作方法的属性描述符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function enumerable(value: boolean) {
return function(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
descriptor.enumerable = value;
};
}

class Foo {
@enumerable(false)
bar() {
//
}
}

let index = 0;
for (let key in new Foo()) {
index++;
}

console.log(index); // 0

访问器装饰器

访问器装饰器和方法装饰器类似。需要注意的是不能同时给同一个属性的gettersetter设置装饰器。

属性装饰器

属装饰器表达式会在运行时当作函数被调用,传入下列 2 个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。

参数装饰器

参数装饰器表达式会在运行时当作函数被调用,传入下列 3 个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。
  3. 参数在函数参数列表中的索引。

元数据 Metadata

Metadata 目前还处于提案阶段,还不是 ecmascript 标准的一部分,但是在 typescript 中已经可以用来使用了。

定义

元数据(Metadata),又称中介数据、中继数据,为描述数据的数据(data about data),主要是描述数据属性(property)的信息,用来支持如指示存储位置、历史数据、资源查找、文件记录等功能。

既然是用来描述数据属性的信息,也就是说元数据用来修饰类本身的一些性质,和类实例没有直接关系。

我们看看元数据的语义上的存储位置:

  • 对于类 C 本身,存储在C.\[\[Metadata\]\].undefined属性中
  • 对于类 C 的静态成员,存储在C.\[\[Metadata\]\].属性中
  • 对于类 C 的实例成员,存储在C.proptype.\[\[Metadata\]\].属性中

事实上也是如此吗???

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import 'reflect-metadata';

const key = Symbol('key');

class C {
@Reflect.metadata(key, 'aaa')
method() {
//
}
}

Reflect.defineMetadata(key, 'bbb', C);
console.log(Reflect.getMetadata(key, C)); // bbb
console.log(C['[[Metadata]]']); // undefined

const c = new C();
console.log(Reflect.getMetadata(key, C, 'method')); // undefined
console.log(Reflect.getOwnMetadata(key, c, 'method')); // undefined,因为是存在原型上的。
console.log(Reflect.getMetadata(key, c, 'method')); // aaa

// 修改method的
Reflect.defineMetadata(key, 'eee', C.prototype, 'method');
console.log(Reflect.getMetadata(key, c, 'method')); // eee

// 如果修改
Reflect.defineMetadata(key, 'fff', c, 'method');
console.log(Reflect.getOwnMetadata(key, c, 'method')); // fff
// 对c自身已经声明了元数据,将不会去原型链上查找,也就是会覆盖原型链的值
console.log(Reflect.getMetadata(key, c, 'method')); // fff

奇怪的是,运行后C['[[Metadata]]']的值为 undefined。经过查询源码得知,ts的reflect-metadata的实现并没有将元数据存在类本身,而是存在一个全局的WeakMap对象上的,通过传入的target和property作为键去进行元数据的声明和查询的。

我理解错误吗??;

1
2
3
// [[Metadata]] internal slot
// https://rbuckton.github.io/reflect-metadata/#ordinary-object-internal-methods-and-internal-slots
var Metadata = new _WeakMap();

反射

如果使用原生 js,通过元数据反射可以实现动态(这里有区别于 ts 的静态检查)的参数校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import 'reflect-metadata';

// Design-time type annotations
function Type(type: any) {
return Reflect.metadata('design:type', type);
}
function ParamTypes(...types) {
return Reflect.metadata('design:paramtypes', types);
}
function ReturnType(type) {
return Reflect.metadata('design:returntype', type);
}

// 验证类型
const validate: any = (target, propertyKey, descriptor) => {
const set = descriptor.set;
descriptor.set = function(value) {
let type = Reflect.getMetadata('design:type', target, propertyKey);
if (!(value instanceof type)) {
throw new TypeError('Invalid type.');
}
set(value);
};
};

class C {
@validate
@Type(String)
get name(): any {
return 'text';
}
}

const c = new C();

// @ts-ignore
// 这里只是做演示,实际ts会报错的
c.name = 10; // throw TypeError('Invalid type.')

通过@Type装饰器在元数据中存储了name的类型,然后validate装饰器重写了 setter 方法,在内部取得name的类型并进行了校验。

在上面的例子中通过装饰器声明了类型,实际上 ts 本身在写代码的时候就需要声明类型,通过在tsconfig中将emitDecoratorMetadata设置为true,ts 在编译时会自动的对被装饰的目标生成类型的元数据。

包括以下几个值

  • design:type - 目标类型
  • design:paramtypes - 目标参数的类型
  • design:returntype - 目标的返回值

但是并不是所有类型的装饰器都会有上述的全部元数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import 'reflect-metadata';

const classDecorator = () => <T extends { new (...args: any[]): {} }>(
target: T
) => {
const properties = Reflect.getOwnMetadata('design:type', target);
const parameters = Reflect.getOwnMetadata('design:paramtypes', target);
const returntype = Reflect.getOwnMetadata('design:returntype', target);

console.log('\nclass metadata');

console.log(properties);
console.log(parameters);
console.log(returntype);

return class extends target {
job: string = 'it';
};
};

const funcDecorator = () => (
target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor<any>
) => {
const properties = Reflect.getOwnMetadata('design:type', target, propertyKey);
const parameters = Reflect.getOwnMetadata(
'design:paramtypes',
target,
propertyKey
);
const returntype = Reflect.getOwnMetadata(
'design:returntype',
target,
propertyKey
);

console.log('\nfunc metadata');

console.log(properties);
console.log(parameters);
console.log(returntype);
};

const propertyDecorator = () => (target: any, propertyKey: string) => {
const properties = Reflect.getOwnMetadata('design:type', target, propertyKey);
const parameters = Reflect.getOwnMetadata(
'design:paramtypes',
target,
propertyKey
);
const returntype = Reflect.getOwnMetadata(
'design:returntype',
target,
propertyKey
);

console.log('\nproperty metadata');

console.log(properties);
console.log(parameters);
console.log(returntype);
};

const parameterDecorator = () => (
target: Object,
propertyKey: string,
paramIndex: number
) => {
const properties = Reflect.getOwnMetadata('design:type', target, propertyKey);
const parameters = Reflect.getOwnMetadata(
'design:paramtypes',
target,
propertyKey
);
const returntype = Reflect.getOwnMetadata(
'design:returntype',
target,
propertyKey
);

console.log('\nparameter metadata');

console.log(properties);
console.log(parameters[paramIndex]);
console.log(returntype);
};

class Props {
name: string;
}

@classDecorator()
class People {
constructor(name: string) {
this.name = name;
}

@funcDecorator()
getName(@parameterDecorator() props: Props): string {
return props.name + this.name;
}

@propertyDecorator()
name: string;
}

运行的结果为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
parameter metadata
[Function: Function]
[Function: Props]
[Function: String]

func metadata
[Function: Function]
[ [Function: Props] ]
[Function: String]

property metadata
[Function: String]
undefined
undefined

class metadata
undefined
[ [Function: String] ] // 构造函数的参数
undefined

根据输出的结果,可以得到如下元数据的情况:

目标 design:type design:paramtypes design:returntype
方法
属性
参数

从编译后的代码中,可以看到是元数据是如何生成的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var People = /** @class */ (function() {
function People(name) {
this.name = name;
}
People.prototype.getName = function(props) {
return props.name + this.name;
};
__decorate(
[
funcDecorator(),
__param(0, parameterDecorator()),
__metadata('design:type', Function),
__metadata('design:paramtypes', [Props]),
__metadata('design:returntype', String),
],
People.prototype,
'getName',
null
);
__decorate(
[propertyDecorator(), __metadata('design:type', String)],
People.prototype,
'name',
void 0
);
People = __decorate(
[classDecorator(), __metadata('design:paramtypes', [String])],
People
);
return People;
})();

作用

从官方的文档中能够看出元数据的使用场景

A number of use cases (Composition/Dependency Injection, Runtime Type Assertions, Reflection/Mirroring, Testing) want the ability to add additional metadata to a class in a consistent manner.

这里有一篇好友经纬的关于依赖注入的文章-基于 TypeScript 的 IoC 和 DI,值得一看。

本文就先写到这里,有什么不对的地方请指正。

参考文档