使用 TypeScript 裝飾器裝飾你的代碼

Mohan Ram 原做,受權 New Frontend 翻譯。javascript

裝飾器讓程序員能夠編寫元信息之內省代碼。裝飾器的最佳使用場景是橫切關注點——面向切面編程。php

面向切面編程(AOP) 是一種編程範式,它容許咱們分離橫切關注點,藉此達到增長模塊化程度的目標。它能夠在不修改代碼自身的前提下,給已有代碼增長額外的行爲(通知)。java

@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();
複製代碼

上面的代碼展現了裝飾器多麼具備聲明性。下面咱們將介紹裝飾器的細節:ios

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

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

裝飾器是一種特殊的聲明,可附加在類、方法、訪問器、屬性、參數聲明上。git

裝飾器使用 @expression 的形式,其中 expression 必須可以演算爲在運行時調用的函數,其中包括裝飾聲明信息。程序員

它起到了以聲明式方法將元信息添加至已有代碼的做用。github

裝飾器類型及其執行優先級爲typescript

  1. 類裝飾器——優先級 4 (對象實例化,靜態)
  2. 方法裝飾器——優先級 2 (對象實例化,靜態)
  3. 訪問器或屬性裝飾器——優先級 3 (對象實例化,靜態)
  4. 參數裝飾器——優先級 1 (對象實例化,靜態)

注意,若是裝飾器應用於類構造函數的參數,那麼不一樣裝飾器的優先級爲:1. 參數裝飾器,2. 方法裝飾器,3. 訪問器或參數裝飾器,4. 構造器參數裝飾器,5. 類裝飾器。express

// 這是一個裝飾器工廠——有助於將用戶參數傳給裝飾器聲明
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 稱爲裝飾器工廠。macos

裝飾器工廠 幫助用戶傳遞可供裝飾器利用的參數。

咱們還能夠看到,演算順序由頂向下執行順序由底向上

裝飾器的簽名

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

其餘內容推薦

相關文章
相關標籤/搜索