一文速览 TypeScript 装饰器 与 IoC 机制

共 14637字,需浏览 30分钟

 ·

2021-10-09 20:39

前言

本篇文章适用于对 TypeScript 装饰器缺少使用经验或只是浅尝辄止过的同学,我将从 TypeScript 装饰器的诞生背景开始,介绍不同种类装饰器的使用场景和功能,再到 元数据反射 与 IoC 机制。相信读完本文后,在以后使用 TypeScript 装饰器时,你会多一份踏实:现在你清清楚楚得知道它们的运作流程了!

TypeScript装饰器简介

首先,装饰器是什么?简单的说,装饰器是一种应用在类及其内部成员的语法,它的本质其实就是函数。我对这一语法抱有额外的热情,则是因为它能很好地隐藏掉许多功能的实现细节。如:

@InternalChanges()
class Foo { }

@InternalChanges() 这个装饰器中,我们甚至能够完全修改掉这个类的功能行为,而只需要这唯一的一行代码。你可能会觉得这使得内部实现过于黑盒,但仔细想想,可复用的装饰器实际就相当于 utils 方法,在提供给外部使用时,我们本就不希望使用者需要关心内部的逻辑。

而装饰器的另外一个功能要使用的更加广泛,也更加符合我上面所说的“我就希望它是黑盒的”,那就是元数据(元编程)相关,这一点我们会在后面详细展开。

其次我们需要知道,JavaScript 与 TypeScript 中的装饰器完全不是一回事,JS中的装饰器目前依然停留在 stage 2 阶段,并且目前版本的草案与TS中的实现差异相当之大(TS是基于第一版,JS目前已经第三版了),所以二者最终的装饰器实现必然有非常大的差异。

如果你曾使用过 TypeScript 装饰器,不妨看看下面这张图展示的当前 JavaScript 装饰器使用方式,就能感觉出现在二者的实现差异之大了:

js-decorator

严格的来说,装饰器不是 TypeScript 所提供的特性(如类型别名与接口等),而是其实现的 ECMAScript提案(就像类的私有成员一样)。

TS实际上只会对 stage-3 以上的提案提供支持,比如 TS3.7版本 引入了可选链(Optional chaining)与空值合并(Nullish-Coalescing),我想这两个语法目前应该非常多的同学已经重度使用了。而当 TypeScript 开始引入装饰器支持 时(大约在15年左右,最先引入的 TypeScript 版本是 1.5 版本),ECMAScript 中的装饰器依然处于 stage-1 阶段。其原因是 TypeScript 与 Angular 团队达成了合作,Ng 团队不再维护 AtScript,而 TypeScript 引入了注解语法(Annotation)及相关特性。

AtScript 最初构建于 TypeScript 之上,但是又引入了一部分来自于 Dart 的语言特性。同时 Angular 2.0 也是基于 AtScript 而开发的。同样是在 TypeScript 1.5 版本,TypeScript 团队宣布许多 AtScript 的特性将被实现在 1.5 版本中,而 Angular 2.0 也将直接基于 TypeScript。

为什么叫 AtScript ?因为 Angular 中重度使用了装饰器,at即代表了 @ 这一语法。

但是并不需要担心,即使装饰器永远到达不了stage-3/4 阶段,它也不会消失的(更何况现在提案中的装饰器和 TypeScript 装饰器也不是一个东西了)。有相当多的框架都是装饰器的重度用户,如AngularNestMidway等。对于装饰器的内部实现与编译结果会始终保留(但不能确定的是,在 JavaScript 装饰器成功进入最终阶段后是否会发生变化),就像JSX一样。

如果你对它的历史与发展方向有兴趣,可以读一读 是否应该在production里使用typescript的decorator?(贺师俊贺老的回答)

为什么我们需要装饰器?在后面的例子中我们会体会到装饰器的强大与魅力,基于装饰器我们能够快速优雅的复用逻辑,对业务代码进行能力增强。同时我们本文的重点:依赖注入也将使用装饰器的元数据反射能力来实现。

装饰器与注解

由于我本身并没学习过 Java 以及Spring IoC,因此我的理解可能存在一些偏差,还请在评论区指出错误之处~

装饰器与注解实际上也有一定区别,由于并没有学过Java,这里就不与Java中的注解进行比较了。而只是说我所认为的二者差异:

  • 注解 应该如同字面意义一样, 只是为某个被注解的对象提供元数据(metadata)的注入,本质上不能起到任何修改行为的操作,需要额外的scanner去进行扫描获得元数据并基于其去执行操作,注解的元数据才有实际意义。
  • 单纯的装饰器 没法添加元数据,只能基于已经由注解注入的元数据来执行操作,来对类以及内部成员如方法、属性、方法参数进行某种特定的操作。

但实际上,TypeScript中的装饰器通常是同时包含了这两种效能的,它在消费元数据的同时,也能够提供元数据供别的装饰器消费(通过装饰器的先后执行顺序)。

不同类型的装饰器及使用

如果要在本地运行示例代码,你需要确保在tsconfig.json中启用了experimentalDecoratorsemitDecoratorMetadata

类装饰器

function addProp(constructor: Function{
  constructor.prototype.job = 'fe';
}

@addProp
class P {
  job: string;
  constructor(public name: string) {}
}

let p = new P('林不渡');

console.log(p.job); // fe
复制代码

我们发现,在以单纯装饰器方式 @addProp 调用时,不管用它来装饰哪个类,起到的作用都是相同的,即修改类上的属性。因为这里装饰器的逻辑是固定的。这样肯定不是我们为想要的,起码得支持调用时传入不同的参数来将属性修改为不同的值吧?

试试以 @addProp() 的方式来调用:

function addProp(param: string): ClassDecorator {
  return (constructor: Function) => {
    constructor.prototype.job = param;
  };
}

@addProp('fe+be')
class P {
  job: string;
  constructor(public name: string) {}
}

let p = new P('林不渡');

console.log(p.job); // fe+be

首先要明确地是,TS中的装饰器实现本质是一个语法糖,它的本质是一个函数,如果调用形式为@deco()(即上面的例子),那么这个函数应该再返回一个函数来实现调用,所以 addProp 方法再次返回了一个 ClassDecorator。应用在不同位置的装饰器将接受的参数是不同的,如这里的类装饰器接受的参数将是类的构造函数

其次,你应该明白ES6中class的实质,如果现在暂时不明白,推荐先阅读我的这篇一点都没技术含量的技术文章: 从 Babel 编译结果看 ES6 的 Class 实质。

现在我们想要添加的属性值就可以由我们决定了, 实际上由于我们拿到了原型对象,还可以进行更多操作,解锁更多神秘姿势。

方法装饰器

方法装饰器的入参为 类的原型对象  属性名 以及属性描述符(descriptor),其属性描述符包含writable enumerable configurable ,我们可以在这里去配置其相关信息,如禁止这个方法再次被修改。

注意,对于静态成员来说,首个参数会是类的构造函数。而对于实例成员(比如下面的例子),则是类的原型对象。

function addProps(): MethodDecorator {
  return (target, propertyKey, descriptor) => {
    descriptor.writable = false;
  };
}

class A {
  @addProps()
  originMethod() {
    console.log("I'm Original!");
  }
}

const a = new A();

a.originMethod = () => {
  console.log("I'm Changed!");
};

// 仍然是原来的方法
a.originMethod(); // I'm Original! 

你是否有点想起来Object.defineProperty()?的确方法装饰器也是借助它来修改类和方法的属性的,你可以在 TypeScript Playground 中看看 TypeScript 对上面代码的编译结果。

属性装饰器

类似于方法装饰器,但它的入参少了属性描述符。原因则是目前没有方法在定义原型对象成员的同时,去描述一个实例的属性(创建描述符)。

function addProps(): PropertyDecorator {
  return (target, propertyKey) => {
    console.log(target);
    console.log(propertyKey);
  };
}

class A {
  @addProps()
  originProps: unknown;
}

属性与方法装饰器有一个重要作用是注入与提取元数据,这点我们在后面会体现到。

参数装饰器

参数装饰器的入参首要两位与属性装饰器相同,第三个参数则是参数在当前函数参数中的索引

function paramDeco(params?: any): ParameterDecorator {
  return (target, propertyKey, index) => {
    target.constructor.prototype.fromParamDeco = 'Foo';
  };
}

class B {
  someMethod(@paramDeco() param1: unknown, @paramDeco() param2: unknown) {
    console.log(`${param1}  ${param2}`);
  }
}

// "A B"
new B().someMethod('A''B');
// Foo
// @ts-ignore
console.log(B.prototype.fromParamDeco);

参数装饰器与属性装饰器都有个特别之处,他们都不能获取到描述符descriptor,因此也就不能去修改其参数/属性的行为。但是我们可以这么做:给类原型添加某个属性,携带上与参数/属性/装饰器相关的元数据,并由下一个执行的装饰器来读取。(装饰器的执行顺序请参见下一节)。

当然像例子中这样直接在原型上添加属性的方式是十分不推荐的,后面我们会使用 ES7 中的 Reflect Metadata 来进行元数据的读/写。

装饰器工厂

假设现在我们同时需要四种功能相近的装饰器,你会怎么做?定义四种装饰器然后分别使用吗?也行,但后续你看着这一堆装饰器可能会感觉有点头疼...,因此我们可以考虑接入工厂模式,使用一个装饰器工厂来为我们根据条件生成不同的装饰器。

首先我们准备好各个装饰器函数:

function classDeco(): ClassDecorator {
    return (target: Object) => {
        console.log('Class Decorator Invoked');
        console.log(target);
    };
}

function propDeco(): PropertyDecorator {
    return (target: Object, propertyKey: string | symbol) => {
        console.log('Property Decorator Invoked');
        console.log(propertyKey);
    };
}

function methodDeco(): MethodDecorator {
    return (
        target: Object,
        propertyKey: string | symbol,
        descriptor: PropertyDescriptor
    ) => {
        console.log('Method Decorator Invoked');
        console.log(propertyKey);
    };
}

function paramDeco(): ParameterDecorator {
    return (target: Object, propertyKey: string | symbol, index: number) => {
        console.log('Param Decorator Invoked');
        console.log(propertyKey);
        console.log(index);
    };
}

接着,我们实现一个工厂函数来根据不同条件返回不同的装饰器:

enum DecoratorType {
  CLASS = 'CLASS',
  METHOD = 'METHOD',
  PROPERTY = 'PROPERTY',
  PARAM = 'PARAM',
}

type FactoryReturnType =
  | ClassDecorator
  | MethodDecorator
  | PropertyDecorator
  | ParameterDecorator;

function decoFactory(
  thisany,
  type: DecoratorType.CLASS,
  ...args: any[]
): ClassDecorator
;

function decoFactory(
  thisany,
  type: DecoratorType.METHOD,
  ...args: any[]
): MethodDecorator
;

function decoFactory(
  thisany,
  type: DecoratorType.PROPERTY,
  ...args: any[]
): PropertyDecorator
;

function decoFactory(
  thisany,
  type: DecoratorType.PARAM,
  ...args: any[]
): ParameterDecorator
;

function decoFactory(
  thisany,
  type: DecoratorType,
  ...args: any[]
): FactoryReturnType 
{
  switch (type) {
    case DecoratorType.CLASS:
      return classDeco.apply(this, args);

    case DecoratorType.METHOD:
      return methodDeco.apply(this, args);

    case DecoratorType.PROPERTY:
      return propDeco.apply(this, args);

    case DecoratorType.PARAM:
      return paramDeco.apply(this, args);

    default:
      throw new Error('Invalid DecoratorType');
  }
}

@decoFactory(DecoratorType.CLASS)
class C {
  @decoFactory(DecoratorType.PROPERTY)
  prop: unknown;

  @decoFactory(DecoratorType.METHOD)
  method(@decoFactory(DecoratorType.PARAM) param: string) {}
}

new C().method('foobar');

以上是一种方式,你也可以通过判断传入的参数,来判断当前的装饰器被应用在哪个位置。

多个装饰器声明的执行顺序

类中不同声明上的装饰器将按以下规定的顺序应用:

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

注意这个顺序,后面我们能够实现元数据读写,也正是因为这个顺序。

当存在多个装饰器来装饰同一个声明时,则会有以下的顺序:

  • 首先,由上至下依次对装饰器表达式求值,得到返回的真实函数(如果有的话)。
  • 而后,求值的结果会由下至上依次调用。

这个执行顺序有点像洋葱模型对吧?

function foo({
    console.log("foo in");
    return function (target, propertyKey: string, descriptor: PropertyDescriptor{
        console.log("foo out");
    }
}

function bar({
    console.log("bar in");
    return function (target, propertyKey: string, descriptor: PropertyDescriptor{
        console.log("bar out");
    }
}

class A {
    @foo()
    @bar()
    method() {}
}

// foo in
// bar in
// bar out
// foo out

Reflect Metadata

基本元数据读写

Reflect Metadata是属于 ES7 的一个提案,其主要作用是在声明时去读写元数据。TS早在1.5+版本就已经支持反射元数据的使用,目前想要使用,我们还需要安装 reflect-metadata ,且在 tsconfig.json中启用 emitDecoratorMetadata 选项。

你可以将元数据理解为用于描述数据的数据,如某个对象的键、键值、类型等等就可称之为该对象的元数据。做一个简单的阐述:

为类或类属性添加了元数据后,构造函数的原型(或是构造函数,根据静态成员还是实例成员决定)会具有[[Metadata]]属性,该属性内部包含一个Map结构,键为属性键,值为元数据键值对

reflect-metadata提供了对 Reflect 对象的扩展,在引入后,我们可以直接从 Reflect对象上获取扩展方法,并将其作为装饰器使用:

文档见 reflect-metadata,但不用急着看,其API命令还是很语义化的。

import 'reflect-metadata';

@Reflect.metadata('className''D')
class D {
  @Reflect.metadata('methodName''hello')
  public hello(): string {
    return 'hello world';
  }
}

const d = new D();
console.log(Reflect.getMetadata('className', D));
console.log(Reflect.getMetadata('methodName', d));

可以看到,我们给类 D 与 D 内部的方法hello都注入了元数据,并通过getMetadata(metadataKey, target)这个方式取出了存放的元数据。

Reflect-metadata支持 命令式(Reflect.defineMetadata) 与声明式(上面的装饰器方式)的元数据定义

我们注意到,注入在类上的元数据在取出时 target 为这个类D,而注入在方法上的元数据在取出时 target 则为实例d。原因其实我们实际上在上面的装饰器执行顺序提到了,这是由于注入在方法、属性、参数上的元数据实际上是被添加在了实例对应的位置上,因此需要实例化才能取出。

内置元数据

Reflect允许程序去检视自身,基于这个效果,我们可以在装饰器运行时去检查其类型相关信息,如目标类型、目标参数的类型以及方法返回值的类型,这需要借助 TypeScript 内置的元数据metadataKey来实现,以一个检查入参的例子为例:

访问符装饰器的属性描述符参数将会额外拥有getset方法,其他与属性装饰器相同

import 'reflect-metadata';

class Point {
  x: number;
  y: number;
}

class Line {
  private _p0: Point;
  private _p1: Point;

  @validate
  set p0(value: Point) {
    this._p0 = value;
  }
  get p0() {
    return this._p0;
  }

  @validate
  set p1(value: Point) {
    this._p1 = value;
  }
  get p1() {
    return this._p1;
  }
}

function validate<T>(
  target: any,
  propertyKey: string,
  descriptor: TypedPropertyDescriptor
{
  let set = descriptor.set!;
  descriptor.set = function (value: T{
    let type = Reflect.getMetadata('design:type', target, propertyKey);
    if (!(value instanceof type)) {
      throw new TypeError('Invalid type.');
    }
    set(value);
  };
}

const line = new Line();
// Error!
// @ts-ignore
line.p0 = {
  x: 1,
};

在这个例子中,我们基于 Reflect.getMetadata('design:type', target, propertyKey) 获取到了装饰器对应声明的属性类型,并确保在 setter被调用时检查值类型。

这里的 design:type 即是 TS 的内置元数据key,也即是说 TS 在编译前还手动执行了@Reflect.metadata("design:type", Point)。除了 design:key 以外,TS还内置了**design:paramtypes(获取目标参数类型)design:returntype(获取方法返回值类型)**这两种元数据字段来提供帮助。但有一点需要注意,即使对于基本类型,这些元数据也返回对应的包装类型,如number -> [Function: Number]

IoC

概念介绍:IoC、依赖注入、容器

IoC的全称为 Inversion of Control,意为控制反转,它是OOP中的一种设计原则,常用于解耦代码。

直接说概念多没意思,让我们来想象这样一个场景:

有这么一个类 C,它的代码内部使用到了另外两个类 A、B,需要去分别实例化它们。在不使用 IoC 的情况下,我们很容易写出来这样的代码:

import { A } from './modA';
import { B } from './modB';

class C {
  constructor() {
    this.a = new A();
    this.b = new B();
  }
}

乍一看可能没什么,但实际上类 C 会强依赖于A、B,造成模块之间的耦合。如果后续 A、B 的实例化参数变化,或者是 A、B 内部又依赖了别的类,那么维护起来简直是一团乱麻。

要解决这个问题,我们可以这么做:

  • C 的内部代码只需要定义一个 类型为 A、B的成员。
  • 用一个第三方容器来管理这些作为依赖的类
  • 当实例化 C 时,由容器负责实例化 A、B,并注入到对应的属性上

以 Injection 为例:

import { Container } from 'injection';
import { A } from './A';
import { B } from './B';
const container = new Container();
container.bind(A);
container.bind(B);

class C {
  constructor() {
    this.a = container.get('a');
    this.b = container.get('b');
  }
}

现在A、B、C之间没有了耦合,甚至当某个类 D 需要使用 C 的实例时,我们也可以把 C 交给IoC容器,它会帮我们照看好的。

我们现在能够知道 IoC 容器大概的作用了:容器内部维护着一个对象池,管理着各个对象实例,当用户需要使用实例时,容器会自动将对象实例化交给用户。

再举个栗子,当我们想要处对象时,会上Soul、Summer、陌陌...等等去一个个找,找哪种的与怎么找是由我自己决定的,这叫 控制正转。现在我觉得有点麻烦,直接把自己的介绍上传到世纪佳缘,如果有人对我感兴趣了,就会主动向我发起聊天,这叫 控制反转

DI的全称为Dependency Injection,即依赖注入。依赖注入是控制反转最常见的一种应用方式,就如它的名字一样,它的思路就是在对象创建时自动注入依赖对象。再以 Injection 的使用为例,这次我们用上装饰器:

// provide意为当前对象需要被绑定到容器中
// inject意为去容器中取出对应的实例注入到当前属性中
@provide()
export class UserService {
 
  @inject()
  userModel;

  async getUser(userId) {
    return await this.userModel.get(userId);
  }
}

我们不需要在构造函数中去手动 this.userModel = xxx 并且考虑需要的传参了,容器会自动帮我们做这一步。

基于 IoC 机制的路由简易实现

如果你用 NestJS、MidwayJS 写过应用,那么你肯定熟悉下面这样的代码:

@provide()
@controller('/user')
export class UserController {

  @get('/all')
  async getUser(): Promise<void> {
    // ...
  }

  @get('/uid/:uid')
  async findUserByUid(): Promise<void> {
    // ...
  }

  @post('/uid/:uid')
  async updateUser(): Promise<void> {
    // ...
  }
}

这种基于装饰器声明路由的方式一直是我的心头好,你可以通过装饰器非常容易的定义路由层面的拦截器与中间件等操作,在 NestJS 中,还存在着 @Pipe @Guard @Catch @UseInterceptors 等非常多细粒度的装饰器用于在控制器或者路由层面进行操作。

可是你想过它们是如何实现的吗?假设我们要解析的路由如下:

@controller('/user')
export class UserController {
  @get('/all')
  async getAllUser(): Promise<void> {
    // ...
  }

  @post('/update')
  async updateUser(): Promise<void> {
    // ...
  }
}

首先思考 controllerget / post装饰器,我们需要使用这几个装饰器注入哪些信息:

  • 路径
  • 方法(方法装饰器)

首先是对于整个类,我们需要将path: "/user"这个数据注入:

// 工具常量枚举
export enum METADATA_MAP {
  METHOD = 'method',
  PATH = 'path',
  GET = 'get',
  POST = 'post',
  MIDDLEWARE = 'middleware',
}

const { METHOD, PATH, GET, POST } = METADATA_MAP;

export const controller = (path: string): ClassDecorator => {
  return (target) => {
    Reflect.defineMetadata(PATH, path, target);
  };
};

而后是方法装饰器,我们选择一个高阶函数去吐出各个方法的装饰器,而不是为每种方法定义一个。

// 方法装饰器 保存方法与路径
export const methodDecoCreator = (method: string) => {
  return (path: string): MethodDecorator => {
    return (_target, _key, descriptor) => {
      Reflect.defineMetadata(METHOD, method, descriptor.value!);
      Reflect.defineMetadata(PATH, path, descriptor.value!);
    };
  };
};

// 首先确定方法,而后在使用时才去确定路径
const get = methodDecoCreator(GET);
const post = methodDecoCreator(POST);

接下来我们要做的事情就很简单了:

  • 拿到注入在类上元数据的根路径
  • 拿到每个方法上元数据的方法、路径
  • 拼接,生成路由表
const routeGenerator = (ins: Record<string, unknown>) => {
  const prototype = Object.getPrototypeOf(ins);

  const rootPath = Reflect.getMetadata(PATH, prototype['constructor']);

  const methods = Object.getOwnPropertyNames(prototype).filter(
    (item) => item !== 'constructor'
  );

  const routeGroup = methods.map((methodName) => {
    const methodBody = prototype[methodName];

    const path = Reflect.getMetadata(PATH, methodBody);
    const method = Reflect.getMetadata(METHOD, methodBody);
    return {
      path: `${rootPath}${path}`,
      method,
      methodName,
      methodBody,
    };
  });
  console.log(routeGroup);
  return routeGroup;
};

生成的结果大概是这样:

[
  {
    path: '/user/all',
    method: 'post',
    methodName: 'getAllUser',
    methodBody: [Function (anonymous)]
  },
  {
    path: '/user/update',
    method: 'get',
    methodName: 'updateUser',
    methodBody: [Function (anonymous)]
  }
]

依赖注入工具库

我个人了解并使用过的TS依赖注入工具库包括:

  • TypeDI,TypeStack出品
  • TSYringe,微软出品
  • Inversify,目前 JS/TS 中 star数最多的一个 依赖注入工具库
  • Injection,MidwayJS团队出品,是 MidwayJS 底层 IoC 的能力支持

我们再看看上面呈现过的Injection的例子:

@provide()
export class UserService {
 
  @inject()
  userModel;

  async getUser(userId) {
    return await this.userModel.get(userId);
  }
}

实际上,一个依赖注入工具库必定会提供的就是 从容器中获取实例注入对象到容器中的两个方法,如上面的 provideinject,TypeDI的 ServiceInject,以及 Inversify 的 injectableinject

总结

读完这篇文章,我想你应该对 TypeScript中 的装饰器与 IoC 机制有了大概的了解,如果你意犹未尽,不妨去看一下 TypeScript 对装饰器、反射元数据的编译结果(原本想作为本文的收尾部分,但我个人觉得没有特别具有技术含量的地方,所以还请自行根据需要扩展~),如果不想自己本地再起一个项目,你也可以直接使用TypeScript Playground。

最后,强烈推荐尝试一次全程重度使用装饰器来开发项目,这里给一个可行的技术方案:

  • Midway Serverless,使用装饰器声明你的 Serverless 函数,如果不想使用 Serverless ,你也可以使用 MidwayJS 来开发 Node 服务。
  • TypeORM,使用装饰器语法声明你的数据库表以及字段,结合 MidwayJS 官方提供的 ORM组件 来获得丝滑体验。
  • TypeGraphQL,使用装饰器语法声明你的 GraphQL 对象类型,与 TypeORM 可以一体使用,这样你就能够同时修改数据库表字段与 GraphQL Schema了。如果要与 Midway(Serverless)一同使用,需要 Apollo-Server-Midway。
  • Util-Decorators,提供基于装饰器的公用方法,如节流防抖、错误处理等。


浏览 70
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报