TypeScript 中的装饰器介绍
对于 ts 中装饰器的简单介绍

装饰器简介

ECMAScript 标准中引入了装饰器功能,用于在特定的场景下,对类及其成员进行修改,实现一些元编程的需求。虽然目前装饰器的提案还处于 Stage2 的阶段,但是 TypeScript 中已经作为实验性功能提供出来。

通过使用装饰器可以在不修改方法本身的情况下,对于相关方法定义的功能进行修改或者增强扩展(修改替换场景相对比较少见),并且这种扩展本身是可以被复用的。引用知乎上逗逼段子手的一段描述,可以提供更加浅显的理解:

  1. 你一个男的程序员,穿上女装,戴上假发,你就有了女人的外表(穿女装、戴假发的过程就是新的特效,你拥有 了女人的外表,你原来的小jj还在,没有消失)
  2. 你新买的毛坯房,装修,买家具后变好看了(装修、家具就是新的特效)
  3. 孙悟空被放进炼丹炉装饰了一下,出来后,学会了火眼金睛,以前的本领都还在

如需在 TypeScript 中使用装饰器功能,需要首先在 tsconfig 中开启装饰器的实验性配置项 experimentalDecorators。

装饰器的行为与使用

下面以最为常见的类方法装饰器为例,来介绍下 TypeScript 中装饰器在使用上的行为。

由于装饰器本身需要配合类声明使用,首先定义一个用于测试的最简单的类与类方法:

class Test {
    public bar() {
        console.log('bar');
    }
}

随后我们就可以对装饰器进行各种尝试了。

装饰器函数

首先,在 Typescript 中,只要是满足装饰器签名形态的函数都可以作为一个装饰器使用。例如下面这个最简单的装饰器。

function foo (target: any, propertyKey: string) {
    console.log('foo');
};

其中 target 为被装饰器声明的类本身,propertyKey 为类方法的属性名称。

然后可以直接在我们定义的类上调用执行。

class Test {
    public bar() {
        console.log('bar');
    }
}

new Test().bar();

输出结果为:

foo
bar

需要注意这边有一个陷阱,TypeScript 中目前定义的装饰器本身只是一种特殊的声明行为,仅在类声明的过程中会被触发一次。例如可以将上面的调用实现成如下的两次重复操作:

new Test().bar();
new Test().bar();

则其返回结果为:

foo
bar
bar

如果需要覆盖被修饰方法的执行行为,则需要在装饰器的声明过程,对被修饰方法的执行方法进行包装处理。例如:

function foo (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('foo');
    const func = descriptor.value;
    descriptor.value = (...args: any[]) => {
        console.log('before foo run');
        func.apply(target, args);
        console.log('after foo run');
    };
};

通过覆盖目标声明中 descriptor 对应的属性,可以对被修饰方法的执行过程进行包装。这样每次在被修饰方法执行时,也会调用装饰器中定义的执行行为。

同样执行先前的两次重复调用过程,可以得到如下的结果。

foo
before foo run
bar
after foo run
before foo run
bar
after foo run

可以看到装饰器中定义的执行过程方法,在每次调用过程都会被调用执行一次。这种实现方式其实比较符合 AOP 的切面理念。

装饰器工厂方法

在装饰器的使用过程中,往往静态声明的装饰器行为本身并不能满足对于不同对象在不同场景下的业务需求。比如对于不同 http 请求可能会有不同的权限拦截等等场景,都最好需要通过参数配置的方式来定义装饰器,来避免重复定义装饰器的开发代价。所以就需要声明装饰器的工厂方法,用于动态生成进行属性装饰的装饰器本身。

例如将前面的装饰器方法包装到一个简单的函数中并返回,即实现了一个最简单的无参数装饰器工厂方法。

function foo() {
    console.log('before foo');
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log('foo');
        const func = descriptor.value;
        descriptor.value = (...args: any[]) => {
            console.log('before foo run');
            func.apply(target, args);
            console.log('after foo run');
        };
    };
}

实现了工厂方法以后,在对应的类属性声明过程中,通过调用装饰器的工厂方法来生成装饰器使用。

class Test {
    @foo()
    public bar() {
        console.log('bar');
    }
}

new Test().bar();

可以得到如下的执行结果:

before foo
foo
before foo run
bar
after foo run

装饰器组合调用

在装饰器的使用过程中,可以使用多个装饰器进行组合调用。多个装饰器函数组合的过程与复合函数求职过程一致。例如,当组合装饰器 f 和 g 时,组合的的结果 (f ∘ g)(x) 等同于 f(g(x))。

定义如下的装饰器:

function f() {
    console.log('before f');
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log('f');
    };
}

function g() {
    console.log('before g');
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log('g');
    };
}

然后在类方法上声明使用:

class Test {
    @f()
    @g()
    public bar() {
        console.log('bar');
    }
}

new Test().bar();

可以得到下面的执行结果:

before f
before g
g
f
bar

是不是有种符合洋葱圈模型的既视感?当然洋葱圈模型在处理声明式逻辑的行为上作用并不是特别大,但是对于业务上切片的处理上,是可以带来帮助的。通过合理的使用装饰器,确实也可以实现对于类方法的洋葱圈模型包装。

类装饰器

TypeScript 中还有一类比较特殊的装饰器是类装饰器,类装饰器可以对类定义的


interface Constructor {
    new(...args: any[]): any
}

function foo<T extends Constructor>(constructor: T) {
    console.log('foo');
}

@foo
class Test {
    constructor() {
        console.log('bar');
    }
}

new Test().bar();

在调用过程可以得到如下的结果:

foo
bar

与类方法装饰器一样,在被多次调用时,类装饰器中的行为只会被声明过程调用一次。

在类装饰器使用过程中,虽然只能定义类声明过程行为,但是可以利用类被的重写覆盖(override)特性来对类本身进行修改。例如:

function foo<T extends {new(...args:any[]):{}}>(constructor:T) {
    return class extends constructor {
        public bar() {
            console.log('foo');
        }
    }
}

@foo
class Test {
    public bar() {
        console.log('bar');
    }
}

new Test().bar();

返回结果为:

foo

可以实先类本身的行为覆盖,或者补充相关的私有处理行为,实现类似于 mixin 这样的插件式功能。

需要注意的是,如果使用了类装饰器的 override 特性,初始化后的对象实例已经不再是原始定义的 class 实例,而是装饰器声明扩展的 class 实例,原始的 class 将不再存在,因为本身装饰器是一个声明式定义的行为。

属性装饰器/访问器装饰器

属性装饰器相当于是类方法装饰器的简化,使用时只能拿到两个参数,一个是对象目标,另一个是属性的名称。于方法装饰器的区别在于,属性装饰目前器不能用于修改对象的原始属性,其目前的主要作用仅能用来监视对象中的属性声明,使用价值比较有限。

function foo(target: any, propertyKey: string) {
    console.log(propertyKey);
}

class Test {
    @foo
    private bar: string;
}

new Test();

输出结果为:

bar

访问器装饰器则与类方法装饰器更为类似,配合了 ES 中提供的访问器(set/get)特性,可以对访问器实现扩展。访问器装饰器使用方式与类方法装饰器类似,区别在于对于 descriptor 的使用上会操作到对应的访问器方法,这里就不再详细举例了。

需要注意,装饰器访问器同样存在使用陷阱,在 TypeScript 中不允许用不同的装饰器装饰同一个成员的 set 与 get 访问器,而是要在同一个装饰其中同时实现 set 与 get 的装饰行为,因为对于同一个成员的声明过程只会被调用一次。

参数装饰器

在 TypeScript 中,除了对类本身和类上面定义的属性进行装饰声明以外,还可以对类方法的属性定义装饰器。

function foo(target: Object, propertyKey: string , parameterIndex: number) {
    console.log('foo');
}

class Test {
    public bar(@foo arg: string) {
        console.log(arg)
    }
}

new Test().bar('bar1');
new Test().bar('bar2');

同样是声明过程的执行效果。

foo
bar1
bar2

对于参数装饰器,单单使用装饰器本身的行为,能在函数声明过程进行的操作比较有限,更强大的功能需要配合元数据反射的机制来进行扩展。

元数据反射

虽然元数据反射 API 同装饰器本身一样都是不是 ES 标准的一部分,而只是开发阶段方案。但是 TypeScript 团队成员已经开始着手实现实验性的元数据 API,TypeScript 编译器已经可以将类型元数据传递给装饰器试用。

通过使用 TypeScript 团队在 npm 上发布的 reflect-metadata 库,配合装饰器可以用来实现元数据 API 操作。同时,使用这个功能时,需要开启 tsconfig 中实验性的 emitDecoratorMetadata 的配置。

目前在 TypeScript 中提供了三个可以直接使用的元数据:

  • 类型元数据使用元数据键 “design:type”
  • 参数类型元数据使用元数据键 “design:paramtypes”
  • 返回值类型元数据使用元数据键 “design:returntype”

例如:

import 'reflect-metadata';

function type<T>(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<T>) {
    const set = descriptor.set;
    descriptor.set = (value: T) => {
        const type = Reflect.getMetadata('design:type', target, propertyKey);
        console.log(type);
        set(value);
    }
}

class Test {
    @type
    public set bar(bar: string) {
        console.log(bar);
    }
}

new Test().bar = 'bar';

调用范围结果为:

[Function: String]
bar

可以实现在装饰器中,通过 Reflect 提供的 metadata API 获取到被装饰方法的相关属性。

当然在实际情况下,可以根据业务需求,定义更多私有的元数据属性供业务使用。可以通过元数据 API 实现在对象上注册绑定,并装饰器之间共享使用需要传递的元数据。

装饰器的执行顺序

在装饰器的声明过程中,会按照如下的规则顺序执行:

  1. 实例成员 a. 参数装饰器 b. 按照的成员申明顺序,执行方法装饰器,访问器装饰器,属性装饰器
  2. 静态成员 a. 参数装饰器 b. 按照的成员申明顺序,执行方法装饰器,访问器装饰器,属性装饰器
  3. 构造函数参数装饰器
  4. 类装饰器

装饰器的局限性

目前 TypeScript 的装饰器,最大的局限在于目前的实现本身并不是 ES 提案中的装饰器规则,而且提案以及其转译实现在过去的几个版本中变化较大,对于后向兼容存在比较大的不确定性。目前 TypeScript 实现的装饰器可能更像是微软和 Google 当年在 Angular 的 py 交易之后,实现的更加接近于当年 AtScript 的注解功能。

另外 ES 的装饰器目前是基于类声明使用的,而不是像 python 那种更加灵活的函数装饰器,可以更灵活的应用在更多的场景上。


参考文章:

  1. https://www.typescriptlang.org/docs/handbook/decorators.html
  2. https://juejin.im/post/5b41f76be51d4518f140f9e4

最后修改于 2019-05-16