理解装饰器和Reflect

114 阅读8分钟

ts支持装饰器,装饰器是一种语法糖,本质就是一个函数。一个类加上一个装饰器,可以看作是调用一个函数并把该类作为参数传入,对该类进行修改后又返回另一个类,这跟高阶组件的思路差不多。当然,这个思路也不是高阶组件首创的。

先看一个例子

通过代码注释理解代码意图

// 我写了一个类
class Car {
  
}
// 我想给类添加一个run方法
// 所以写个RunDecorator函数来给类加run方法
// 该方法参数类型T是构造函数类型的写法
// 类的本质就是构造函数
const RunDecorator = <T extends new (...args: any[]) => any>(
  constructor: T,
) => {
  // 拿到传进来的构造函数,通过继承的方式重载它
  // 本质上就是产生了另一个新的类
  return class extends constructor {
    // 添加run方法
    run() {
      console.log('runing');
    }
  }
}
// 把RunDecorator作用到Car上,产生新的类
const NewCar = RunDecorator(Car);
// 用新的类创建实例
const newCar = new NewCar();
// 新的类实例执行run方法
newCar.run();

上面的代码的执行结果就仅仅是打印出runing,如下:

runing

上面的RunDecorator,所添加的run方法,固定打印'runing'字符串,但我们希望打印的内容可以灵活指定,并且可以用在不同的类上面,所以我们写一个工厂函数来生成不同输出的RunDecorator,这个工厂函数本质上就是利用闭包特性,传入不同的内容,产生不同输出的RunDecorator:

// 返回RunDecorator的工厂方法
// 参数是某个字符串
const CreateRunDecorator = (who: string) => {
  // 返回RunDecorator方法
  return <T extends new (...args: any[]) => any>(
    constructor: T,
  ) => {
    // RunDecorator方法依然会重载某个构造函数或类
    return class extends constructor {
      // 新的run方法,根据工厂函数调用时传入的不同字符串,决定输出的不同内容
      run() {
        console.log(`${who} runing`);
      }
    }
  }
}

class Car {
  
}
// 给Car类添加的run方法输出内容会带有'Car'字符串
const NewCar = CreateRunDecorator('Car')(Car);

class Dog {

}
// 给Dog类添加的run方法输出内容会带有'Dog'字符串
const NewDog = CreateRunDecorator('Dog')(Dog);

// 新类创建新实例并各自调用run方法
const newCar = new NewCar();
newCar.run();
const newDog = new NewDog();
newDog.run();

输出结果如下:

Car runing
Dog runing

用装饰器

为了方便以后使用,我把CreateRunDecorator单独放在一个文件(decorator.ts)里面,并对外导出:

// decorator.ts
export const CreateRunDecorator = (who: string) => {
  return <T extends new (...args: any[]) => any>(
    constructor: T,
  ) => {
    return class extends constructor {
      run() {
        console.log(`${who} runing`);
      }
    }
  }
}

哪里想使用就引入进来

// 引入
import { CreateRunDecorator } from './decorator';

class Car {
  
}
const NewCar = CreateRunDecorator('Car')(Car);

const newCar = new NewCar();
newCar.run();

为了给某个类添加run方法,每次都要调用CreateRunDecorator产生新的类,写法不够简洁,利用typescript的装饰器支持,就可以直接给一个类赋能,不需要产生新类,写法简洁,当然这都是语法糖,本质上跟我们前面的调用行为类似,首先要在配置文件tsconfig.json上打开装饰器配置:

{
  "compilerOptions": {
    ......
    "experimentalDecorators": true,
    ......
  }
  ......
}

然后就可以用CreateRunDecorator来装饰一个类,注意,给一个类添加了类声明所没有的方法,调用新方法时类型判断会报错,需要加上一行类型声明防止报错:

import { CreateRunDecorator } from './decorator';

@CreateRunDecorator('Car')
class Car {
  // 装饰器添加了新方法,但Car不包含该新方法,下面调用run时,
  // typescript的类型检测就会报错,这里加上这个类型声明就不会报错,
  // 实际编译成js后不存在这些代码,完全是写给类型检测工具看的
  [key: string]: any;
}

const car = new Car();
car.run();

ts装饰器的使用,是一种语法糖,本质上就是调用装饰器返回的方法,以Car为参数,返回的新类再覆盖原来的Car。

修改类中的方法

上面通过装饰器,给类添加了新的方法。现在我想修改类中已有的方法,还是举个例子:

// 有一个类
class Car {
  // 里面有个run方法,打印一句话
  run() {
    console.log('Car running');
  }
}

// 有个修改方法,修改目标里面的某个方法
// 第一个参数是目标对象,第二个参数是属性名
function logSome(target: any, propertyName: string) {
  // 拿到目标的方法的描述符
  const descriptor = Object.getOwnPropertyDescriptor(target, propertyName);
  // 描述符的value就是方法本身
  const method = descriptor?.value;
  // 重新修改方法属性
  Object.defineProperty(target, propertyName, {
    ...descriptor, // 其他描述属性沿用原来的
    // 覆盖原来的value值,创造新的方法
    value: function(...args: any[]) {
      // 多了一行打印
      console.log(`method ${propertyName} begin run`);
      // 继续调用原来的方法
      return method.apply(this, args);
    }
  });
}
// 类方法不写在构造函数,而是写在原型上
logSome(Car.prototype, 'run');
(new Car()).run();

运行结果:

method run begin run
Car running

接下来改成用装饰器实现同样的修改。

装饰器就是一个方法,ts会调用装饰器这个方法,传入一些参数,对于方法装饰器,ts会传入三个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字,即方法名。
  3. 成员的属性描述符,即上面通过Object.getOwnPropertyDescriptor获取到的对象。

这里解释下第一个参数,上面我们调用logSome时传的第一个参数不是类本身,而是类的原型,因为run方法是实例成员,它存放在类的原型中,需要修改类的原型属性才能修改这个方法,实际上,对类的方法使用装饰器时,ts会判断该方法是静态成员还是实例成员,如果是静态成员,ts会直接传入构造函数,对于实例成员,会传入类的原型。说白了,ts会帮我们判断,传进来的目标肯定就是方法所在的对象。

而传给装饰器的第三个参数就是方法属性的描述符,也省了我们自己获取,把装饰器方法放到decorator.ts文件中导出。

要注意的是,跟上面不使用装饰器的修改不同的是,使用装饰器修改方法可以直接修改方法的描述符的value即可,不需要通过Object.defineProperty修改目标属性,因为,ts会拿传进去的描述符对象(propertyDescriptor)来调用Object.defineProperty修改run属性,你也可以在装饰器方法最后返回一个对象,ts会用它来作为run属性的最新描述符:

// decorator.ts
export function logSome(
  target: any,
  propertyName: string,
  propertyDescriptor: PropertyDescriptor,
) {
  // 先存起方法本身
  const method = propertyDescriptor?.value;
  // 直接修改方法属性描述符
  propertyDescriptor.value = function(...args: any[]) {
    console.log(`method ${propertyName} begin run`);
    return method.apply(this, args);
  }
  // 返回描述符
  // ts处理装饰器最后会用返回的描述符覆盖原来方法属性的描述符
  return propertyDescriptor;
}

具体使用:

import { logSome } from './decorator';

class Car {
  @logSome
  run() {
    console.log('Car running');
  }
}

(new Car()).run();

中间总结

装饰器可以作用于类的各个部位,它本质上就是一个函数。用装饰器装饰不同部位时,传给装饰器的参数也有所不同,会根据具体部位传入充足的信息。所以我们只要先了解清楚各个部位的装饰器会传什么参数,再根据参数信息思考如何实现我们想要的效果。

了解其它装饰器

上面描述了类和方法装饰器,接着再简单看看其它装饰器。

访问器装饰器

访问器指get方法和set方法,比如:

class User {
  private _age: number;

  @fix
  get age() {
    return this._age;
  }
}

访问器装饰器被调用时,传进去的参数按顺序有这些:

  1. 类的构造函数或者是类的原型,跟方法装饰器一样,取决于装饰的是静态属性还是实例成员;
  2. 属性名,比如上面例子中是age
  3. 成员的属性描述器;

注意:同一个属性的get和set方法,只能给其中一个加装饰器,不能同时给两个都加,因为装饰器应用于的属性描述符,是结合了get和set访问器,而不是各自单独的描述符。

属性装饰器

例子:

class User {
  @fix
  age: number;
}

属性装饰器运行时会收到的参数是:

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

参数装饰器

例子:

class User {
  eat(@need str: string) {
    console.log(str);
  }
}

参数装饰器运行时会收到的参数是:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象;
  2. 成员属性名;
  3. 参数下标,即第几个参数,从0开始。

装饰器执行顺序

多种类型装饰器的执行顺序,可以说是从内到外,

参数->方法->get/set/属性->类装饰器

同一个属性/方法可以加多个装饰器,比如:

class User {
  @log
  @fix
  eat(str: string) {
    console.log(str);
  }
}

多个装饰器,先调用离属性近的,比如上例,会先调用fix,执行完后,返回结果再继续调用log。 其他类型的装饰器类似。

反射和元数据

Reflect这个对象本身集合了很多对象相关的操作,,比如:

const obj = {
  name: 'kkk',
};

// 读取对象属性值
console.log(Reflect.get(obj, 'name'));
// 获取对象所有key
console.log(Reflect.ownKeys(obj));
// 获取对象属性描述符
console.log(Reflect.getOwnPropertyDescriptor(obj, 'name'));

打印结果:

kkk
[ 'name' ]
{ value: 'kkk', writable: true, enumerable: true, configurable: true }

新的提案提出给Reflect添加更多操作元数据的方法。

元数据,可以理解为类或对象的辅助信息,或者就单纯认为就是信息。属性的描述符信息也可以认为是元信息,数组长度也可以认为是一种元信息。

因为给Reflect添加新的方法的提案一直没有落实,所以原生不能支持,但有牛人实现了库来实现那些新方法。

安装该库:

npm install reflect-metadata --save

现在可以通过新的方法给类添加元数据,例子:

// 需要引入库
import 'reflect-metadata';

const metaKey = 'eat-key';
const metaValue = {speed: 'fast'};

const classMetaKey = 'fix-key';
const classMetaValue = {view: 'far'};

// 用装饰器给类添加元数据
@Reflect.metadata(classMetaKey, classMetaValue)
class Someone {
  // 用装饰器给类实例方法添加元数据
  @Reflect.metadata(metaKey, metaValue)
  eat() {
    console.log('eating');
  }
}

class User {
  eat() {
    console.log('eating');
  }
}
// 用方法而不是装饰器给类实例方法添加元数据,等价于用装饰器
Reflect.defineMetadata(metaKey, metaValue, User.prototype, 'eat');
// 用方法而不是装饰器给类添加元数据
Reflect.defineMetadata(classMetaKey, classMetaValue, User);

// 给实例方法添加的元素数只能到实例或原型获取
console.log(Reflect.getMetadata(metaKey, new User(), 'eat'));
console.log(Reflect.getMetadata(metaKey, User.prototype, 'eat'));
console.log(Reflect.getMetadata(metaKey, new Someone(), 'eat'));
console.log(Reflect.getMetadata(metaKey, Someone.prototype, 'eat'));

console.log(Reflect.getMetadata(classMetaKey, User));
console.log(Reflect.getMetadata(classMetaKey, Someone));

输出结果:

{ speed: 'fast' }
{ speed: 'fast' }
{ speed: 'fast' }
{ speed: 'fast' }
{ view: 'far' }
{ view: 'far' }

反射控制元数据方法配合装饰器

当给类实例方法添加装饰器时,我想知道方法的参数类型是什么,比如:

class User {
  @log
  eat(str1: string, str2: string) {
    console.log(str1, str2);
  }
}

我的log方法想根据参数类型打印不同日志,我可以给这个方法再加一个装饰器,装饰器会添加方法的参数类型:

import 'reflect-metadata';

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  // 通过反射方法获取元数据
  const typeL = Reflect.getMetadata('design:paramtypes', target, propertyKey);
  // 根据参数类型打印不同日志
  if (typeL[0] === String) {
    console.log('eat method need string params');
  } else {
    console.log('eat method need not string params');
  }
}

class User {
  // 在log之前先加一个元数据,记录方法的参数类型
  @log
  @Reflect.metadata('design:paramtypes', [String, String])
  eat(str1: string, str2: string) {
    console.log(str1, str2);
  }
}

在我们实现一些装饰器时,可能需要额外的辅助元数据,比如实例方法的装饰器逻辑可能需要知道方法参数的类型,返回值类型;比如实现属性装饰器时,想知道属性的类型;等等。

typescript提供了另一个特性,该特性在我们添加装饰器时,会帮我们针对装饰器目标插入更早执行的元数据,让我们能方便拿到一些有用的元数据。

在typescript的配置文件打开该特性:

{
  "compilerOptions": {
    ......
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true, // 打开这个
    ......
  }
}

打开这个配置之后,为了看看效果,我再写一个例子,复用之前用过的一些装饰器:

import { logSome, CreateRunDecorator, logAccess } from './decorator';
@CreateRunDecorator('shit')
class Car {

  _seatCount = 5;

  @logSome
  run() {
    console.log('Car running');
  }

  @logAccess
  get seat() {
    return this._seatCount;
  }
}

主要是为了看编译成js后的代码: image.png 它给run方法和seat访问器都添加了额外的元数据,并且元数据的key就是固定那几个:

"design:type"
"design:paramtypes"
"design:returntype"

那我们实现run方法和seat访问器的装饰器时,就可以通过Reflect.getMetadata,使用这几个固定的key作为第一个参数,就可以获取到方法的类型,参数类型,返回值类型。