使用 TypeScript 裝飾器裝飾你的程式碼

NewFrontendWeekly發表於2019-06-28

Mohan Ram 原作,授權 New Frontend 翻譯。

裝飾器讓程式設計師可以編寫元資訊以內省程式碼。裝飾器的最佳使用場景是橫切關注點——面向切面程式設計。

面向切面程式設計(AOP) 是一種程式設計正規化,它允許我們分離橫切關注點,藉此達到增加模組化程度的目標。它可以在不修改程式碼自身的前提下,給已有程式碼增加額外的行為(通知)。

@log // 類裝飾器
class Person {
  constructor(private firstName: string, private lastName: string) {}

  @log // 方法裝飾器
  getFullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

const person = new Person('Mohan', 'Ram');
person.getFullName();
複製程式碼

上面的程式碼展示了裝飾器多麼具有宣告性。下面我們將介紹裝飾器的細節:

  1. 什麼是裝飾器?它的目的和型別
  2. 裝飾器的簽名
  3. 方法裝飾器
  4. 屬性裝飾器
  5. 引數裝飾器
  6. 訪問器裝飾器
  7. 類裝飾器
  8. 裝飾器工廠
  9. 元資訊反射 API
  10. 結語

什麼是裝飾器?它的目的和型別

裝飾器是一種特殊的宣告,可附加在類、方法、訪問器、屬性、引數宣告上。

裝飾器使用 @expression 的形式,其中 expression 必須能夠演算為在執行時呼叫的函式,其中包括裝飾宣告資訊。

它起到了以宣告式方法將元資訊新增至已有程式碼的作用。

裝飾器型別及其執行優先順序為

  1. 類裝飾器——優先順序 4 (物件例項化,靜態)
  2. 方法裝飾器——優先順序 2 (物件例項化,靜態)
  3. 訪問器或屬性裝飾器——優先順序 3 (物件例項化,靜態)
  4. 引數裝飾器——優先順序 1 (物件例項化,靜態)

注意,如果裝飾器應用於類建構函式的引數,那麼不同裝飾器的優先順序為:1. 引數裝飾器,2. 方法裝飾器,3. 訪問器或引數裝飾器,4. 構造器引數裝飾器,5. 類裝飾器。

// 這是一個裝飾器工廠——有助於將使用者引數傳給裝飾器宣告
function f() {
  console.log("f(): evaluated");
  return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("f(): called");
  }
}

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

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

// f(): evaluated
// g(): evaluated
// g(): called
// f(): called
複製程式碼

我們看到,上面的程式碼中,fg 返回了另一個函式(裝飾器函式)。fg 稱為裝飾器工廠。

裝飾器工廠 幫助使用者傳遞可供裝飾器利用的引數。

我們還可以看到,演算順序由頂向下執行順序由底向上

裝飾器的簽名

declare type ClassDecorator =
  <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type PropertyDecorator =
  (target: Object, propertyKey: string | symbol) => void;
declare type MethodDecorator = <T>(
  target: Object, propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<T>) =>
    TypedPropertyDescriptor<T> | void;
複製程式碼

方法裝飾器

從上面的簽名中,我們可以看到方法裝飾器函式有三個引數:

  1. target —— 當前物件的原型,也就是說,假設 Employee 是物件,那麼 target 就是 Employee.prototype
  2. propertyKey —— 方法的名稱
  3. descriptor —— 方法的屬性描述符,即 Object.getOwnPropertyDescriptor(Employee.prototype, propertyKey)
export function logMethod(
  target: Object,
  propertyName: string,
  propertyDescriptor: PropertyDescriptor): PropertyDescriptor {
  // target === Employee.prototype
  // propertyName === "greet"
  // propertyDesciptor === Object.getOwnPropertyDescriptor(Employee.prototype, "greet")
  const method = propertyDesciptor.value;

  propertyDesciptor.value = function (...args: any[]) {
    // 將 greet 的引數列表轉換為字串
    const params = args.map(a => JSON.stringify(a)).join();
    // 呼叫 greet() 並獲取其返回值
    const result = method.apply(this, args);
    // 轉換結尾為字串
    const r = JSON.stringify(result);
    // 在終端顯示函式呼叫細節
    console.log(`Call: ${propertyName}(${params}) => ${r}`);
    // 返回撥用函式的結果
    return result;
  }
  return propertyDesciptor;
};

class Employee {
    constructor(private firstName: string, private lastName: string
    ) {}

    @logMethod
    greet(message: string): string {
        return `${this.firstName} ${this.lastName} says: ${message}`;
    }
}

const emp = new Employee('Mohan Ram', 'Ratnakumar');
emp.greet('hello');

複製程式碼

上面的程式碼應該算是自解釋的——讓我們看看編譯後的 JavaScript 是什麼樣的。

"use strict";
var __decorate = (this && this.__decorate) ||
    function (decorators, target, key, desc) {
        // 函式引數長度
        var c = arguments.length

        /**
         * 處理結果
         * 如果僅僅傳入了裝飾器陣列和目標,那麼應該是個類裝飾器。
         * 否則,如果描述符(第 4 個引數)為 null,就根據已知值準備屬性描述符,
         * 反之則使用同一描述符。
         */

        var r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc;
        
        // 宣告儲存裝飾器的變數
        var 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--) {
                // 如果裝飾器合法,將其賦值給 d
                if (d = decorators[i]) {
                    /**
                     * 如果僅僅傳入了裝飾器陣列和目標,那麼應該是類裝飾器,
                     * 傳入目標呼叫裝飾器。
                     * 否則,如果 4 個引數俱全,那麼應該是方法裝飾器,
                     * 據此進行呼叫。
                     * 反之則使用同一描述符。
                     * 如果傳入了 3 個引數,那麼應該是屬性裝飾器,可進行相應的呼叫。
                     * 如果以上條件皆不滿足,返回處理的結果。
                    */
                    r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r
                }
            }
        };

        /**
         * 由於只有方法裝飾器需要根據應用裝飾器的結果修正其屬性,
         * 所以最後返回處理好的 r
         */
        return c > 3 && r && Object.defineProperty(target, key, r), r;
    };

var Employee = /** @class */ (function () {
    function Employee(firstName, lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    Employee.prototype.greet = function (message) {
        return this.firstName + " " + this.lastName + " says: " + message;
    };

    // typescript 呼叫 `__decorate` 輔助函式,
    // 以便在物件原型上應用裝飾器
    __decorate([
        logMethod
    ], Employee.prototype, "greet");
    return Employee;
}());
var emp = new Employee('Mohan Ram', 'Ratnakumar');
emp.greet('hello');
複製程式碼

讓我們開始分析 Employee 函式——構造器初始化 name 引數和 greet 方法,將其加入原型。

__decorate([logMethod], Employee.prototype, "greet");
複製程式碼

這是 TypeScript 自動生成的通用方法,它根據裝飾器型別和相應引數處理裝飾器函式呼叫。

該函式有助於內省方法呼叫,併為開發者鋪平了處理類似日誌記憶化應用配置等橫切關注點的道路。

在這個例子中,我們僅僅列印了函式呼叫及其引數、響應。

注意,閱讀 __decorate 方法中的詳細註釋可以理解其內部機制。

屬性裝飾器

屬性裝飾器函式有兩個引數:

  1. target —— 當前物件的原型,也就是說,假設 Employee 是物件,那麼 target 就是 Employee.prototype
  2. propertyKey —— 屬性的名稱
function logParameter(target: Object, propertyName: string) {
    // 屬性值
    let _val = this[propertyName];

    // 屬性讀取訪問器
    const getter = () => {
        console.log(`Get: ${propertyName} => ${_val}`);
        return _val;
    };

    // 屬性寫入訪問器
    const setter = newVal => {
        console.log(`Set: ${propertyName} => ${newVal}`);
        _val = newVal;
    };

    // 刪除屬性
    if (delete this[propertyName]) {
        // 建立新屬性及其讀取訪問器、寫入訪問器
        Object.defineProperty(target, propertyName, {
            get: getter,
            set: setter,
            enumerable: true,
            configurable: true
        });
    }
}

class Employee {
    @logParameter
    name: string;
}

const emp = new Employee();
emp.name = 'Mohan Ram';
console.log(emp.name);
// Set: name => Mohan Ram
// Get: name => Mohan Ram
// Mohan Ram
複製程式碼

上面的程式碼中,我們在裝飾器中內省屬性的可訪問性。下面是編譯後的程式碼。

var Employee = /** @class */ (function () {
    function Employee() {
    }
    __decorate([
        logParameter
    ], Employee.prototype, "name");
    return Employee;
}());
var emp = new Employee();
emp.name = 'Mohan Ram'; // Set: name => Mohan Ram
console.log(emp.name); // Get: name => Mohan Ram
複製程式碼

引數裝飾器

引數裝飾器函式有三個引數:

  1. target —— 當前物件的原型,也就是說,假設 Employee 是物件,那麼 target 就是 Employee.prototype
  2. propertyKey —— 引數的名稱
  3. index —— 引數陣列中的位置
function logParameter(target: Object, propertyName: string, index: number) {
    // 為相應方法生成後設資料鍵,以儲存被裝飾的引數的位置
    const metadataKey = `log_${propertyName}_parameters`;
    if (Array.isArray(target[metadataKey])) {
        target[metadataKey].push(index);
    }
    else {
        target[metadataKey] = [index];
    }
}

class Employee {
    greet(@logParameter message: string): string {
        return `hello ${message}`;
    }
}
const emp = new Employee();
emp.greet('hello');
複製程式碼

在上面的程式碼中,我們收集了所有被裝飾的方法引數的索引或位置,作為後設資料加入物件的原型。下面是編譯後的程式碼。

// 返回接受引數索引和裝飾器的函式
var __param = (this && this.__param) || function (paramIndex, decorator) {
  // 該函式返回裝飾器
  return function (target, key) { decorator(target, key, paramIndex); }
};

var Employee = /** @class */ (function () {
    function Employee() {}
    Employee.prototype.greet = function (message) {
        return "hello " + message;
    };
    __decorate([
        __param(0, logParameter)
    ], Employee.prototype, "greet");
    return Employee;
}());
var emp = new Employee();
emp.greet('hello');
複製程式碼

類似之前見過的 __decorate 函式,__param 函式返回一個封裝引數裝飾器的裝飾器。

如我們所見,呼叫引數裝飾器時,會忽略其返回值。這意味著,呼叫 __param 函式時,其返回值不會用來覆蓋引數值。

這就是引數裝飾器不返回的原因所在。

訪問器裝飾器

訪問器不過是類宣告中屬性的讀取訪問器和寫入訪問器。

訪問器裝飾器應用於訪問器的屬性描述符,可用於觀測、修改、替換訪問器的定義。

function enumerable(value: boolean) {
    return function (
      target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log('decorator - sets the enumeration part of the accessor');
        descriptor.enumerable = value;
    };
}

class Employee {
    private _salary: number;
    private _name: string;

    @enumerable(false)
    get salary() { return `Rs. ${this._salary}`; }

    set salary(salary: any) { this._salary = +salary; }

    @enumerable(true)
    get name() {
        return `Sir/Madam, ${this._name}`;
    }

    set name(name: string) {
        this._name = name;
    }

}

const emp = new Employee();
emp.salary = 1000;
for (let prop in emp) {
    console.log(`enumerable property = ${prop}`);
}
// salary 屬性不在清單上,因為我們將其設為假
// output:
// decorator - sets the enumeration part of the accessor
// decorator - sets the enumeration part of the accessor
// enumerable property = _salary
// enumerable property = name
複製程式碼

上面的例子中,我們定義了兩個訪問器 namesalary,並通過裝飾器設定是否將其列入清單,據此決定物件的行為。name 將列入清單,而 salary 不會。

注意:TypeScript 不允許同時裝飾單一成員的 getset 訪問器。相反,所有成員的裝飾器都必須應用於首個指定的訪問器(根據文件順序)。這是因為裝飾器應用於屬性描述符,屬性描述符結合了 getset 訪問器,而不是分別應用於每項宣告。

下面是編譯的程式碼。

function enumerable(value) {
    return function (target, propertyKey, descriptor) {
        console.log('decorator - sets the enumeration part of the accessor');
        descriptor.enumerable = value;
    };
}

var Employee = /** @class */ (function () {
    function Employee() {
    }
    Object.defineProperty(Employee.prototype, "salary", {
        get: function () { return "Rs. " + this._salary; },
        set: function (salary) { this._salary = +salary; },
        enumerable: true,
        configurable: true
    });
    Object.defineProperty(Employee.prototype, "name", {
        get: function () {
            return "Sir/Madam, " + this._name;
        },
        set: function (name) {
            this._name = name;
        },
        enumerable: true,
        configurable: true
    });
    __decorate([
        enumerable(false)
    ], Employee.prototype, "salary", null);
    __decorate([
        enumerable(true)
    ], Employee.prototype, "name", null);
    return Employee;
}());
var emp = new Employee();
emp.salary = 1000;
for (var prop in emp) {
    console.log("enumerable property = " + prop);
}
複製程式碼

類裝飾器

類裝飾器應用於類的構造器,可用於觀測、修改、替換類定義。

export function logClass(target: Function) {
    // 儲存一份原構造器的引用
    const original = target;

    // 生成類的例項的輔助函式
    function construct(constructor, args) {
        const c: any = function () {
            return constructor.apply(this, args);
        }
        c.prototype = constructor.prototype;
        return new c();
    }

    // 新構造器行為
    const f: any = function (...args) {
        console.log(`New: ${original['name']} is created`);
        return construct(original, args);
    }

    // 複製 prototype 屬性,保持 intanceof 操作符可用
    f.prototype = original.prototype;

    // 返回新構造器(將覆蓋原構造器)
    return f;
}

@logClass
class Employee {}

let emp = new Employee();
console.log('emp instanceof Employee');
console.log(emp instanceof Employee); // true
複製程式碼

上面的裝飾器宣告瞭一個名為 original 的變數,將其值設為被裝飾的類構造器。

接著宣告瞭名為 construct 的輔助函式。該函式用於建立類的例項。

我們接下來建立了一個名為 f 的變數,該變數將用作新構造器。該函式呼叫原構造器,同時在控制檯列印例項化的類名。這正是我們給原構造器加入額外行為的地方。

原構造器的原型複製到 f,以確保建立一個 Employee 新例項的時候,instanceof 操作符的效果符合預期。

新構造器一旦就緒,我們便返回它,以完成類構造器的實現。

新構造器就緒之後,每次建立例項時會在控制檯列印類名。

編譯後的程式碼如下。

var Employee = /** @class */ (function () {
    function Employee() {
    }
    Employee = __decorate([
        logClass
    ], Employee);
    return Employee;
}());
var emp = new Employee();
console.log('emp instanceof Employee');
console.log(emp instanceof Employee);
複製程式碼

在編譯後的程式碼中,我們注意到兩處不同:

  1. 如你所見,傳給 __decorate 的引數有兩個,裝飾器陣列和構造器函式。
  2. TypeScript 編譯器使用 __decorate 的返回值以覆蓋原構造器。

這正是類裝飾器必須返回一個建構函式的原因所在。

裝飾器工廠

由於每種裝飾器都有它自身的呼叫簽名,我們可以使用裝飾器工廠來泛化裝飾器呼叫。

import { logClass } from './class-decorator';
import { logMethod } from './method-decorator';
import { logProperty } from './property-decorator';
import { logParameter } from './parameter-decorator';

// 裝飾器工廠,根據傳入的引數呼叫相應的裝飾器
export function log(...args) {
    switch (args.length) {
        case 3: // 可能是方法裝飾器或引數裝飾器
            // 如果第三個引數是數字,那麼它是索引,所以這是引數裝飾器
            if typeof args[2] === "number") {
                return logParameter.apply(this, args);
            }
            return logMethod.apply(this, args);
        case 2: // 屬性裝飾器 
            return logProperty.apply(this, args);
        case 1: // 類裝飾器
            return logClass.apply(this, args);
        default: // 引數數目不合法
            throw new Error('Not a valid decorator');
    }
}

@log
class Employee {
    @log
    private name: string;

    constructor(name: string) {
        this.name = name;
    }

    @log
    greet(@log message: string): string {
        return `${this.name} says: ${message}`;
    }
}
複製程式碼

元資訊反射 API

元資訊反射 API (例如 Reflect)能夠用來以標準方式組織元資訊。

「反射」的意思是程式碼可以偵測同一系統中的其他程式碼(或其自身)。

反射在組合/依賴注入、執行時型別斷言、測試等使用場景下很有用。

import "reflect-metadata";

// 引數裝飾器使用反射 api 儲存被裝飾引數的索引
export function logParameter(target: Object, propertyName: string, index: number) {
    // 獲取目標物件的元資訊
    const indices = Reflect.getMetadata(`log_${propertyName}_parameters`, target, propertyName) || [];
    indices.push(index);
    // 定義目標物件的元資訊
    Reflect.defineMetadata(`log_${propertyName}_parameters`, indices, target, propertyName);
}

// 屬性裝飾器使用反射 api 獲取屬性的執行時型別
export function logProperty(target: Object, propertyName: string): void {
    // 獲取物件屬性的設計型別
    var t = Reflect.getMetadata("design:type", target, propertyName);
    console.log(`${propertyName} type: ${t.name}`); // name type: String
}


class Employee {
    @logProperty
    private name: string;
    
    constructor(name: string) {
        this.name = name;
    }

    greet(@logParameter message: string): string {
        return `${this.name} says: ${message}`;
    }
}
複製程式碼

上面的程式碼用到了 reflect-metadata 這個庫。其中,我們使用了反射元資訊的設計鍵(例如:design:type)。目前只有三個:

  • 型別元資訊用了元資訊鍵 design:type
  • 引數型別元資訊用了元資訊鍵 design:paramtypes
  • 返回型別元資訊用了元資訊鍵 design:returntype

有了反射,我們就能夠在執行時得到以下資訊:

  • 實體
  • 實體型別
  • 實體實現的介面
  • 實體構造器引數的名稱和型別。

結語

  • 裝飾器 不過是在設計時(design time)幫助內省程式碼,註解及修改類和屬性的函式。
  • Yehuda Katz 提議在 ECMAScript 2016 標準中加入裝飾器特性:tc39/proposal-decorators
  • 我們可以通過裝飾器工廠將使用者提供的引數傳給裝飾器。
  • 有 4 種裝飾器:裝飾器、方法裝飾器、屬性/訪問器裝飾器、引數裝飾器。
  • 元資訊反射 API 有助於以標準方式在物件中加入元資訊,以及在執行時獲取設計型別資訊

我把文中所有程式碼示例都放到了 mohanramphp/typescript-decorators 這個 Git 倉庫中。謝謝閱讀!

題圖:Alex Loup

其他內容推薦

相關文章