TS装饰器介绍

本文介绍了TypeScript中的装饰器模式,包括装饰器模式的概念、起步、核心玩法,如装饰方法、装饰参数和装饰类的构造器,以及装饰器在依赖注入和约束类的静态方法中的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

装饰器模式简介

装饰器模式是一种重要的设计模式,它能够以对客户透明的方式动态地给一个对象附加上更多的责任,为明晰它的概念,请看下面的例子:

在一个末日生存游戏里,你有一个安全屋和一张床,每次睡觉都可以恢复一定的精力,但是初始的床什么都没有,每天睡在木板上只能恢复20点精力,某天你出外搜寻了材料并制作了一个床垫,将床垫放在床上,每次睡觉可以额外恢复20点精力,之后你又找到了被絮和三件套,甚至还给床进行改装增加了按摩功能,每次睡觉可以额外恢复的精力越来越多了…

在这个例子中,对于使用床的人而言,“睡觉”这个动作没有发生改变,床还是那张床,只不过我们通过床垫、被絮、按摩机增加了额外的功能。在程序设计中,它们就可以分别以装饰器的形式进行设计,再通过组合使床拥有全部的装饰特征,程序中类之间的关系使用uml类图表示如下:

uml

JavaScript的装饰器提案历经一波三折,目前仍处于Stage 2阶段,而且在语法和实现上经历了较大的改版,距离正式成为ECMA语言标准尚需时日。在TypeScript愈发流行的今天,它已推出了这个实验性功能,一些框架如angular、nestjs都已经大量使用了装饰器,本文我们一起探索一下它。

起步

新建一个node项目,并使用tsc工具生成tsconfig.json配置文件,笔者使用的tsc版本为4.0.3

mkdir decorator-tour && cd decorator-tour && npm init -y && tsc --init

为了使Typescript编译器支持装饰器,需要在tsconfig.json的compilerOptions选项中设置"experimentalDecorators": true

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

新建index.ts文件,并输入以下最初的代码:

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  greet(name: string): string {
    console.log(`welcome, ${name}!`);
    return "Hello";
  }
}

const g = new Greeter('msg');

g.greet('tom');

这段代码声明了一个向顾客问好的类GreeterGreeter.greet方法表示“欢迎”之意,实现上就是打印一段欢迎文本出来。
执行tsc && node index.js后控制台将打印出welcome, tom!

装饰器核心玩法

装饰方法

“欢迎”应该配合上“微笑”,下面的需求是在调用greet之前先打印一行"smile"文本,怎么操作呢?当然可以直接在greet方法里加入打印"smile"的代码,但这并不好,试想未来可能实现Greeter.guideGreeter.interpret等函数,它们都需要先打印"smile"然后执行功能,这种情况下Greeter类的很多方法都有相同的需求,就可以将打印"smile"这个功能提取成一个装饰器。

function smile(
  target: Greeter,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  return {
    ...descriptor,
    value: function(name: string) {
      console.log('smile');
      // 调用被装饰的方法
      return descriptor.value(name);
    }
  };
}

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  @smile
  greet(name: string): string {
    console.log(`welcome, ${name}!`);
    return "Hello";
  }
}

const g = new Greeter('msg');

g.greet('tom');
g.greet('tom');
g.greet('tom');

在上面的代码中,smile是一个装饰器,@smile语法规定了greet方法将使用smile装饰器,而装饰器smile本身其实是一个函数,它接收target(被装饰的对象),propertyKey(被装饰的属性名称)和descriptor(该属性的描述)作为参数,本例中target表示Greeter的原型对象,即Greeter.prototype,propertyKey是"greet",而descriptor是Greeter.prototype.greet的属性描述对象,类似下面这样:

{
  value: Function,
  writable: true,
  enumerable: true,
  configurable: true
}

那么在执行过程中是怎么把装饰器和原方法串联起来的呢?我们不妨分析一下经过tsc编译后的代码:

'use strict'
function smile(target, propertyKey, descriptor) {
    return __assign(__assign({}, descriptor), { value: function (name) {
            console.log('smile');
            return descriptor.value(name);
        } });
}
var Greeter = (function () {
  function Greeter(message) {
    this.greeting = message
  }
  Greeter.prototype.greet = function (name) {
    console.log('welcome, ' + name + '!')
    return 'Hello'
  }
  // @smile装饰器被解析成这一行
  __decorate([smile], Greeter.prototype, 'greet', null)
  return Greeter
})()
var g = new Greeter('msg')
g.greet('tom')

首先看到装饰器解析后的语句,@smile装饰器被解析成了__decorate([smile], Greeter.prototype, 'greet', null),下面重点的是__decorate的实现。

var __decorate =
  (this && this.__decorate) ||
  function (decorators, target, key, desc) {
    var c = arguments.length, // 本例中为4
      r = // 本例中为greet方法的属性描述对象
        c < 3
          ? target
          : desc === null
          // *** 逻辑走到这里,desc等于greet方法的属性描述对象
          ? (desc = Object.getOwnPropertyDescriptor(target, key))
          : desc,
      d
    if (typeof Reflect === 'object' && typeof Reflect.decorate === 'function')
      r = Reflect.decorate(decorators, target, key, desc)
    else // *** 逻辑走到这里
      for (var i = decorators.length - 1; i >= 0; i--)
        if ((d = decorators[i]))
          // 这一步执行了smile函数,然后smile函数返回一个新的description
          r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r
          // 重新给 Greeter.prototype.greet 方法赋值
    return c > 3 && r && Object.defineProperty(target, key, r), r
  }

原理其实很清楚,__decorate先获取了greet方法的属性描述对象r,然后执行smile装饰器函数,最后将smile返回的属性描述对象赋值给greet方法;我们注意到smile调用了被装饰的方法,那如果smile函数中不调用被装饰的方法会怎么样呢?比如如下定义smile装饰器,则最终greet方法被覆盖,运行tsc && node index.js将得到hijacked method

function smile(
  target: Greeter,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  return {
    value: function(name: string): string {
      console.log('hijacked method');
      return '';
    },
    writable: true,
    enumerable: true,
    configurable: true
  }
}

上面我们用到的装饰器语法是@smile的形式,实际上装饰器也是可以带参数的,比如现在需求发生了变化,需要根据不同场景确定"smile"的打印次数,greet方法执行的时候需要打印3次smile,打印的次数可以作为参数传递给smile装饰器灵活控制,如何实现呢?熟悉闭包的同学肯定已经想到了。

function smile(times: number) {
  return function(
    target: Greeter,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    return {
      ...descriptor,
      value: function(name: string) {
        for (let i = 0; i < times; i++) {
          console.log('smile');
        }
        return descriptor.value(name);
      }
    }
  }
}

经过上面的改造,smile装饰器需接收1个参数,通过@smile(3)的形式使用装饰器即可在调用greet方法前打印3次"smile"。

装饰参数

不光方法可以被装饰,方法的参数同样也可以,看下面的写法:

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  @smile(3)
  @checkParam
  greet(@startsWith('t') name: string): string {
    console.log(`welcome, ${name}!`);
    return "Hello";
  }
}

例子中的startsWith是一个参数装饰器,它限制greet方法的name参数必须以字符t开头,需要checkParam装饰器配合工作。

在讲解装饰器代码之前,需要先了解一点Reflect-metadata,它是一个ECMA提案,可以在对象和对象的属性上定义元数据,用法类似下面这样:

const o = { a: 1, b: 2 };
Reflect.defineMetadata("meta_key", "meta_value", o, 'a'); // 定义元数据
Reflect.getOwnMetadata("meta_key", o, 'a'); // "meta_value" 获得元数据

目前需要借助reflect-metadata库进行polyfill。

// 命令行安装
// npm install reflect-metadata -S

// index.ts 引入该库
import "reflect-metadata";

接下来看startsWith装饰器的实现:

const startsWithKey = '__startswith';
function startsWith(prefix: string) {
  return function(target: any, // Greeter.prototype
    propertyKey: string, // 'greet'
    paramsIndex: number // 参数的序号,本例中name参数序号为0
  ) {
    const startsWithConstraints = Reflect.getOwnMetadata(
      startsWithKey,
      target,
      propertyKey
    ) || {} as Record<number,string>;
    startsWithConstraints[paramsIndex] = prefix;

    Reflect.defineMetadata(
      startsWithKey,
      startsWithConstraints,
      target,
      propertyKey
    );
  }
}

startsWith返回的装饰器函数有三个参数,分别是target(被装饰的对象),propertyKey(被装饰的属性)和paramsIndex(参数的次序),本例中target表示Greeter的原型对象,即Greeter.prototype,propertyKey是"greet",paramsIndex是name参数的次序0。该函数设置了greet方法的metadata,key为一个常数startsWithKey, value是一个[[参数次序:开头字符]]的映射。到这里我们发现startsWith装饰器只是收集了映射,但是并未进行校验,这是由于参数装饰器并不能得到运行时调用方法的实参,校验操作需要在一个额外的方法装饰器checkParam中进行。

function checkParam(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const method = descriptor.value;
  // 对greet方法进行改写,先进行参数校验
  descriptor.value = function() {
    // 获得startsWith装饰器收集到的[[参数次序:开头字符]]映射
    const startsWithConstraints = Reflect.getOwnMetadata(
      startsWithKey,
      target,
      propertyKey
    ) || {};

    // 对定义了startsWith装饰器的参数进行校验
    Array.prototype.slice.call(arguments).forEach((arg, index) => {
      const prefix = startsWithConstraints[index];
      if (prefix && !arg.startsWith(prefix)) {
        throw new Error(`argument ${index} must start with ${prefix}`);
      }
    })

    return method.apply(this, arguments);
  };
}

checkParam装饰器先通过descriptor.value得到Greeter.prototype.greet方法,然后对这个方法进行改写,增加了参数校验。校验之前从greet方法的metadata中取出了startsWith装饰器收集到的[[参数次序:开头字符]]映射,然后分别对定义了startWith装饰器的参数进行校验。

下面测试一下校验失败的场景:

const g = new Greeter('msg');

g.greet('rtom');

运行程序后命令行会得到一个报错:

throw new Error("argument " + index + " must start with " + prefix);
                ^
                
Error: argument 0 must start with t
...

装饰类的构造器

类似方法装饰器,类的构造器也可以被装饰,写法是直接在class关键字上方添加装饰器代码,装饰器的实现比较简单,只有一个参数target,指代的是构造器方法本身。

function activate(target: Greeter) {
  target.active = true; // target指代Greeter函数
}

@activate
class Greeter {
  static active = false;
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  greet(name: string): string {
    console.log(`welcome, ${name}!`);
    return "Hello";
  }
}

console.log(Greeter.active); // true

在上方的例子中,activate装饰器的target参数指代Greeter构造器,该装饰器修改了Greeter类的static属性active的值。我们并未实例化这个类,但装饰器代码已经发挥了作用。

应用

至此我们对Typescript的装饰器有了一定的了解,通过使用装饰器可以定制类构造器、方法,甚至方法参数的行为。下面我们举两个例子实战一下。

依赖注入

首先聊聊什么是依赖注入,vue中就有这个概念,provide/inject是解决组件之间的通信问题的利器,不受层级结构的限制。其核心思想是外层组件通过provide选项声明可同享的属性,内层组件通过inject选项指定待注入的属性,这样外层组件的属性值就可以同步到内层组件了。我们大致可以这样理解依赖注入的步骤:首先收集需要共享的数据,然后标记需要使用这些数据的对象,最后从共享数据中挑选出该对象需要的数据交给它。

接下来我们模仿nest.js的做法实现一个简单的依赖注入。

class FlowerService {
  strew () {
    console.log('strew flower');
  }
}

class Greeter {
  constructor(
    private readonly flower: FlowerService
  ) {
  }
  greet(name: string): string {
    console.log(`welcome, ${name}!`);
    this.flower.strew();
    return "Hello";
  }
}

// 期待的操作是 const g = create(Greeter);
const g = new Greeter(new FlowerService());
g.greet('tom');

这是一段给"欢迎"方法添加"撒花"动作的代码,"撒花"作为一个类FlowerService独立出来。现在创建Greeter对象的时候是主动将FlowerService实例化并传入,我们期待的是将FlowerService作为依赖,通过某种方式注入到Greeter实例中,下面是具体实现步骤:

首先实现provide,这里直接对FlowerService类进行装饰,将它的构造器放到一个weakmap中存起来备用。

const providerMap = new WeakMap();

// ----- Provider -----
function provider(target: any) {
  providerMap.set(target, null);
}

@provider
class FlowerService {
  strew () {
    console.log('strew flower');
  }
}

接下来是一个关键的问题,程序怎么知道Greeter类需要FlowerService这个依赖呢?我们发现Greeter构造函数的形参flower就是FlowerService的实例,那程序有办法拿到构造函数的入参类型吗?这需要在tsconfig.json的compilerOptions配置中开启一个额外的选项:

// tsconfig.json
{
  "compilerOptions": {
    ...
    "emitDecoratorMetadata": true,
    ...
  }
}

然后我们给Greeter添加一个装饰器inject,这个装饰器可以什么也不做。

function inject(target: any) {
  // do nothing
}

@inject
class Greeter {
  constructor(
    private readonly flower: FlowerService
  ) {
  }
  greet(name: string): string {
    ...
  }
}

下面是tsc编译得到的js代码,Greeter被添加了两个装饰器,一个是我们自己定义的inject,另外一个调用了__metadata函数,该函数通过Reflect.metadata返回一个装饰器,该装饰器设置了Greeter的metadata,操作类似:Reflect.defineMetadata("design:paramtypes", [FlowerService], Greeter); // 定义元数据,我们发现Greeter构造器的参数类型就以metadata的形式被保存到design:paramtypes这个key中了。

var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
...
function inject(target) {
}
var Greeter = (function () {
    function Greeter(flower) {
        this.flower = flower;
    }
    Greeter.prototype.greet = function (name) {
        ...
    };
    Greeter = __decorate([
        inject,
        // __metadata函数返回一个装饰器,该装饰器设置了Greeter的metadata
        __metadata("design:paramtypes", [FlowerService])
    ], Greeter);
    return Greeter;
}());

最后一步是创建对象,我们定义一个函数create(target),首先通过Reflect.getOwnMetadata("design:paramtypes", target)获取目标构造器target的入参类型,然后在providerMap中查找是否存在该入参类型,如果有则说明该类型是一个provider,将该类型实例化后传给目标构造器target,最后创建target实例并返回。值得注意的是,provider可能自身也依赖其他provider,故需要处理一下依赖的递归收集。

function create(target: any) {
  // 获取函数的入参类型
  const paramTypes = Reflect.getOwnMetadata(
    "design:paramtypes",
    target
  ) || [];
  
  const deps = paramTypes.map((type: any) => {
    const instance = providerMap.get(type);
    if (instance === null) {
      // 递归收集依赖
      providerMap.set(type, create(type));
    }
    return providerMap.get(type);
  })
  return new target(...deps);
}

这样简单的依赖注入就实现了,下面是完整代码。

import "reflect-metadata";

const providerMap = new WeakMap();

// ----- Provider -----
function provider(target: any) {
  providerMap.set(target, null);
}

@provider
class FlowerService {
  strew () {
    console.log('strew flower');
  }
}

// ----- Inject -----
function create(target: any) {
  // 获取函数的入参类型
  const paramTypes = Reflect.getOwnMetadata(
    "design:paramtypes",
    target
  ) || [];
  
  const deps = paramTypes.map((type: any) => {
    const instance = providerMap.get(type);
    if (instance === null) {
      // 递归收集依赖
      providerMap.set(type, create(type));
    }
    return providerMap.get(type);
  })
  return new target(...deps);
}

// 必须要inject一下,ts解析出构造器的入参类型
function inject(target: any) {}

@inject
class Greeter {
  constructor(
    private readonly flower: FlowerService
  ) {
  }
  greet(name: string): string {
    console.log(`welcome, ${name}!`);
    this.flower.strew();
    return "Hello";
  }
}

const g = create(Greeter);
g.greet('tom');

// 命令行输出
// welcome, tom!
// strew flower

约束类的静态方法

要让Foo类实现Bar接口,我们通常这样写class Foo implements Bar,Bar接口里面约束了实例的属性和方法,如:

interface Bar {
  work: () => void
}
class Foo implements Bar {
  work() {
    // do something
  }
}

但约束Foo的静态属性要怎么做呢?首先interface不支持添加static关键字,下面这种写法是不被允许的:

interface Bar {
  static life: number;
  work: () => void;
}

我们知道static属性其实最终是添加在构造函数上的,改成下面这种写法才可行:

interface Bar {
  work: () => void
}
interface StaticBar {
  life: number;
}
const Foo: StaticBar = class implements Bar {
  static life: number;
  work() {
    // do something
  }
}

但是这种方式改变了class声明的写法,感觉不是很优雅,下面是使用装饰器的写法:

interface Bar {
  work: () => void
}

type WithStatic<T, U> = {
  new(): T;
} & U;

type BarWithStatic = WithStatic<Bar, { life: number }>;

// 通过装饰器重写了构造函数的类型
function staticImplements<T>() {
  return <U extends T>(constructor: U) => {};
}

@staticImplements<BarWithStatic>()
class Foo {
  static life: number;
  work() {
    // do something
  }
}

这里的装饰器staticImplements没有做任何逻辑上的操作,它只是声明了构造函数的类型,这样静态属性自然就具备了类型声明。

参考

  1. 官方文档
  2. 如何用 Decorator 装饰你的 Typescript?
  3. How to define static property in TypeScript interface
  4. A practical guide to TypeScript decorators
  5. decorator与依赖注入
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值