Typescript学习(十八)初探装饰器

170 阅读11分钟

所谓的装饰器, 实际上可以看作就是一个函数, 一个可以扩展类及其成员功能的函数! 在使用Typescript之前, 记得开启experimentalDecorators设置, 本文中使用的Typescript版本为5.0.2;

装饰器分类

类装饰器

前面说了, 装饰器本质上是一个函数, 所谓的类装饰器, 就是用来修饰整个类的函数, 并从整个类的级别上去扩展功能, 它的参数只有一个, 那就是被修饰的类本身! Typescript内置了ClassDecorator, 表示类装饰器的类型; 后续还有PropertyDecorator、MethodDecorator、ParameterDecorator分别代表属性装饰器、方法装饰器和参数装饰器的类型; 我们先来看看类装饰器的使用吧:

// 为类的原型新增一个方法
function AddMethod ():ClassDecorator {
  return (target:any) => {
    target.prototype.fn = () => {
      console.log('装饰器内的方法')
    }
  }
}

// 为类添加静态方法
function AddProperty (value:string):ClassDecorator {
  return (target:any) => {
    target.staticProperty = value
  }
}

@AddProperty('装饰器静态属性')
@AddMethod()
class Foo {
  static staticProperty: any
  fn() {
    throw new Error("Method not implemented.")
  }
}

let foo = new Foo()
foo.fn() // 装饰器内的方法
console.log(Foo.staticProperty) // 装饰器静态属性

除了拓展属性/方法, 类装饰器还能直接返回一个新的子类, 这个子类则继承了被修饰的那个类, 而被修饰的类, 最终会被这个子类所代替;

// 重写一个类的装饰器
function OverrideClass ():ClassDecorator {
  return (target:any) => {
    return class extends target {
      print () {
        console.log('this is new print')
      }
    }
  }
}

@OverrideClass()
class Bar {
  print () {
    console.log('this is old print')
  }
}

// 注意, 这里的Bar已经不是原来那个了, 而是装饰器中返回的那个子类
const bar = new Bar()
bar.print() // this is new print

方法装饰器

虽然, 使用类装饰器可以直接为类新增方法和属性, 但是, Typescript为我们提供了专门处理方法/属性的装饰器, 我们就来先认识下方法装饰器, 不同于类装饰器, 它的入參有三个, 分别为类原型对象、方法名、方法的属性描述符, 显然, 相比于类装饰器, 它提供的信息更多, 应对的场景也更具体!

// 计算一个异步函数的执行时间
function calcReqTime ():MethodDecorator {
  return (_target:any, methodName, descriptor: TypedPropertyDescriptor<any>) => {
    const originMethod = descriptor.value
    descriptor.value = async () => {
      console.time(String(methodName) + '请求时间')
      await originMethod()
      console.timeEnd(String(methodName) + '请求时间')
    }
  }
}

class Axios {
  @calcReqTime()
  post ():Promise<void> {
    return new Promise((resolve) => {
      setTimeout (() => {
        console.log('请求成功')
        resolve()
      }, 3000)
    })
  }
}

const axios = new Axios()

axios.post()
/**
 * 请求成功
  post请求时间: 3.006s
 */

我们通过descriptor.value, 获取到被修饰的方法的具体逻辑, 然后将其赋给originMethod变量, 然后再重新为descriptor.value赋值, 即重新定义其逻辑, 增加了计算执行时间的console.time代码, 再执行originMethod, 就能够得到其执行的时间了; 以上的代码其实就是在维持方法原有功能基础上, 扩展了类方法的能力; 还有一点要注意, 这里所说的方法, 其实是类原型上的方法, 而非类上的静态方法, 如果想要为一个类添加静态方法, 还是需要使用类装饰器;

访问符装饰器

其实, 所谓的访问符装饰器就是方法装饰器, 只不过, 刚才的方法装饰器里我们操作了descriptor.value, 现在我们操作descriptor.set, 当然, 也可以是descriptor.get, 所以, 你也可以说这就是方法装饰器, 只是换了个叫法

class House {
  total:number = 0
  constructor(public length:number, public width:number) {}
  get area () {
    return this.length * this.width
  }
  @logPrice('王老五')
  set price (value: number) {
    this.total = value * this.area
  }
}

function logPrice (name:string): MethodDecorator {
  return (_target: any, methodKey: any, descriptor: TypedPropertyDescriptor<any>) => {
    const originSetter = descriptor.set
    descriptor.set = function (value:number) {
      if (originSetter) {
        originSetter.call(this, value)
        console.log(`${name} will spent ${value} per square meters to buy the house`)
      }
    }
  }
}

const house = new House(100, 100)

house.price = 200000 // 王老五 will spent 200000 per square meters to buy the house

属性装饰器

不同于方法装饰器, 属性装饰器只有两个参数: 类的类原型对象、属性名称, 因为在类定义阶段, 还无法确定属性的值, 所以, 我们能做的, 也就只是为一个类的原型添加/修改属性了

function PropDeco (value:any):PropertyDecorator {
  return (prototype: any, propName: string|symbol) => {
    prototype[propName] = value
    prototype.desc = `我的名字叫${value}`
  }
}

class Info {
  @PropDeco('大名')
  name:string|undefined
  desc: any
}

const info = new Info()

console.log(info.name, 'info.name') // 大名
console.log(info.desc, 'info.desc') // 我的名字叫大名

参数装饰器

参数装饰器接受三个参数: 类型原型对象、方法的名称、被修饰参数的索引值

function ParamsDeco(): ParameterDecorator {
  return (prototype: any, methodName: string | symbol, index: number) => {
    console.log(prototype, methodName, index);
  };
}

class Info {
  getData(@ParamsDeco() id: number, name: string) {}
}

new Info(); // {} getData 0

装饰器执行顺序

那么, 这几种装饰器的执行顺序是怎样的呢?

不同装饰器执行顺序

我们要先明白一个点: 即使不对被修饰的class进行实例化或者其他操作, 装饰器也会执行, 或者说, 你的class啥也不做, 光定义了一个类, 只要上面有装饰器, 那么, 这些装饰器其实就已经执行了, 因为装饰器本就是一个方法, 它是来修饰或者说辅助class的, 但是class何时实例化与其并无关系, 相当于就是各干各的

function ParamsDeco(): ParameterDecorator {
  console.log('ParaDeco执行');
  return (prototype: any, methodName: string | symbol, index: number) => {
    console.log('ParaDeco应用');
  };
}
function Cls() {
  console.log('Cls执行');
  return (target: any) => {
    console.log('Cls应用');
  };
}
function Met(): MethodDecorator {
  console.log('Met执行');
  return (target: any, method: string | symbol, descriptor: any) => {
    console.log('Met应用');
  };
}

function Prop(): PropertyDecorator {
  console.log('Prop执行');
  return (target: {}, propName: string | symbol) => {
    console.log('Prop应用');
  };
}
@Cls()
class Info {
  @Prop()
  name: any;
  @Met()
  getData(@ParamsDeco() id: number, name: string) {}
}
/**
 * 
  Prop执行
  Prop应用
  Met执行
  ParaDeco执行
  ParaDeco应用
  Met应用
  Cls执行
  Cls应用
 */

我们将属性和方法调换一下:

@Cls()
class Info {
  @Met()
  getData(@ParamsDeco() id: number, name: string) {}
  @Prop()
  name: any;
}

/**
  Met执行
  ParaDeco执行
  ParaDeco应用
  Met应用
  Prop执行
  Prop应用
  Cls执行
  Cls应用
 */

由此可知, 装饰器的执行顺序上

  1. 方法和属性的执行/应用顺序, 取决于其放置的位置;
  2. 参数装饰器在其所在方法执行后/应用之前被执行并应用;
  3. 类装饰器一定在最后被执行并应用;

相同装饰器执行顺序

前面展示了不同装饰器的执行顺序, 接下来要讨论的, 是相同装饰器的执行顺序

function Met1(): MethodDecorator {
  console.log('Met1执行');
  return (target: any, method: string | symbol, descriptor: any) => {
    console.log('Met1应用');
  };
}
function Met2(): MethodDecorator {
  console.log('Met2执行');
  return (target: any, method: string | symbol, descriptor: any) => {
    console.log('Met2应用');
  };
}
function Met3(): MethodDecorator {
  console.log('Met3执行');
  return (target: any, method: string | symbol, descriptor: any) => {
    console.log('Met3应用');
  };
}

class Info {
  name: any;
  @Met1()
  @Met2()
  @Met3()
  getData(id: number, name: string) {}
}
/**
 * 
  Met1执行
  Met2执行
  Met3执行
  Met3应用
  Met2应用
  Met1应用
 */

可以得出结论, 相同的装饰器, 由上而下执行, 但是, 又是由下而上应用, 即执行其返回的方法

Reflect

在前面的介绍中, 我们会发现, 属性装饰器和参数装饰器似乎有点‘鸡肋’, 比如: 属性装饰器可以获取属性名, 可以获取类原型对象, 那然后呢? 我们就为了打印一下属性名? 这样做显然没有意义, 而我们又拿不到运行时的属性值, 又没办法对属性值做点什么; 这个装饰器似乎真没什么用? 显然不会, 专家们不可能提些毫无作用的提案, 这些装饰器想要发挥更大的作用, 还必须有一个帮手, 那就是反射元数据, 我们先来了解下反射(Reflect)的基本概念

基本概念

Reflect和Proxy一样, 都为操作对象提供了新的API, 但是不同于Proxy, 它的存在不是为了实现代理, 而是有其他几个目的:

  1. 统一API, 现在很多操作对象的API都在Object等内置对象上, 而这些都是语言内部方法, 后续将逐步转移到Reflect对象上, 例如, 原本属于Object的defineProperty, 在Reflect上也有, 且功能完全一致
let obj = {};
Reflect.defineProperty(obj, 'age', { value: 'jack', writable: false });
console.log(obj); // { name: 'jack' }
  1. 其操作更合理, 虽然Object和Reflect上都有相同的方法, 但是, Reflect提供了更加友好的错误处理方式, 前面我们设置了obj的name属性不可更改, 现在我们来更改它试试
// Object.defineProperty只能通过try {} ... catch () {}方式来处理
try {
  Object.defineProperty(obj, 'age', { value: 11 });
} catch (e: any) {
  console.log(e.message); // Cannot redefine property: age
}

// Reflect.defineProperty则会返回一个boolean类型来判断是否设置成功
const isOk = Reflect.defineProperty(obj, 'age', { value: '222' });
if (!isOk) {
  console.log('执行错误');
}

可以看到Reflect.defineProperty返回的是一个boolean类型, 即是否成功, 这样, 更加方便我们处理逻辑;

  1. 将原来Object中的命令式操作转为函数式操作, 例如, 之前判断一个对象中是否有某个属性, 会使用in; 而现在可以直接使用Reflect.has来判断
let obj = { name: 'jack' };

if ('name' in obj) {
  console.log('有名字');
}

const isHas = Reflect.has(obj, 'name');
console.log('isHasName:', isHas); // isHasName: true
  1. 可以很好地配合Proxy来实现一些操作, 因为Reflect上的方法和Proxy上的一一对应, 这样, 我们就可以通过Reflect来实现一个方法备份, 然后在此基础之上, 为原方法增加新的操作;
let obj = { name: '' };
const instance = new Proxy(obj, {
  set(target, name, value, receiver) {
    const isOk = Reflect.set(target, name, value, receiver);
    if (isOk) {
      console.log('设置成功!');
    }
    return isOk;
  },
});

instance.name = 'jack';

在上面的案例中, 我们使用了new Proxy来监听了对象obj的设置行为, 并在Proxy的set中, 使用了Reflect.set来执行原有的设置逻辑, 而在此基础上, 增加了设置成功的日志打印功能;

反射元数据

介绍反射元数据之前, 我们要先了解什么是元数据? 其实所谓的元数据, 就是描述数据的数据, 什么叫描述数据的数据? 为什么要描述数据? 想想刚才说的属性装饰器, 我们目前只能获取属性名和原型对象, 但是我们不知道属性值, 更没办法对属性值进行限制; 那如果我们能在装饰器里'描述'一下这个属性值, 告诉程序, 这个属性值, 在运行的时候, 应该是xxx样子的, 然后程序根据你的描述, 在运行时按照你描述的样子去对比属性值, 并作出对应的操作, 那我们的装饰器是不是瞬间强大了不少, 不再是只会console的无聊玩具了! 好了说完了元数据, 我们回头来理解下, 所谓的反射元数据, 其实就是Reflect上, 专门用于处理元数据的API! 但是很遗憾, 目前为止, 元数据提案还尚未通过, 尽管它提出的很早, 所以, 这里我们就需要使用'reflect-metadata'这个npm包, 先来看看元数据的基本操作

import 'reflect-metadata';
class Foo {
  info: object | undefined;
}

Reflect.defineMetadata('class:key', 'class:value', Foo);
console.log(Reflect.getMetadata('class:key', Foo)); // class:value
Reflect.defineMetadata('info:key', 'info:value', Foo, 'info');
console.log(Reflect.getMetadata('info:key', Foo, 'info')); // info:value

defineMetadata方法接收四个参数: 元数据key、元数据的值、目标对象/类、目标对象/类的属性(可选)

getMetadata方法和defineMetadata相比, 只是缺少了元数据的值这个入參, 毕竟这个方法就是拿来读取原数据的值的;

装饰器应用

前面说了, 元数据正是为了增强装饰器能力而生的, 所以, reflect-metadata也提供了反射元数据的装饰器调用方式

import 'reflect-metadata';

@Reflect.metadata('class-meta-key', 'class-meta-value')
class Foo {
  @Reflect.metadata('prop-meta-key', 'prop-meta-value')
  info: object | undefined;
  @Reflect.metadata('method-meta-key', 'method-meta-value')
  getInfo() {
    return this.info;
  }
}

const foo = new Foo();

console.log(Reflect.getMetadata('class-meta-key', Foo)); // class-meta-value
console.log(Reflect.getMetadata('prop-meta-key', foo, 'info')); // prop-meta-value
console.log(Reflect.getMetadata('method-meta-key', Foo.prototype, 'getInfo')); // method-meta-value

从以上案例可以看出, metadata其实就是defineMetadata, 只不过是以装饰器的方式调用罢了; 我们仍然可以通过getMetadata来获取到其不同位置的元数据; 注意了, 元数据设置的位置取决于额装饰器@Reflect.metadata调用的位置, 如果调用在class之上, 那其元数据就设置在类上; 如果在非静态属性上调用, 那自然就设置在原型对象上, 或者你说实例上也没错; 正如prop-meta-key和method-meta-key所在位置一样

添加参数

前面的装饰器中的元数据入參, 都是我们写死的, 这显然也不符合实际需要, 所以, 我们要让元数据装饰器可以接收到参数, 这样才能灵活处理各种场景, 先以最常见的校验为例, 加入我们要在运行时限定类及类成员的类型, 注意了, 是运行时, 不是开发阶段, 在运行时, 所有类型标注都不存在了!

const expectedType = Symbol('expectedType');

function expectType(Type: 'string' | 'number' | 'boolean'): PropertyDecorator {
  return (target, prop) => {
    Reflect.defineMetadata(expectedType, Type, target, prop);
  };
}

class Student {
  @expectType('string')
  name: any;
  @expectType('number')
  age: any;
  constructor(name: any, age: any) {
    this.name = name;
    this.age = age;
  }
}

function validateType(target: any) {
  const allKeys = Reflect.ownKeys(target);
  for (let key of allKeys) {
    const actualType = typeof target[key];
    const expectType = Reflect.getMetadata(expectedType, target, key);
    if (actualType !== expectType) {
      console.log('类型错误');
    }
  }
}

validateType(new Student('小明', '18岁')); // 类型错误

我们在装饰器中使用元数据定义属性age为number类型, 再在validateType方法中进行了类型校验, 从而实现了在运行时也能校验类型的功能!

除此之外, 还能对类的属性进行必填校验:

import 'reflect-metadata';
const requiredKey = Symbol('requiredKey');
function required(): PropertyDecorator {
  return (target, prop) => {
    const requiredKeys = Reflect.getMetadata(requiredKey, target) ?? [];
    Reflect.defineMetadata(requiredKey, [...requiredKeys, prop], target);
  };
}

class Person {
  @required()
  name: any;
  @required()
  age: any;
  constructor(name?: string, age?: number) {
    this.name = name;
    this.age = age;
  }
}

let person = new Person('jack');

function validate(target: any) {
  // 获取目标对象的所有属性
  const allKeys = Reflect.ownKeys(target);
  const requiredKeys = Reflect.getMetadata(requiredKey, target);
  for (let key of requiredKeys) {
    if (!target[key]) {
      console.log('必填项不得为空');
    }
  }
}
validate(person);

本案例中, name和age都是必填, validate中获取到元数据, 然后进行对比, 从而实现必填项的校验