TypeScript 的 Decorator

weixin_34321977發表於2018-11-03

前言

隨著TypeScript和ES6裡引入了類,在一些場景下我們需要額外的特性來支援標註或修改類及其成員。 裝飾器(Decorators)為我們在類的宣告及成員上通過超程式設計語法新增標註提供了一種方式。 Javascript裡的裝飾器目前處在 建議徵集的第二階段,但在TypeScript裡已做為一項實驗性特性予以支援。

詳細的基礎知識可以參考官方文件,本文也做了一部分擷取。

本文結合TypeScript 中的 Decorator & 後設資料反射:從小白到專家(部分 I)做一些記錄。

關於JS的裝飾器可參考 ES7裝飾器 Decorator

一、基礎

裝飾器是一種特殊型別的宣告,它能夠被附加到 類宣告方法訪問符屬性引數 上。 裝飾器使用@expression這種形式,expression求值後必須為一個函式,它會在執行時被呼叫,被裝飾的宣告資訊做為引數傳入。

1.1 啟用

tsconfig.json:

{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true
    }
}
1.2 裝飾器工廠

如果我們要定製一個修飾器如何應用到一個宣告上,我們得寫一個裝飾器工廠函式。 裝飾器工廠 就是一個簡單的函式,它返回一個表示式,以供裝飾器在執行時呼叫。

我們可以通過下面的方式來寫一個裝飾器工廠函式:

function color(value: string) { // 這是一個裝飾器工廠
    return function (target) { //  這是裝飾器
        // do something with "target" and "value"...
    }
}

當多個裝飾器應用於一個宣告上,它們求值方式與複合函式相似。在這個模型下,當複合fg時,複合的結果(fg)(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 D {
  @f()
  @g()
  method() { }
}

let d = new D();
d.method(); 

// 輸出
// f(): evaluated
// g(): evaluated
// g(): called
// f(): called

二、 幾種修飾型別

這些裝飾器都
不能用在宣告檔案中(.d.ts),或者任何外部上下文(比如 declare的類)裡。

2.1 類裝飾器

(和JS類裝飾器沒有什麼區別)

類裝飾器應用於類建構函式,可以用來監視,修改或替換類定義。

類裝飾器表示式會在執行時當作函式被呼叫,類的建構函式作為其唯一的引數。

如果類裝飾器返回一個值,它會使用提供的建構函式來替換類的宣告。

注意 如果你要返回一個新的建構函式,你必須注意處理好原來的原型鏈。 在執行時的裝飾器呼叫邏輯中 不會為你做這些。

下面是使用類裝飾器(@sealed)的例子,應用在Greeter類:

@sealed
class Greeter {
    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
    greet() {
        return "Hello, " + this.greeting;
    }
}

function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
}

下面是一個過載建構函式的例子。

function classDecorator<T extends {new(...args:any[]):{}}>(constructor:T) {
    return class extends constructor {
        newProperty = "new property";
        hello = "override";
    }
}

@classDecorator
class Greeter {
    property = "property";
    hello: string;
    constructor(m: string) {
        this.hello = m;
    }
}

console.log(new Greeter("world"));

// 輸出  
//class_1 {
//  property: 'property',
//  hello: 'override',
//  newProperty: 'new property' }

可見new時指定的值 world 被覆蓋為 override,並新增了新屬性 newProperty

2.2 方法裝飾器

方法裝飾器宣告在一個方法的宣告之前(緊靠著方法宣告)。 它會被應用到方法的 屬性描述符上,可以用來監視,修改或者替換方法定義。

方法裝飾器表示式會在執行時當作函式被呼叫,傳入下列3個引數:

  • 對於靜態成員來說是類的建構函式,對於例項成員是類的原型物件。
  • 成員的名字。
  • 成員的屬性描述符。

如果程式碼輸出目標版本小於ES5,屬性描述符將會是undefined。

如果方法裝飾器返回一個值,它會被用作方法的屬性描述符。

如果程式碼輸出目標版本小於ES5返回值會被忽略。

下面是一個方法裝飾器(@enumerable)的例子,應用於Greeter類的方法上:

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }

  @enumerable(false)
  greet() {
    return "Hello, " + this.greeting;
  }
}

function enumerable(value: boolean) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;
  };
}
2.3 訪問器get裝飾器

TypeScript不允許同時裝飾一個成員的get和set訪問器。取而代之的是,一個成員的所有裝飾的必須應用在文件順序的第一個訪問器上。這是因為,在裝飾器應用於一個屬性描述符時,它聯合了get和set訪問器,而不是分開宣告的。

其餘特性和 2.2 方法裝飾器 一樣。

下面是使用了訪問器裝飾器(@configurable)的例子,應用於Point類的成員上:

class Point {
  private _x: number;
  private _y: number;
  constructor(x: number, y: number) {
    this._x = x;
    this._y = y;
  }

  @configurable(false)
  get x() { return this._x; }

  @configurable(true)
  get y() { return this._y; }
}

function configurable(value: boolean) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.configurable = value;
  };
}
2.4 屬性裝飾器

屬性裝飾器表示式會在執行時當作函式被呼叫,傳入下列2個引數:

  • 對於靜態成員來說是類的建構函式,對於例項成員是類的原型物件。
  • 成員的名字。

屬性描述符不會做為引數傳入屬性裝飾器,這與TypeScript是如何初始化屬性裝飾器的有關。 因為目前沒有辦法在定義一個原型物件的成員時描述一個例項屬性,並且沒辦法監視或修改一個屬性的初始化方法。返回值也會被忽略。因此,屬性描述符只能用來監視類中是否宣告瞭某個名字的屬性。


import "reflect-metadata";

const formatMetadataKey = Symbol("format");

class Greeter {
  @format("Hello, %s")
  greeting: string;

  constructor(message: string) {
    this.greeting = message;
  }
  greet() {
    let formatString = getFormat(this, "greeting");
    return formatString.replace("%s", this.greeting);
  }
}


function format(formatString: string) {
  return Reflect.metadata(formatMetadataKey, formatString);
}

function getFormat(target: any, propertyKey: string) {
  return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}


let geeter = new Greeter('NowhereToRun');

console.log(geeter.greet()); // Hello, NowhereToRun

這個@format("Hello, %s")裝飾器是個 裝飾器工廠。當 @format("Hello, %s")被呼叫時,它新增一條這個屬性的後設資料,通過reflect-metadata庫(這個我們後面再說)裡的Reflect.metadata函式。 當 getFormat被呼叫時,它讀取格式的後設資料。

注意  這個例子需要使用reflect-metadata庫。

2.5 引數裝飾器

引數裝飾器宣告在一個引數宣告之前(緊靠著引數宣告)。 引數裝飾器應用於類建構函式或方法宣告。

引數裝飾器表示式會在執行時當作函式被呼叫,傳入下列3個引數:

  • 對於靜態成員來說是類的建構函式,對於例項成員是類的原型物件。
  • 成員的名字。
  • 引數在函式引數列表中的索引。

注意引數裝飾器只能用來監視一個方法的引數是否被傳入。

引數裝飾器的返回值會被忽略。

import "reflect-metadata";

const requiredMetadataKey = Symbol("required");

function required(target: Object, propertyKey: string | symbol, parameterIndex: number) {
  console.log(target, propertyKey, parameterIndex);  

  let existingRequiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || [];
  existingRequiredParameters.push(parameterIndex);
  Reflect.defineMetadata(requiredMetadataKey, existingRequiredParameters, target, propertyKey);
}

function validate(target: any, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) {
  let method = descriptor.value;
  descriptor.value = function () {
    let requiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyName);
    if (requiredParameters) {
      for (let parameterIndex of requiredParameters) {
        if (parameterIndex >= arguments.length || arguments[parameterIndex] === undefined) {
          throw new Error("Missing required argument.");
        }
      }
    }

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

class Greeter {
  greeting: string;

  constructor(message: string) {
    this.greeting = message;
  }

  @validate
  greet(@required name: string) {
    return "Hello " + name + ", " + this.greeting;
  }
}

let greet = new Greeter('u r number1');

console.log(greet.greet('NowhereToRun')); 

// Greeter {}     'greet'      0
// Hello NowhereToRun, u r number1

這個例子就是靠@required 和 @validate共同作用,檢測引數必須存在,其實在TS的呼叫時,如果不傳參已經會報錯了,這裡只是一個簡單的例子。

@required裝飾器新增了後設資料實體把引數標記為必需的。 @validate裝飾器把greet方法包裹在一個函式裡在呼叫原先的函式前驗證函式引數。

後設資料 metadata

相關文章

相關文章