装饰器(旧语法)

上一章介绍了装饰器的标准语法,那是在2022年通过成为标准的。但是在此之前,TypeScript 早在2014年就支持装饰器,不过使用的是旧语法。

装饰器的旧语法与标准语法,有相当大的差异。旧语法以后会被淘汰,但是目前大量现有项目依然在使用它,本章就介绍旧语法下的装饰器。

experimentalDecorators 编译选项

使用装饰器的旧语法,需要打开--experimentalDecorators编译选项。

  1. $ tsc --target ES5 --experimentalDecorators

此外,还有另外一个编译选项--emitDecoratorMetadata,用来产生一些装饰器的元数据,供其他工具或某些模块(比如 reflect-metadata )使用。

这两个编译选项可以在命令行设置,也可以在tsconfig.json文件里面进行设置。

  1. {
  2. "compilerOptions": {
  3. "target": "ES6",
  4. "experimentalDecorators": true,
  5. "emitDecoratorMetadata": true
  6. }
  7. }

装饰器的种类

按照所装饰的不同对象,装饰器可以分成五类。

  • 类装饰器(Class Decorators):用于类。
  • 属性装饰器(Property Decorators):用于属性。
  • 方法装饰器(Method Decorators):用于方法。
  • 存取器装饰器(Accessor Decorators):用于类的 set 或 get 方法。
  • 参数装饰器(Parameter Decorators):用于方法的参数。

下面是这五种装饰器一起使用的一个示例。

  1. @ClassDecorator() // (A)
  2. class A {
  3. @PropertyDecorator() // (B)
  4. name: string;
  5. @MethodDecorator() //(C)
  6. fly(
  7. @ParameterDecorator() // (D)
  8. meters: number
  9. ) {
  10. // code
  11. }
  12. @AccessorDecorator() // (E)
  13. get egg() {
  14. // code
  15. }
  16. set egg(e) {
  17. // code
  18. }
  19. }

上面示例中,A 是类装饰器,B 是属性装饰器,C 是方法装饰器,D 是参数装饰器,E 是存取器装饰器。

注意,构造方法没有方法装饰器,只有参数装饰器。类装饰器其实就是在装饰构造方法。

另外,装饰器只能用于类,要么应用于类的整体,要么应用于类的内部成员,不能用于独立的函数。

  1. function Decorator() {
  2. console.log('In Decorator');
  3. }
  4. @Decorator // 报错
  5. function decorated() {
  6. console.log('in decorated');
  7. }

上面示例中,装饰器用于一个普通函数,这是无效的,结果报错。

类装饰器

类装饰器应用于类(class),但实际上是应用于类的构造方法。

类装饰器有唯一参数,就是构造方法,可以在装饰器内部,对构造方法进行各种改造。如果类装饰器有返回值,就会替换掉原来的构造方法。

类装饰器的类型定义如下。

  1. type ClassDecorator = <TFunction extends Function>
  2. (target: TFunction) => TFunction | void;

上面定义中,类型参数TFunction必须是函数,实际上就是构造方法。类装饰器的返回值,要么是返回处理后的原始构造方法,要么返回一个新的构造方法。

下面就是一个示例。

  1. function f(target:any) {
  2. console.log('apply decorator')
  3. return target;
  4. }
  5. @f
  6. class A {}
  7. // 输出:apply decorator

上面示例中,使用了装饰器@f,因此类A的构造方法会自动传入f

A不需要新建实例,装饰器也会执行。装饰器会在代码加载阶段执行,而不是在运行时执行,而且只会执行一次。

由于 TypeScript 存在编译阶段,所以装饰器对类的行为的改变,实际上发生在编译阶段。这意味着,TypeScript 装饰器能在编译阶段运行代码,也就是说,它本质就是编译时执行的函数。

下面再看一个示例。

  1. @sealed
  2. class BugReport {
  3. type = "report";
  4. title: string;
  5. constructor(t:string) {
  6. this.title = t;
  7. }
  8. }
  9. function sealed(constructor: Function) {
  10. Object.seal(constructor);
  11. Object.seal(constructor.prototype);
  12. }

上面示例中,装饰器@sealed()会锁定BugReport这个类,使得它无法新增或删除静态成员和实例成员。

如果除了构造方法,类装饰器还需要其他参数,可以采取“工厂模式”,即把装饰器写在一个函数里面,该函数可以接受其他参数,执行后返回装饰器。但是,这样就需要调用装饰器的时候,先执行一次工厂函数。

  1. function factory(info:string) {
  2. console.log('received: ', info);
  3. return function (target:any) {
  4. console.log('apply decorator');
  5. return target;
  6. }
  7. }
  8. @factory('log something')
  9. class A {}

上面示例中,函数factory()的返回值才是装饰器,所以加载装饰器的时候,要先执行一次@factory('log something'),才能得到装饰器。这样做的好处是,可以加入额外的参数,本例是参数info

总之,@后面要么是一个函数名,要么是函数表达式,甚至可以写出下面这样的代码。

  1. @((constructor: Function) => {
  2. console.log('log something');
  3. })
  4. class InlineDecoratorExample {
  5. // ...
  6. }

上面示例中,@后面是一个箭头函数,这也是合法的。

类装饰器可以没有返回值,如果有返回值,就会替代所装饰的类的构造函数。由于 JavaScript 的类等同于构造函数的语法糖,所以装饰器通常返回一个新的类,对原有的类进行修改或扩展。

  1. function decorator(target:any) {
  2. return class extends target {
  3. value = 123;
  4. };
  5. }
  6. @decorator
  7. class Foo {
  8. value = 456;
  9. }
  10. const foo = new Foo();
  11. console.log(foo.value); // 123

上面示例中,装饰器decorator返回一个新的类,替代了原来的类。

上例的装饰器参数target类型是any,可以改成构造方法,这样就更准确了。

  1. type Constructor = {
  2. new(...args: any[]): {}
  3. };
  4. function decorator<T extends Constructor> (
  5. target: T
  6. ) {
  7. return class extends target {
  8. value = 123;
  9. };
  10. }

这时,装饰器的行为就是下面这样。

  1. @decorator
  2. class A {}
  3. // 等同于
  4. class A {}
  5. A = decorator(A) || A;

上面代码中,装饰器要么返回一个新的类A,要么不返回任何值,A保持装饰器处理后的状态。

方法装饰器

方法装饰器用来装饰类的方法,它的类型定义如下。

  1. type MethodDecorator = <T>(
  2. target: Object,
  3. propertyKey: string|symbol,
  4. descriptor: TypedPropertyDescriptor<T>
  5. ) => TypedPropertyDescriptor<T> | void;

方法装饰器一共可以接受三个参数。

  • target:(对于类的静态方法)类的构造函数,或者(对于类的实例方法)类的原型。
  • propertyKey:所装饰方法的方法名,类型为string|symbol
  • descriptor:所装饰方法的描述对象。

方法装饰器的返回值(如果有的话),就是修改后的该方法的描述对象,可以覆盖原始方法的描述对象。

下面是一个示例。

  1. function enumerable(value: boolean) {
  2. return function (
  3. target: any,
  4. propertyKey: string,
  5. descriptor: PropertyDescriptor
  6. ) {
  7. descriptor.enumerable = value;
  8. };
  9. }
  10. class Greeter {
  11. greeting: string;
  12. constructor(message:string) {
  13. this.greeting = message;
  14. }
  15. @enumerable(false)
  16. greet() {
  17. return 'Hello, ' + this.greeting;
  18. }
  19. }

上面示例中,方法装饰器@enumerable()装饰 Greeter 类的greet()方法,作用是修改该方法的描述对象的可遍历性属性enumerable@enumerable(false)表示将该方法修改成不可遍历。

下面再看一个例子。

  1. function logger(
  2. target: any,
  3. propertyKey: string,
  4. descriptor: PropertyDescriptor
  5. ) {
  6. const original = descriptor.value;
  7. descriptor.value = function (...args) {
  8. console.log('params: ', ...args);
  9. const result = original.call(this, ...args);
  10. console.log('result: ', result);
  11. return result;
  12. }
  13. }
  14. class C {
  15. @logger
  16. add(x: number, y:number ) {
  17. return x + y;
  18. }
  19. }
  20. (new C()).add(1, 2)
  21. // params: 1 2
  22. // result: 3

上面示例中,方法装饰器@logger用来装饰add()方法,它的作用是让该方法输出日志。每当add()调用一次,控制台就会打印出参数和运行结果。

属性装饰器

属性装饰器用来装饰属性,类型定义如下。

  1. type PropertyDecorator =
  2. (
  3. target: Object,
  4. propertyKey: string|symbol
  5. ) => void;

属性装饰器函数接受两个参数。

  • target:(对于实例属性)类的原型对象(prototype),或者(对于静态属性)类的构造函数。
  • propertyKey:所装饰属性的属性名,注意类型有可能是字符串,也有可能是 Symbol 值。

属性装饰器不需要返回值,如果有的话,也会被忽略。

下面是一个示例。

  1. function ValidRange(min:number, max:number) {
  2. return (target:Object, key:string) => {
  3. Object.defineProperty(target, key, {
  4. set: function(v:number) {
  5. if (v < min || v > max) {
  6. throw new Error(`Not allowed value ${v}`);
  7. }
  8. }
  9. });
  10. }
  11. }
  12. // 输出 Installing ValidRange on year
  13. class Student {
  14. @ValidRange(1920, 2020)
  15. year!: number;
  16. }
  17. const stud = new Student();
  18. // 报错 Not allowed value 2022
  19. stud.year = 2022;

上面示例中,装饰器ValidRange对属性year设立了一个上下限检查器,只要该属性赋值时,超过了上下限,就会报错。

注意,属性装饰器的第一个参数,对于实例属性是类的原型对象,而不是实例对象(即不是this对象)。这是因为装饰器执行时,类还没有新建实例,所以实例对象不存在。

由于拿不到this,所以属性装饰器无法获得实例属性的值。这也是它没有在参数里面提供属性描述对象的原因。

  1. function logProperty(target: Object, member: string) {
  2. const prop = Object.getOwnPropertyDescriptor(target, member);
  3. console.log(`Property ${member} ${prop}`);
  4. }
  5. class PropertyExample {
  6. @logProperty
  7. name:string = 'Foo';
  8. }
  9. // 输出 Property name undefined

上面示例中,属性装饰器@logProperty内部想要获取实例属性name的属性描述对象,结果拿到的是undefined。因为上例的target是类的原型对象,不是实例对象,所以拿不到name属性,也就是说target.name是不存在的,所以拿到的是undefined。只有通过this.name才能拿到name属性,但是这时this还不存在。

属性装饰器不仅无法获得实例属性的值,也不能初始化或修改实例属性,而且它的返回值也会被忽略。因此,它的作用很有限。

不过,如果属性装饰器设置了当前属性的存取器(getter/setter),然后在构造函数里面就可以对实例属性进行读写。

  1. function Min(limit:number) {
  2. return function(
  3. target: Object,
  4. propertyKey: string
  5. ) {
  6. let value: string;
  7. const getter = function() {
  8. return value;
  9. };
  10. const setter = function(newVal:string) {
  11. if(newVal.length < limit) {
  12. throw new Error(`Your password should be bigger than ${limit}`);
  13. }
  14. else {
  15. value = newVal;
  16. }
  17. };
  18. Object.defineProperty(target, propertyKey, {
  19. get: getter,
  20. set: setter
  21. });
  22. }
  23. }
  24. class User {
  25. username: string;
  26. @Min(8)
  27. password: string;
  28. constructor(username: string, password: string){
  29. this.username = username;
  30. this.password = password;
  31. }
  32. }
  33. const u = new User('Foo', 'pass');
  34. // 报错 Your password should be bigger than 8

上面示例中,属性装饰器@Min通过设置存取器,拿到了实例属性的值。

存取器装饰器

存取器装饰器用来装饰类的存取器(accessor)。所谓“存取器”指的是某个属性的取值器(getter)和存值器(setter)。

存取器装饰器的类型定义,与方法装饰器一致。

  1. type AccessorDecorator = <T>(
  2. target: Object,
  3. propertyKey: string|symbol,
  4. descriptor: TypedPropertyDescriptor<T>
  5. ) => TypedPropertyDescriptor<T> | void;

存取器装饰器有三个参数。

  • target:(对于静态属性的存取器)类的构造函数,或者(对于实例属性的存取器)类的原型。
  • propertyKey:存取器的属性名。
  • descriptor:存取器的属性描述对象。

存取器装饰器的返回值(如果有的话),会作为该属性新的描述对象。

下面是一个示例。

  1. function configurable(value: boolean) {
  2. return function (
  3. target: any,
  4. propertyKey: string,
  5. descriptor: PropertyDescriptor
  6. ) {
  7. descriptor.configurable = value;
  8. };
  9. }
  10. class Point {
  11. private _x: number;
  12. private _y: number;
  13. constructor(x:number, y:number) {
  14. this._x = x;
  15. this._y = y;
  16. }
  17. @configurable(false)
  18. get x() {
  19. return this._x;
  20. }
  21. @configurable(false)
  22. get y() {
  23. return this._y;
  24. }
  25. }

上面示例中,装饰器@configurable(false)关闭了所装饰属性(xy)的属性描述对象的configurable键(即关闭了属性的可配置性)。

下面的示例是将装饰器用来验证属性值,如果赋值不满足条件就报错。

  1. function validator(
  2. target: Object,
  3. propertyKey: string,
  4. descriptor: PropertyDescriptor
  5. ){
  6. const originalGet = descriptor.get;
  7. const originalSet = descriptor.set;
  8. if (originalSet) {
  9. descriptor.set = function (val) {
  10. if (val > 100) {
  11. throw new Error(`Invalid value for ${propertyKey}`);
  12. }
  13. originalSet.call(this, val);
  14. };
  15. }
  16. }
  17. class C {
  18. #foo!: number;
  19. @validator
  20. set foo(v) {
  21. this.#foo = v;
  22. }
  23. get foo() {
  24. return this.#foo;
  25. }
  26. }
  27. const c = new C();
  28. c.foo = 150;
  29. // 报错

上面示例中,装饰器用自己定义的存值器,取代了原来的存值器,加入了验证条件。

TypeScript 不允许对同一个属性的存取器(getter 和 setter)使用同一个装饰器,也就是说只能装饰两个存取器里面的一个,且必须是排在前面的那一个,否则报错。

  1. // 报错
  2. class Person {
  3. #name:string;
  4. @Decorator
  5. set name(n:string) {
  6. this.#name = n;
  7. }
  8. @Decorator // 报错
  9. get name() {
  10. return this.#name;
  11. }
  12. }

上面示例中,@Decorator同时装饰name属性的存值器和取值器,所以报错。

但是,下面的写法不会报错。

  1. class Person {
  2. #name:string;
  3. @Decorator
  4. set name(n:string) {
  5. this.#name = n;
  6. }
  7. get name() {
  8. return this.#name;
  9. }
  10. }

上面示例中,@Decorator只装饰它后面第一个出现的存值器(set name()),并不装饰取值器(get name()),所以不报错。

装饰器之所以不能同时用于同一个属性的存值器和取值器,原因是装饰器可以从属性描述对象上面,同时拿到取值器和存值器,因此只调用一次就够了。

参数装饰器

参数装饰器用来装饰构造方法或者其他方法的参数。它的类型定义如下。

  1. type ParameterDecorator = (
  2. target: Object,
  3. propertyKey: string|symbol,
  4. parameterIndex: number
  5. ) => void;

参数装饰器接受三个参数。

  • target:(对于静态方法)类的构造函数,或者(对于类的实例方法)类的原型对象。
  • propertyKey:所装饰的方法的名字,类型为string|symbol
  • parameterIndex:当前参数在方法的参数序列的位置(从0开始)。

该装饰器不需要返回值,如果有的话会被忽略。

下面是一个示例。

  1. function log(
  2. target: Object,
  3. propertyKey: string|symbol,
  4. parameterIndex: number
  5. ) {
  6. console.log(`${String(propertyKey)} NO.${parameterIndex} Parameter`);
  7. }
  8. class C {
  9. member(
  10. @log x:number,
  11. @log y:number
  12. ) {
  13. console.log(`member Paremeters: ${x} ${y}`);
  14. }
  15. }
  16. const c = new C();
  17. c.member(5, 5);
  18. // member NO.1 Parameter
  19. // member NO.0 Parameter
  20. // member Paremeters: 5 5

上面示例中,参数装饰器会输出参数的位置序号。注意,后面的参数会先输出。

跟其他装饰器不同,参数装饰器主要用于输出信息,没有办法修改类的行为。

装饰器的执行顺序

前面说过,装饰器只会执行一次,就是在代码解析时执行,哪怕根本没有调用类新建实例,也会执行,而且从此就不再执行了。

执行装饰器时,按照如下顺序执行。

  1. 实例相关的装饰器。
  2. 静态相关的装饰器。
  3. 构造方法的参数装饰器。
  4. 类装饰器。

请看下面的示例。

  1. function f(key:string):any {
  2. return function () {
  3. console.log('执行:', key);
  4. };
  5. }
  6. @f('类装饰器')
  7. class C {
  8. @f('静态方法')
  9. static method() {}
  10. @f('实例方法')
  11. method() {}
  12. constructor(@f('构造方法参数') foo:any) {}
  13. }

加载上面的示例,输出如下。

  1. 执行: 实例方法
  2. 执行: 静态方法
  3. 执行: 构造方法参数
  4. 执行: 类装饰器

同一级装饰器的执行顺序,是按照它们的代码顺序。但是,参数装饰器的执行总是早于方法装饰器。

  1. function f(key:string):any {
  2. return function () {
  3. console.log('执行:', key);
  4. };
  5. }
  6. class C {
  7. @f('方法1')
  8. m1(@f('参数1') foo:any) {}
  9. @f('属性1')
  10. p1: number;
  11. @f('方法2')
  12. m2(@f('参数2') foo:any) {}
  13. @f('属性2')
  14. p2: number;
  15. }

加载上面的示例,输出如下。

  1. 执行: 参数1
  2. 执行: 方法1
  3. 执行: 属性1
  4. 执行: 参数2
  5. 执行: 方法2
  6. 执行: 属性2

上面示例中,实例装饰器的执行顺序,完全是按照代码顺序的。但是,同一个方法的参数装饰器,总是早于该方法的方法装饰器执行。

如果同一个方法或属性有多个装饰器,那么装饰器将顺序加载、逆序执行。

  1. function f(key:string):any {
  2. console.log('加载:', key);
  3. return function () {
  4. console.log('执行:', key);
  5. };
  6. }
  7. class C {
  8. @f('A')
  9. @f('B')
  10. @f('C')
  11. m1() {}
  12. }
  13. // 加载: A
  14. // 加载: B
  15. // 加载: C
  16. // 执行: C
  17. // 执行: B
  18. // 执行: A

如果同一个方法有多个参数,那么参数也是顺序加载、逆序执行。

  1. function f(key:string):any {
  2. console.log('加载:', key);
  3. return function () {
  4. console.log('执行:', key);
  5. };
  6. }
  7. class C {
  8. method(
  9. @f('A') a:any,
  10. @f('B') b:any,
  11. @f('C') c:any,
  12. ) {}
  13. }
  14. // 加载: A
  15. // 加载: B
  16. // 加载: C
  17. // 执行: C
  18. // 执行: B
  19. // 执行: A

为什么装饰器不能用于函数?

装饰器只能用于类和类的方法,不能用于函数,主要原因是存在函数提升。

JavaScript 的函数不管在代码的什么位置,都会提升到代码顶部。

  1. addOne(1);
  2. function addOne(n:number) {
  3. return n + 1;
  4. }

上面示例中,函数addOne()不会因为在定义之前执行而报错,原因就是函数存在提升,会自动提升到代码顶部。

如果允许装饰器可以用于普通函数,那么就有可能导致意想不到的情况。

  1. let counter = 0;
  2. let add = function (target:any) {
  3. counter++;
  4. };
  5. @add
  6. function foo() {
  7. //...
  8. }

上面示例中,本来的意图是装饰器@add每使用一次,变量counter就加1,但是实际上会报错,因为函数提升的存在,使得实际执行的代码是下面这样。

  1. @add // 报错
  2. function foo() {
  3. //...
  4. }
  5. let counter = 0;
  6. let add = function (target:any) {
  7. counter++;
  8. };

上面示例中,@add还没有定义就调用了,从而报错。

总之,由于存在函数提升,使得装饰器不能用于函数。类是不会提升的,所以就没有这方面的问题。

另一方面,如果一定要装饰函数,可以采用高阶函数的形式直接执行,没必要写成装饰器。

  1. function doSomething(name) {
  2. console.log('Hello, ' + name);
  3. }
  4. function loggingDecorator(wrapped) {
  5. return function() {
  6. console.log('Starting');
  7. const result = wrapped.apply(this, arguments);
  8. console.log('Finished');
  9. return result;
  10. }
  11. }
  12. const wrapped = loggingDecorator(doSomething);

上面示例中,loggingDecorator()是一个装饰器,只要把原始函数传入它执行,就能起到装饰器的效果。

多个装饰器的合成

多个装饰器可以应用于同一个目标对象,可以写在一行。

  1. @f @g x

上面示例中,装饰器@f@g同时装饰目标对象x

多个装饰器也可以写成多行。

  1. @f
  2. @g
  3. x

多个装饰器的效果,类似于函数的合成,按照从里到外的顺序执行。对于上例来说,就是执行f(g(x))

前面也说过,如果fg是表达式,那么需要先从外到里求值。

参考链接