TS从入门到放弃【十八】:装饰器

310 阅读7分钟

如果想在 TS 项目中使用装饰器,需要将 tsconfig.json 配置项中的 experimentalDecorators 设置为 true

装饰器的定义

  • 装饰器是一种新的声明,它能够作用于类的声明、方法、访问符属性和参数上。使用 @ 符号加一个名字来定义。
  • 名字必须是一个函数,或者求值后是一个函数,函数在运行的时候会被调用,被装饰的声明作为参数会自动传入。
  • 装饰器要紧挨着要修饰的内容的前面;所有的装饰器不能用在**声明文件(.d.ts为后缀的文件)**中,和任何外部上下文中(例如:declear)。

1、装饰器使用

  • 装饰器
function setProp(target) {
  // ...
}
// 使用
@setProp
// 下面紧跟需要被装饰的函数
  • 装饰器工厂
function setProp() {
  return function(target) {
    // ...
  };
}
@setProp()

装饰器工厂返回一个函数,使用的时候 @setProp() 进行了函数调用

2、组合使用多个装饰器

  • 装饰器工厂,是从上至下去生成装饰器。
  • 普通装饰器的顺序是从下至上依次执行。
function setName() {
  console.log('get setName');
  return (target) => {
    console.log('setName');
  };
}
function setAge() {
  console.log('get setAge');
  return (target) => {
    console.log('setAge');
  };
}

使用:

@setName()
@setAge()
class ClassDec { // 输出:get setName => get setAge => setAge => setName
  constructor(){}
}

相当于装饰器工厂先从上到下生成装饰器,装饰器再从下到上依次调用

3、装饰器求值

在类的定义中,不同声明上的装饰器是由规定的顺序的:

  • 参数装饰器、方法装饰器、访问符装饰器、属性装饰器:应用到每个实例成员上
  • 参数装饰器、方法装饰器、访问符装饰器、属性装饰器:应用到每个静态成员上
  • 参数装饰器:应用到构造函数上
  • 类装饰器:应用到类上

4、类装饰器

类装饰器在类声明之前声明。

类装饰器的表达式会在运行的时候被当作函数来调用,它有一个唯一的参数:被装饰的类

let sign: any = null;
function setName(name: string) {
  return (target: new() => any) => {
    sign = target;
    console.log(target.name);
  }
}
@setName('dylan') // 打印 ClassDec
class ClassDec {
  constructor() {}
}

上面使用装饰器后,会打印出 ClassDec,说明传入的 target 参数是被装饰的类

接下来,我们来看一下 sign

console.log(sign === ClassDec); // true
console.log(sign === ClassDec.prototype.constructor); // true

可以看出,sign 就等同于 ClassDec这个类本身

5、修改类的原型对象和构造函数

function addName(constructor: new() => any) {
  constructor.prototype.name = 'dylan';
}
@addName
class ClassD{}
const d = new ClassD();
console.log(d.name); // Error:类型“ClassD”上不存在属性“name”。

此时会报错,这是因为我们的 ClassD 上并没有定义 name 属性。

所以,我们需要定义一个同名的接口,通过声明合并来解决这个问题:

function addName(constructor: new() => any) {
  constructor.prototype.name = 'dylan';
}
@addName
class ClassD{}
interface ClassD {
  name: string;
}
const d = new ClassD();
console.log(d.name); // dylan

我们使用同名的接口同名的类进行声明合并之后,name 是添加到了类的原型对象上的,所以我们可以拿到它的值。

6、修改类的实现

如果类装饰器返回一个值,那么会使用这个返回的值来替代被装饰类的声明。所以我们可以使用这个特性来修改类的实现。但是要注意的是,这样我们就得自己来处理原有的原型链

function classDecorator<T extends new(...args: any[]) => {}>(target: T) {
  return class extends target {
    public newProperty = 'new property';
    public hello = 'override';
  };
}

定义好装饰器工厂后,我们来定义一个类 Greeter

@classDecorator
class Greeter {
  public property = 'property';
  public hello: string;
  constructor(str: string) {
    this.hello = str;
  }
}

接下来我们使用这个类创建一个实例,来看看效果:

console.log(new Greeter('world'));

打印出的结果为:

Greeter {
  property: 'property',
  hello: 'override',
  newProperty: 'new property',
  [[Prototype]]: Greeter
}

我们可以看到,最后创建出的实例,不仅包含类中定义的 propertyhello,还包含了 newProperty 这个参数(装饰中的实例属性);并且参数 hello 被覆盖了(原本应该是 world

证明了刚刚的定义:如果类装饰器返回一个值,则会使用返回的值来替代被装饰类的声明

7、方法装饰器

方法装饰器用来处理类中的方法,它可以处理方法的属性描述符,可以处理方法的定义

方法装饰在运行的时候也是被当作函数来调用的。它包含三个参数:

  • 参数1:
    • 装饰的静态成员:代表类的构造函数。
    • 实例成员:代表类的原型对象
  • 参数2:成员的名字
  • 参数3:成员的属性描述符

JS的属性描述符:

  • configurable:可配置
  • writeable:可写
  • enumerable:可枚举

声明一个装饰器工厂:

function enumerable(bool: boolean) {
  // target:类的原型对象/构造函数;propertyName:方法的名字;descriptor:对象(属性描述符)
  return (target: any, propertyName: string, descriptor: PropertyDescriptor) => {
    console.log(target);
    descriptor.enumerable = bool;
  };
}

接下来声明一个类:

class ClassF {
  constructor(public age: number) {}
  @enumerable(false)
  public getAge() {
    return this.age;
  }
}

@enumerable(false) 通过传入的值来控制被装饰的方法是否是可枚举的。

接下来我们使用一下:

const classF = new ClassF(18);
console.log(classF); // ClassF {age: 18}
for(const key in classF) { console.log(key); } // age

在执行 new ClassF(18); 语句的时候,会进入装饰器,装饰器函数中会打印 target(类的原型对象):{constructor: ƒ, getAge: ƒ}(此时装饰的是实例成员,target就是类的原型对象

  • 如果方法装饰返回一个值,那么就会用这个值作为方法的属性描述符对象

接下来我们改一下上面的例子:

function enumerable(bool: boolean): any {
  // target:类的原型对象/构造函数;propertyName:方法的名字;descriptor:对象(属性描述符)
  return (target: any, propertyName: string, descriptor: PropertyDescriptor) => {
    return {
      value() {
        return 'not age';
      },
      enumerable: bool
    };
  };
}
class ClassF {
  constructor(public age: number) {}
  @enumerable(false)
  public getAge() {
    return this.age;
  }
}
const classF = new ClassF(18);
console.log(classF.getAge()); // not age

上述代码中,我们在 enumerable 里返回了一个对象。在创建实例后(new ClassF(18)),调用实例的 getAge() 方法,返回的是 not age

这是因为方法装饰器此时会用装饰器内部返回的值来作为属性描述对象

8、访问器装饰器

访问器也就是 set、get 存取值函数

TS 不允许同时装饰一个成员的 get 和 set 访问器,只需要装饰其中在前面定义的一个就可以了(会同时装饰两个)。也就是把 get 和 set 他俩作为一个整体,对其进行装饰。

function enumerable(bool: boolean) {
  return (target: any, propertyName: string, descriptor: PropertyDescriptor) => {
    descriptor.enumerable = bool;
  };
}
class ClassG {
  private _name: string;
  constructor(name: string) {
    this._name = name;
  }
  @enumerable(false) // 只需要在get set上面加一个装饰器就行了
  get name() {
    return this._name;
  }
  set name(name) {
    this._name = name;
  }
}

此时 name 属性就不可枚举了。

const classG = new ClassG('dylann');
for(const key in classG) { console.log(key); }
  • 装饰器传入false或者不传:只打印_name
  • 装饰器传入true:打印 _name 、 name
  • 去掉装饰器:打印 _name 、 name

同样的,访问器装饰器如果有返回值,这个值就会被作为属性的属性描述符

9、属性装饰器

属性装饰器声明在属性的声明之前。

他有两个参数,和方法装饰器的前两个参数是一模一样的。

  • target:类的原型对象/构造函数;
  • propertyName:成员的名字;

属性装饰器没法操作属性的属性描述符,它只能用来判断某个类中是否声明了某个名字的属性。

function printPropertyName(target: any, propertyName: string) {
  console.log(propertyName + ' ' + 'hahah');
}
class ClassH {
  @printPropertyName
  public name: string;
}

上述代码会打印:name hahah

10、参数装饰器

三个参数:

  • 参数1:构造函数/原型对象。
  • 参数2:成员的名字。
  • 参数3:参数在函数的参数列表中的索引

参数装饰器的返回值会被忽略。

function required(target: any, propertyName: string, index: number) {
  console.log(`修饰的是${propertyName}${index + 1}个参数`);
}
class ClassI {
  public name: string = 'dylan';
  public age: number = 18;
  public getInfo(prefix: string, @required infoType: string): any {
    return prefix + ' ' + this[infoType];
  }
}
interface ClassI {
  [key: string]: string | number | Function;
}
const classI = new ClassI();
console.log(classI.getInfo('heihei', 'age'));

打印结果:

修饰的是getInfo的2个参数

heihei 18