裝飾器(Decorator)是一種程式設計模式(Design Pattern),在某些情況下可以用來替代繼承(inheritance),以更靈活、輕量的方式重用程式碼。



裝飾器其實就是Wrapper的一種應用,舉例來說:

class C {
    A() {
        console.log('A');
    }

    B() {
        console.log('B');
    }
}

class D {
    c: C;

    constructor(c: C) {
        this.c = c;
    }

    A() {
        this.c.A();

        console.log('Z');
    }
}

以上程式中,D的物件實體可以包裹一個C的物件實體,並且擴充C的物件實體的A方法的功能(原本只輸出A,變成輸出AZ)。此類別D就是一個裝飾器,它的用法如下:

let d = new D(new C());

d.A();

TypeScript的裝飾器

TypeScript內建裝飾器的支援,可以用比較易讀的語法來套用裝飾器設計模式。不過這個功能還在實驗中,如果要使用,必須要在tsconfig.json檔案中的compilerOptions欄位內,加上experimentalDecorators欄位,並將欄位值設為true

裝飾器

TypeScript的裝飾器為一個函數,這個函數的型別會根據其要裝飾的對象類型而有所不同。以下會依照裝飾器的裝飾對象來介紹裝飾器。

類別裝飾器

類別裝飾器的函數型別為(target: any) => voidtarget參數即為裝飾對象的建構子函數(即類別本身)。

例如以下函數,就是一個類別裝飾器:

function d(target: any) {
    console.log('Class Decorator');
}

我們可以用小老鼠符號@再接上裝飾器名稱(即函數名稱或儲存函數的變數、參數、常數的名稱)或是路徑,來套用該裝飾器。

類別裝飾器的@要放置於class關鍵字之前。如果也有使用export關鍵字,要放在export關鍵字之前。

例如:

function d(target: any) {
    console.log('Class Decorator');
}

@d
class C {

}

編譯以上程式並立刻執行的話,會輸出Class Decorator文字。

方法裝飾器

若TypeScript的編譯目標為ES5或是以上的版本,方法裝飾器的函數型別為(target: any, propertyKey: string, descriptor: PropertyDescriptor) => void(target: any, propertyKey: string) => voidtarget參數儲存要用來裝飾的類別建構子(裝飾靜態方法時),或是該類別的原型(裝飾物件方法時)。propertyKey參數儲存要用來裝飾的方法名稱,descriptor參數則是儲存屬性的「選項」(例如在先前章節有稍微提到的enumerable)。

例如以下函數,就是一個方法裝飾器:

function d(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('Method Decorator');
}

若TypeScript的編譯目標為ES3,則方法裝飾器的函數型別只能為(target: any, propertyKey: string) => void

方法裝飾器的@要放置於方法名稱之前。如果也有使用privateprotectedsetget關鍵字,要放在它們之前。

例如:

function d(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('Method Decorator');
}

class C {
    @d
    m() {

    }
}

編譯以上程式並立刻執行的話,會輸出Method Decorator文字。

descriptor參數的value欄位即為方法本身。例如上面看過的這個例子:

class C {
    A() {
        console.log('A');
    }

    B() {
        console.log('B');
    }
}

class D {
    c: C;

    constructor(c: C) {
        this.c = c;
    }

    A() {
        this.c.A();

        console.log('Z');
    }
}

可以將「方法執行完之後輸出一個Z文字」的功能做成TypeScript的裝飾器,將程式改寫如下:

function Z(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    let originalMethod = descriptor.value;

    descriptor.value = function () {
        originalMethod.call(this);

        console.log('Z');
    };
}

class C {
    @Z
    A() {
        console.log('A');
    }

    B() {
        console.log('B');
    }
}

這邊要注意的是,若要在descriptor參數的value欄位中呼叫原本的方法,最好用方法提供的call函數並代入this作為參數來呼叫,這樣才可以確保在原先方法中使用到的this關鍵字會是該物件本身的參考。

參數裝飾器

參數裝飾器的函數型別為(target: any, propertyKey: string, parameterIndex: number) => voidtarget參數儲存要用來裝飾的類別建構子(裝飾靜態方法的參數或是建構子的參數時),或是該類別的原型(裝飾物件方法的參數時)。propertyKey參數儲存要用來裝飾的參數所在的方法名稱。parameterIndex儲存要用來裝飾的參數序數(從0開始數)。

例如以下函數,就是一個參數裝飾器:

function d(target: any, propertyKey: string, parameterIndex: number) {
    console.log('Parameter Decorator');
}

參數裝飾器的@要放置於參數名稱之前。如果也有使用public關鍵字,要放在它之前。

例如:

function d(target: any, propertyKey: string, parameterIndex: number) {
    console.log('Parameter Decorator');
}

class C {
    constructor(@d public a: number) {

    }

    m(@d a: number) {
        this.a = a;
    }
}

編譯以上程式並立刻執行的話,會輸出兩行Parameter Decorator文字。

欄位裝飾器

欄位裝飾器的函數型別為(target: any, propertyKey: string) => voidtarget參數儲存要用來裝飾的類別建構子(裝飾靜態欄位時),或是該類別的原型(裝飾物件欄位時)。propertyKey參數儲存要用來裝飾的欄位名稱。

例如以下函數,就是一個欄位裝飾器:

function d(target: any, propertyKey: string) {
    console.log('Field Decorator');
}

欄位裝飾器的@要放置於欄位名稱之前。如果也有使用privateprotectedreadonly關鍵字,要放在它們之前。

例如:

function d(target: any, propertyKey: string) {
    console.log('Field Decorator');
}

class C {
    @d
    n: number = 0;
}

編譯以上程式並立刻執行的話,會輸出Field Decorator文字。

裝飾器工廠(Decorator Factory)

我們可以再撰寫一層擁有自訂參數的函數,用它來產生裝飾器,這個函數即稱為「裝飾器工廠」。裝飾器工廠函數的型別並沒有限制,但它的回傳值型別必須要是上面提到的那些裝飾器的型別。

例如以下函數,就是一個類別裝飾器工廠:

function f() {
    console.log('Class Decorator Factory');

    return (target: any) => {
        console.log('Class Decorator');
    };
}

我們可以用小老鼠符號@接上裝飾器工廠名稱或是路徑,再用小括號()傳入要代進裝飾器工廠的參數,來套用該裝飾器工廠。

例如:

function f() {
    console.log('Class Decorator Factory');

    return (target: any) => {
        console.log('Class Decorator');
    };
}

@f()
class C {

}

編譯以上程式並立刻執行的話,會輸出Class Decorator FactoryClass Decorator文字。

再舉個例子,利用裝飾器工廠,我們可以替方法快速加上Log功能,來紀錄它們何時被呼叫的。程式碼如下:

function log(tag: string) {
    return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
        let originalMethod = descriptor.value;

        descriptor.value = function () {
            console.log(tag);

            originalMethod.call(this);
        };
    };
}


class C {
    @log('m1')
    m1() {

    }

    @log('m2')
    m2() {

    }

    @log('m3')
    m3() {

    }
}

裝飾器的順序

不同對象的裝飾器(或裝飾器工廠)的套用順序是從上到下,從內到外,從右到左。相同對象的裝飾器的套用順序是從下到上(或者說從右到左)例如:

function cd1(target: any) {
    console.log('Class Decorator 1');
}

function cd2(target: any) {
    console.log('Class Decorator 1');
}

function md1(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('Method Decorator 1');
}

function md2(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('Method Decorator 2');
}

function md3(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('Method Decorator 3');
}

function pd1(target: any, propertyKey: string, parameterIndex: number) {
    console.log('Parameter Decorator 1');
}

function pd2(target: any, propertyKey: string, parameterIndex: number) {
    console.log('Parameter Decorator 2');
}

function fd1(target: any, propertyKey: string) {
    console.log('Field Decorator 1');
}

function fd2(target: any, propertyKey: string) {
    console.log('Field Decorator 2');
}

function fd3(target: any, propertyKey: string) {
    console.log('Field Decorator 3');
}

function fd4(target: any, propertyKey: string) {
    console.log('Field Decorator 4');
}

@cd1
class A {
    @fd1
    @fd2
    m = 0;
    @fd3
    n = 0;
}

@cd2
class B {
    @md1
    @md2
    run() {

    }

    @fd4
    f = 0;

    @md3
    compute(@pd1 x: number, @pd2 y: number) {

    }
}

以上程式的輸出結果如下:

Field Decorator 2
Field Decorator 1
Field Decorator 3
Class Decorator 1
Method Decorator 2
Method Decorator 1
Field Decorator 4
Parameter Decorator 2
Parameter Decorator 1
Method Decorator 3
Class Decorator 1

元數據

TypeScript的裝飾器還可以用來替類別或者類別屬性加上元數據(metadata),不過要搭配reflect-metadata套件來使用。

以下指令可以安裝reflect-metadata套件:

npm i --save reflect-metadata

用以下方式引用進TypeScript中:

import 'reflect-metadata';

基本用法如下:

import 'reflect-metadata';

@Reflect.metadata('k1', '123')
class C {
    @Reflect.metadata('k2', '456')
    n: number = 0;

    @Reflect.metadata('k3', 789)
    m() {

    }
}


console.log(Reflect.getMetadata('k1', C));

let c = new C();

console.log(Reflect.getMetadata('k2', c, 'n'));
console.log(Reflect.getMetadata('k3', c, 'm'));

透過Reflect.metadata這個裝飾器工廠,可以傳入元數據的鍵值和值,它們都可以是任意型別的值。如果要取得元數據,則是透過Reflect.getMetadata方法,第一個參數傳入要取得的元數據鍵值,第二個參數傳入元數據的來源(物件)。如果要取得屬性的元數據,則第三個參數要傳入屬性名稱。

我們也可以手動建立裝飾器,並在其中呼叫Reflect.defineMetadata方法來設定類別或是類別屬性的元數據。如下:

import 'reflect-metadata';

function classMetadata(key: string, value: string | number) {
    return (target: any) => {
        Reflect.defineMetadata(key, value, target);
    };
}

function propertyMetadata(key: string, value: string | number) {
    return (target: any, propertyKey: string) => {
        Reflect.defineMetadata(key, value, target, propertyKey);
    };
}

@classMetadata('k1', '123')
class C {
    @propertyMetadata('k2', '456')
    n: number = 0;

    @propertyMetadata('k3', 789)
    m() {

    }
}


console.log(Reflect.getMetadata('k1', C));

let c = new C();

console.log(Reflect.getMetadata('k2', c, 'n'));
console.log(Reflect.getMetadata('k3', c, 'm'));

總結

在這個章節中我們學會了TypeScript的裝飾器用法,利用裝飾器我們可以省下撰寫很多重複程式碼的功夫。最後再舉個模擬HTTP路由程式的例子,如下:

type method = 'get' | 'post';

const routes: {
    [_: string]: {
        get?: Function
        post?: Function
    }
} = {};

function resolve(method: method, path: string, ...data: any[]): any {
    if (routes.hasOwnProperty(path)) {
        let handlers = routes[path];

        let handler = handlers[method];

        if (typeof handler === 'function') {
            switch (method) {
                case 'get':
                    console.log(handler());
                    break;
                case 'post':
                    console.log(handler(...data));
                    break;
            }
        } else {
            throw Error(`The path \`${path}\` has no handler for the \`${method}\` method.`);
        }
    } else {
        throw Error(`The path \`${path}\` is undefined.`);
    }
}

function get(path: string) {
    return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
        if (!routes.hasOwnProperty(path)) {
            routes[path] = {};
        }

        routes[path]['get'] = descriptor.value;
    }
}

function post(path: string) {
    return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
        if (!routes.hasOwnProperty(path)) {
            routes[path] = {};
        }

        routes[path]['post'] = descriptor.value;
    }
}

class RouteClass {
    @get('/a')
    @post('/a')
    someGetMethod() {
        return 'Hello, world!';
    }

    @post('/b')
    somePostMethod(x: number, y: number) {
        return x + y;
    }
}

resolve('get', '/a');
resolve('post', '/a');
resolve('post', '/b', 1, 2);

以上程式的輸出結果如下:

Hello, world!
Hello, world!
3

這個系列的文章就到這裡為止了,對於習慣使用「靜態型別」的程式語言的開發者來說,TypeScript無非是開發「動態型別」的JavaScript程式的利器!