初识 TS 装饰器

240 阅读7分钟

装饰器是一项实验性特性,在未来的版本中可能会发生改变,可以通过配置开启装饰器功能

// tsconfig.json
{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true
    }
}

类装饰器

@f
@g
class Person {
    // ...
}

只有一个参数,即构造函数,当书写多个装饰器时,执行顺序如下

1、从上到下依次求值

2、求值的结果会被当成函数,从下往上调用

感觉有点像 koa 的洋葱模型,这样会出现一个很有趣的现象,如下所示

@f()
@g()
class Person {}
function f(): ClassDecorator {
  console.log("f outer");
  return function (target) {
    console.log("f inner", target);
  };
}
function g(): ClassDecorator {
  console.log("g outer");
  return function (target) {
    console.log("g inner", target);
  };
}
const p = new Person()

/** 
  * f outer
  * g outer
  * g inner
  * f inner
*/

不同种类的装饰器也存在调用顺序,顺序如下

  1. 参数装饰器,然后依次是方法装饰器访问符装饰器,或属性装饰器应用到每个实例成员。
  2. 参数装饰器,然后依次是方法装饰器访问符装饰器,或属性装饰器应用到每个静态成员。
  3. 参数装饰器应用到构造函数。
  4. 类装饰器应用到类。

接下来是一个修改类的构造函数以及其方法的🌰

interface UserType {
  login(username: string, b: number, c: boolean): void;
}

@classDecorator()
class User implements UserType {
  login(a: string, b: number, c: boolean) {
      console.log("user execute");
  }
}

type ConstructorType<T> = new (...args: any[]) => T
type CustomClassType<T> = (target: ConstructorType<T>) => ConstructorType<T>

function classDecorator(): CustomClassType<UserType> {
  return function (target) {
      return class extends target {
          newProperty = "new property";
          hello = "override";
          login() {
              console.log('chaned')
          }
      };
  };
}
const user = new User();
console.log(user);
user.login('name', 1, true)

方法装饰器

传入的参数如下

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  2. 成员的名字
  3. 成员的属性描述符
interface UserType {
  login(username: string, b: number, c: boolean): void;
}
class User implements UserType {
  @loginDecorator() login(a: string, b: number, c: boolean) {
    console.log("user execute");
  }
  @staticDecorator() static staticMethod() {
    console.log("staticMethod");
  }
}
function loginDecorator(): MethodDecorator {
  return function (target, key, descriptor) {
    console.log("loginDecorator", target, key, descriptor);
  };
}
function staticDecorator(): MethodDecorator {
  return function (target, key, descriptor) {
    console.log("staticDecorator", target, key, descriptor);
  };
}
const user = new User();
user.login("name", 1, true);
User.staticMethod(); 

loginDecorator 和 arrowDecorator 中的 target 都是实例的原型对象,包含 login 方法,目前不会其他的标注方法,先复用类的接口签名

写到这里产生一个疑惑,<T extends UserType> 和直接标注 UserType 有区别吗,答案是有的,根据场景选择使用

T extends UserType

T 可以是 UserType 的子类型,或者是包含 UserType 所有属性的其他类型,编译器可以根据传入的类型来推断 T 的具体类型,从而提供更精确的类型检查

直接标注 UserType

该对象必须完全符合 UserType 的定义,不允许为其子类型,而且编译器不会进行类型推断,因为其类型已经固定

访问器装饰器

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  2. 成员的名字
  3. 成员的属性描述符

如果同时定义属性的 get / set,需要统一写在 get 之前,举个🌰

class Person {
  _count: number;
  constructor(count: number) {
      this._count = count;
  }
  @getDecorator()
  @setDecorator()
  get count(): number {
      return this._count;
  }
  set count(val: number) {
      this._count = val;
  }
}
type AccessorDecorator = (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => void

function getDecorator(): AccessorDecorator {
  return function (
      target,
      key,
      descriptor
  ) {
      const originalGet = descriptor.get;
      descriptor.get = function () {
          return originalGet ? originalGet.call(this) : descriptor.value;
      };
  };
}
function setDecorator(): AccessorDecorator {
  return function (
      target,
      key,
      descriptor
  ) {
      const originSet = descriptor.set;
      descriptor.set = function (val: number) {
          if (originSet) {
              originSet.call(this, val * 2);
          } else {
              (this as InstanceType<typeof Person>)._count = val * 2;
          }
      };
  };
}
const p: InstanceType<typeof Person> = new Person(10);
console.log(p.count);
p.count = 20;
console.log(p.count);

属性装饰器

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

下面使用 reflect-metadata 配合装饰器,实现元数据的挂载,可以看到,在声明装饰器时,传入了一个参数,然后使用 Reflect.metadata 挂载到原型中,当外界读取 formatText 时,调用 getMetadata 函数,从实例中读取对应的 value,虽然元数据定义到了原型中,但是通过原型链的查找,是可以正常工作的

import 'reflect-metadata'
const key = Symbol('text')
class Animal {
  @setMetadata('hello $1')
  text: string;
  constructor(text: string) {
    this.text = text;
  }
  get formatText() {
    const string = getMetadata(this, key);
    return string.replace('$1', this.text)
  }
}
function setMetadata(value: string) {
  return function(target: object, key: string) {
    return Reflect.metadata(key, value, target)
  }
}
function getMetadata(target: object, propertyKey: string | symbol) {
  return Reflect.getMetadata(key, target, propertyKey)
}

元数据挂载的原理,在 target 中,设置一个 [[metadata]] 的属性,该属性的值是一个 WeakMap,其中的 key 就是设置元数据的第一个参数,value 为所设置的 value,所以 key 也可以为除了字符串,Symbol 之外的其他类型,不过为了防止命名污染,还是会使用 Symbol 来当作 key,需要注意,interface、type 等类型声明是无法参与元数据的记录的,因为元数据在运行时也会存在

还有一种简写方式,如下,在原型中添加了一个字符串,其代表了 getRecord 的返回类型,然后读取

// 原生写法
const ReturnTypeMetaKey = Symbol("design:returntype");
class Point {
  private x: number;
  private y: number;
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  @ReturnType("[number, number]")
  getCoord() {
    return [this.x, this.y];
  }
}
function ReturnType(type: string) {
  return Reflect.metadata(ReturnTypeMetaKey, type)
}
const p = new Point(50, 100)
Reflect.getMetadata(ReturnTypeMetaKey, p, 'getRecord')
// "[number, number]"


// 简写,结果不变
const ReturnTypeMetaKey = Symbol("design:returntype");
class Point {
  private x: number;
  private y: number;
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  @Reflect.metadata(ReturnTypeMetaKey, "[number, number]")
  getCoord() {
    return [this.x, this.y];
  }
}
const p = new Point(50, 100)
Reflect.getMetadata(ReturnTypeMetaKey, p, 'getRecord')
// "[number, number]" 

以下是原始的装饰器写法,可以看到,存在两种写法,这两种写法的相同点是,对装饰器求值后,会返回一个函数,并且传入对应的参数,不同点在声明时会有所区分,那么为什么上面的元数据可以简写呢,说明 Reflect.metadata 会返回一个装饰器函数,接收对应的参数,并且在 ts 编译时执行,在运行时就可以读取了

@f
class T {
  @methodDecorator()
  method() {

  }
}
function f(target: object) {
  // target 为类的构造函数
}
function methodDecorator () {
  return function(target: object, key: string, descriptor: PropertyDescriptor) {
    // target 为原型
  }
}

参数装饰器

参数装饰器只有一个作用,就是判断某个参数是否被传入,下面是一个例子,用来检测可选参数是否被传入,并且还使用到了方法装饰器,因为需要对原有的逻辑进行扩展,加入对于函数参数的判断,先在原型上定义一个 key 为 required 的元数据,其值为参数的索引,在本🌰中,index 为 1,然后在 validate 装饰器中,改写其方法,先获取到之前定义的参数索引数组,然后与实参 arguments 进行对比,即可进行判定

const paramsKey = Symbol('required')

class Person {
    name: string;
    constructor(name: string) {
        this.name = name
    }

    @validate()
    logName(position: number, @required() prefix?: string) {
        console.log(prefix + ": " + this.name);
    }
}

// 监视该参数是否被传入
type ParamsDecorator = (target: Object, key: string | symbol, index: number) => void
function required(): ParamsDecorator {
    return function (target, key, index) {
        const parmasIndexArr: number[] = Reflect.getOwnMetadata(paramsKey, target, key) || []
        parmasIndexArr.push(index)
        Reflect.defineMetadata(paramsKey, parmasIndexArr, target, key)
    }
}

type MethodDecoratorType = (target: Object, key: string | symbol, descriptor: PropertyDescriptor) => PropertyDescriptor | void
function validate(): MethodDecoratorType {
    return function (target, key, descriptor) {
        const method = descriptor.value
        descriptor.value = function () {
            const parmasIndexArr = Reflect.getMetadata(paramsKey, target, key)
            if (!parmasIndexArr) return
            for (let index of parmasIndexArr) {
                if (index > arguments.length || arguments[index] === void 0) {
                    throw new Error('need more params')
                }
            }
            return method.apply(this, arguments)
        }
    }
}
const p = new Person('jack')
p.logName(1)

一些自己的理解

我认为所有知识,都有一定的规律、证据,比如某种现象的背后,必然存在必要的原因,有一个疑惑如下

target:对于静态成员来说是类的构造函数,对于实例成员来说是原型对象???为什么

Ts 编译后的代码如下

class Person {
    name: string;
    constructor(name: string) {
        this.name = name
    }

    logName(position: number, prefix?: string) {
        console.log(prefix + ": " + this.name);
    }
    static format() { 

    }
}

// 编译后的
var Person = /** @class */ (function () {
    function Person(name) {
        this.name = name;
    }
    Person.prototype.logName = function (position, prefix) {
        console.log(prefix + ": " + this.name);
    };
    Person.format = function () {
    };
    return Person;
}());

可以看到,类实际上是一个函数,对于 logName,会挂载到原型上,而对于静态方法 format,则会挂载到其构造函数身上,最后将构造函数返回,所以,虽然外层声明的 Person 也可以访问到 format 方法,但是准确来说,外层的类只是存储了指向其构造函数的引用关系

结论: 所以 target 总是指向被装饰者的所属对象,比如类装饰器,其本质上是对类的构造函数服务,比如属性装饰器,通过在原型中定义一些属性,当实例发生访问时,就可以命中原型中的 trigger

元数据介绍