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();
複製程式碼
上面的程式碼展示了裝飾器多麼具有宣告性。下面我們將介紹裝飾器的細節:
- 什麼是裝飾器?它的目的和型別
- 裝飾器的簽名
- 方法裝飾器
- 屬性裝飾器
- 引數裝飾器
- 訪問器裝飾器
- 類裝飾器
- 裝飾器工廠
- 元資訊反射 API
- 結語
什麼是裝飾器?它的目的和型別
裝飾器是一種特殊的宣告,可附加在類、方法、訪問器、屬性、引數宣告上。
裝飾器使用 @expression
的形式,其中 expression
必須能夠演算為在執行時呼叫的函式,其中包括裝飾宣告資訊。
它起到了以宣告式方法將元資訊新增至已有程式碼的作用。
裝飾器型別及其執行優先順序為
- 類裝飾器——優先順序 4 (物件例項化,靜態)
- 方法裝飾器——優先順序 2 (物件例項化,靜態)
- 訪問器或屬性裝飾器——優先順序 3 (物件例項化,靜態)
- 引數裝飾器——優先順序 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
複製程式碼
我們看到,上面的程式碼中,f
和 g
返回了另一個函式(裝飾器函式)。f
和 g
稱為裝飾器工廠。
裝飾器工廠 幫助使用者傳遞可供裝飾器利用的引數。
我們還可以看到,演算順序為由頂向下,執行順序為由底向上。
裝飾器的簽名
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;
複製程式碼
方法裝飾器
從上面的簽名中,我們可以看到方法裝飾器函式有三個引數:
- target —— 當前物件的原型,也就是說,假設 Employee 是物件,那麼 target 就是
Employee.prototype
- propertyKey —— 方法的名稱
- 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
方法中的詳細註釋可以理解其內部機制。
屬性裝飾器
屬性裝飾器函式有兩個引數:
- target —— 當前物件的原型,也就是說,假設 Employee 是物件,那麼 target 就是
Employee.prototype
- 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
複製程式碼
引數裝飾器
引數裝飾器函式有三個引數:
- target —— 當前物件的原型,也就是說,假設 Employee 是物件,那麼 target 就是
Employee.prototype
- propertyKey —— 引數的名稱
- 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
複製程式碼
上面的例子中,我們定義了兩個訪問器 name
和 salary
,並通過裝飾器設定是否將其列入清單,據此決定物件的行為。name
將列入清單,而 salary
不會。
注意:TypeScript 不允許同時裝飾單一成員的 get
和 set
訪問器。相反,所有成員的裝飾器都必須應用於首個指定的訪問器(根據文件順序)。這是因為裝飾器應用於屬性描述符,屬性描述符結合了 get
和 set
訪問器,而不是分別應用於每項宣告。
下面是編譯的程式碼。
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);
複製程式碼
在編譯後的程式碼中,我們注意到兩處不同:
- 如你所見,傳給
__decorate
的引數有兩個,裝飾器陣列和構造器函式。 - 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