TypeScript 装饰器(Decorator)

装饰器是一种特殊类型的声明,它能够附加到类声明、方法、访问符、属性、类方法的参数上,以达到扩展类的行为。

自从 ES2015 引入 class,当我们需要在多个不同的类之间共享或者扩展一些方法或行为的时候,代码会变得错综复杂,极其不优雅,这也是装饰器被提出的一个很重要的原因。

1. 慕课解释

常见的装饰器有:类装饰器、属性装饰器、方法装饰器、参数装饰器。

装饰器的写法:普通装饰器(无法传参)、 装饰器工厂(可传参)。

装饰器是一项实验性特性,在未来的版本中可能会发生改变。

若要启用实验性的装饰器特性,你必须在命令行或 tsconfig.json 里启用 experimentalDecorators 编译器选项:

命令行:

tsc --target ES5 --experimentalDecorators

tsconfig.json:

{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true
    }
}                                                                          

2. 装饰器的使用方法

装饰器允许你在类和方法定义的时候去注释或者修改它。装饰器是一个作用于函数的表达式,它接收三个参数 targetnamedescriptor ,然后可选性的返回被装饰之后的 descriptor 对象。

装饰器使用 @expression 这种语法糖形式,expression 表达式求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。

2.1 装饰器工厂

装饰器工厂就是一个简单的函数,它返回一个表达式,以供装饰器在运行时调用。

通过装饰器工厂方法,可以额外传参,普通装饰器无法传参

function log(param: string) {
  return function (target: any, name: string, descriptor: PropertyDescriptor) {
    console.log('target:', target)
    console.log('name:', name)
    console.log('descriptor:', descriptor)

    console.log('param:', param)
  }
}

class Employee {

  @log('with param')
  routine() {
    console.log('Daily routine')
  }
}

const e = new Employee()
e.routine()

代码解释:

第 1 行,声明的 log() 函数就是一个装饰器函数,通过装饰器工厂这种写法,可以接收参数。

来看代码的打印结果:

target: Employee { routine: [Function] }
name: routine
descriptor: {
  value: [Function],
  writable: true,
  enumerable: true,
  configurable: true
}
param: with param
Daily routine

可以看到,先执行装饰器函数,然后执行 routine() 函数。至于类属性装饰器函数表达式的三个参数 targetnamedescriptor 之后会单独介绍。

2.2 装饰器组合

多个装饰器可以同时应用到一个声明上,就像下面的示例:

  • 书写在同一行上:
@f @g x
  • 书写在多行上:
@f
@g
x

在 TypeScript 里,当多个装饰器应用在一个声明上时会进行如下步骤的操作:

  1. 由上至下依次对装饰器表达式求值
  2. 求值的结果会被当作函数,由下至上依次调用

通过下面的例子来观察它们求值的顺序:

function f() {
  console.log('f(): evaluated');
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('f(): called');
  }
}

function g() {
  console.log('g(): evaluated');
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('g(): called');
  }
}

class C {
  @f()
  @g()
  method() {}
}

在控制台里会打印出如下结果:

f(): evaluated
g(): evaluated
g(): called
f(): called

3. 类装饰器

类装饰器表达式会在运行时当作函数被调用,类的构造函数作为其唯一的参数。

通过类装饰器扩展类的属性和方法:

function extension<T extends { new(...args:any[]): {} }>(constructor: T) {
  // 重载构造函数
  return class extends constructor {
    // 扩展属性
    public coreHour = '10:00-15:00'
    // 函数重载
    meeting() {
      console.log('重载:Daily meeting!')
    }
  }
}

@extension
class Employee {
  public name!: string
  public department!: string

  constructor(name: string, department: string) {
    this.name = name
    this.department = department
  }

  meeting() {
    console.log('Every Monday!')
  }

}

let e = new Employee('Tom', 'IT')
console.log(e) // Employee { name: 'Tom', department: 'IT', coreHour: '10:00-15:00' }
e.meeting()    // 重载:Daily meeting!

函数表达式的写法:

const extension = (constructor: Function) => {
  constructor.prototype.coreHour = '10:00-15:00'

  constructor.prototype.meeting = () => {
    console.log('重载:Daily meeting!');
  }
}

@extension
class Employee {
  public name!: string
  public department!: string

  constructor(name: string, department: string) {
    this.name = name
    this.department = department
  }

  meeting() {
    console.log('Every Monday!')
  }

}

let e: any = new Employee('Tom', 'IT')
console.log(e.coreHour) // 10:00-15:00
e.meeting()             // 重载:Daily meeting!

代码解释:

以上两种写法,其实本质是相同的,类装饰器函数表达式将构造函数作为唯一的参数,主要用于扩展类的属性和方法。

4. 作用于类属性的装饰器

作用于类属性的装饰器表达式会在运行时当作函数被调用,传入下列3个参数 targetnamedescriptor

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

如果你熟悉 Object.defineProperty,你会立刻发现这正是 Object.defineProperty 的三个参数。

比如通过修饰器完成一个属性只读功能,其实就是修改数据描述符中的 writable 的值 :

function readonly(value: boolean) {
  return function (target: any, name: string, descriptor: PropertyDescriptor) {
    descriptor.writable = value
  }
}

class Employee {
  @readonly(false)
  salary() {
    console.log('这是个秘密')
  }
}

const e = new Employee()
e.salary = () => { // Error,不可写
  console.log('change')
}
e.salary()

解释: 因为 readonly 装饰器将数据描述符中的 writable 改为不可写,所以倒数第三行报错。

5. 方法参数装饰器

参数装饰器表达式会在运行时当作函数被调用,以使用参数装饰器为类的原型上附加一些元数据,传入下列3个参数 targetnameindex

  1. target: 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  2. name: 成员的名字
  3. index: 参数在函数参数列表中的索引

注意第三个参数的不同。

function log(param: string) {
  console.log(param)

  return function (target: any, name: string, index: number) {
    console.log(index)
  }
}

class Employee {

  salary(@log('IT') department: string, @log('John') name: string) {
    console.log('这是个秘密')
  }

}

可以用参数装饰器来监控一个方法的参数是否被传入。

6. 装饰器执行顺序

function extension(params: string) {
  return function (target: any) {
    console.log('类装饰器')
  }
}

function method(params: string) {
  return function (target: any, name: string, descriptor: PropertyDescriptor) {
    console.log('方法装饰器')
  }
}

function attribute(params: string) {
  return function (target: any, name: string) {
    console.log('属性装饰器')
  }
}

function argument(params: string) {
  return function (target: any, name: string, index: number) {
    console.log('参数装饰器', index)
  }
}

@extension('类装饰器')
class Employee{
  @attribute('属性装饰器')
  public name!: string

  @method('方法装饰器')
  salary(@argument('参数装饰器') name: string, @argument('参数装饰器') department: string) {}
}

查看运行结果:

属性装饰器
参数装饰器 1
参数装饰器 0
方法装饰器
类装饰器

7. 小结

虽然装饰器还在草案阶段,但借助 TypeScript 与 Babel(需安装 babel-plugin-transform-decorators-legacy 插件) 这样的工具已经被应用于很多基础库中,当需要在多个不同的类之间共享或者扩展一些方法或行为时,可以使用装饰器简化代码。