玩轉 TypeScript 裝飾器

前言


本篇文章適用於對 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 中的註解進行比較了。而只是說我所認爲的二者差異:

但實際上,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(
  this: any,
  type: DecoratorType.CLASS,
  ...args: any[]
): ClassDecorator;

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

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

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

function decoFactory(
  this: any,
  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<T>
) {
  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 內部又依賴了別的類,那麼維護起來簡直是一團亂麻。

要解決這個問題,我們可以這麼做:

以 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 依賴注入工具庫包括:

我們再看看上面呈現過的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。

最後,強烈推薦嘗試一次全程重度使用裝飾器來開發項目,這裏給一個可行的技術方案:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/76SvGEBAAJNxvHpjx0yv-g