最近在用 TypeScript 開發一款在 Deno 環境下運行的 Web 服務框架,其中大量用到了裝飾器。node
這是一小段框架的測試代碼。git
import { Controller } from "../../Server.ts";
import { Get, Post, Query } from "../../Router.ts"; interface User { id: string; name: string; } let users: User[] = [ { id: "1", name: "小張", }, { id: "2", name: "小李", }, ]; @Controller("user") export class UserController { @Get("find") findById(@Query("id") id: string): User | undefined { return users.find((user) => user.id === id); } } 複製代碼
用過 Java 的朋友必定會感受很熟悉,一股很濃的 Spring 風不是嗎?github
用過 nodejs 的朋友應該也會聞到一股 Nestjs 的清香吧?web
事實上就是這樣,我目前在作的這個框架就是在 Spring 上面尋找的靈感。其實也談不上什麼靈感,可能只是單純喜歡這種風格的服務端代碼。express
在此以前,我用過不少服務端框架,好比 Java 的 Spring 系列、Python 的 Django、Go 的 Gin、nodejs 的 express、koa、nestjs、nextjs 等等,還有不少叫不上名字的,總之數不勝數,但如今仍然以爲在企業級的服務端開發領域,Spring 是設計最完善的,也是最易用的。編程
這個感覺並非我忽然感慨或是由於正在使用 Spring,其實我近兩年基本沒怎麼好好寫過 Java 代碼,連 web 服務端代碼也不多有寫。json
上面那段代碼的風格與 JavaScript 作的 web 框架是不太同樣的。框架
類比其餘 JavaScript 的 Web 框架,好比我的認爲很棒的 koa,代碼是這種風格的。koa
const router = require("koa-router")();
router.get("/xx", async (ctx, next) => { ctx.body = "hello"; }); module.exports = router; 複製代碼
這種風格也挺好。async
兩種風格的代碼最大的區別其實就是面向對象風格和函數式風格的差別,與實現的功能無關。
koa 也有 TypeScript 版本,也支持上面那種面向對象風格的代碼,只是用的人不多。
若是你用了 TypeScript,基本上已經肯定你的代碼風格很大程度上均可能是面向對象風格的,不多會有人使用 TypeScript 編寫函數式風格的代碼,相比之下,JavaScript 可能更擅長一些。
去年我曾經有一段時間在用 Ramda 和 TypeScript 寫過一個組件庫,但編碼過程體驗比較糟糕。
通常來講,對函數式有追求的人,二元論都比較嚴重,寫出來的代碼都很簡潔。但容易偏激,甚至於簡潔賽過一切,包括性能和可讀性。TypeScript 的特色是穩定,對應的,代碼會很繁瑣。固然能夠經過配置把 ts 的檢測都關掉,以此來寫出簡短的代碼,但這和直接用 JavaScript 又有什麼區別呢?
因此我認爲 TypeScript 不適合寫函數式風格的代碼。
好了,言歸正傳。
JavaScript 可以寫裝飾器嗎?
答案是如今還不能。
裝飾器這個提案在 17 年 4 月 30 日就已經提出了,然而漫長的三年事後,目前仍然處在第二階段。
正式經過提案,不知道要等到什麼年月。其實我想,這種語言發展方式,可以在必定程度上給 JavaScript 延年益壽。
雖然官方標準不支持,可是早就已經有大量開源項目都在經過轉譯器來使用這一特性了。基於 TypeScript 的項目都支持這個特性。
TypeScript 默認是不支持這種寫法的,須要在 tsconfig.json 中將"experimentalDecorators"
設置爲
true。
裝飾器的本質就是一個高階函數,來自於裝飾器模式。不少語言裏都有這種東西。
裝飾器即然是 JavaScript 中的概念,那麼它的觸發時機也將是在 JavaScript 的預編譯階段,而非 TypeScript 的編譯階段。
若是對裝飾器在參數形式的層面進行細分的話,能夠分爲兩種。
一種是不帶參數的,一種是帶參數的。
不帶參數的就是標準的裝飾器。
下面這個例子中,@mewing 能夠劫持 Cat 這個類,並將 Cat 的構造函數傳入 mewing 函數中,而後在 mewing 函數中執行某些操做。
@mewing
class Cat { constructor() { console.log("喵!"); } } function mewing(target: any) { console.log("喵!"); } 複製代碼
使用 nodejs 運行以上代碼,會在控制檯打印 「喵!」,即便不進行 new 的操做。
也就是上面解釋的,裝飾器的邏輯會在 JavaScript 的預編譯階段執行。
由於裝飾器是在預編譯階段執行,因此沒法干擾後續建立的實例。可是能夠在裝飾器中建立類的實例,可這種作法沒有什麼意義。
帶參數的裝飾器能夠傳遞一些元數據到裝飾器中,好比控制貓叫的次數。
@mewing(4)
class Cat { constructor() { console.log("喵!"); } } function mewing(num: number) { return (target: any) => { for (let i = 0; i < num; i++) { console.log("喵!"); } }; } 複製代碼
這樣就會聽到四聲貓叫。
帶參裝飾器和無參裝飾器的區別並不明顯,惟一的區別就是帶參裝飾器會在外層包裹一個函數,這個外層函數就是裝飾工廠函數,而內層函數就是真正的裝飾器。
在類型層面上劃分,裝飾器又分 5 種類型。
分別是類聲明裝飾器(ClassDecorator)、屬性裝飾器(PropertyDecorator)、訪問器裝飾器(AccessorDecorators)、方法裝飾器(MethodDecorator)和參數裝飾器(ParameterDecorator)。
下面是這 5 種類型的聲明,其中訪問器裝飾器和屬性裝飾器共享 PropertyDecorator 類型。
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; declare type ParameterDecorator = ( target: Object, propertyKey: string | symbol, parameterIndex: number ) => void; 複製代碼
類聲明裝飾器能夠直接修改類的定義。
它接收一個參數,該參數是原類的構造函數,並能夠選擇是否返回一個類。若是返回了一個類,就能夠把原來的那個類替換掉。
@mewing(2)
class Cat { constructor() { console.log("喵!"); } } function mewing(num: number) { return (target: any) => { return class Dog { constructor() { for (let i = 0; i < num; i++) console.log("汪!"); } }; }; } new Cat(); 複製代碼
上面的代碼運行結果是「汪!」而不是「喵!」,也就是說,表面上仍是一個可愛的小喵咪,但實際上已經變成了汪星人。
須要注意的是,原來的類有多少方法和屬性,新返回的替換類就須要實現和建立與以前數量相同的方法和屬性。
你也能夠不返回任何值,而是隻對它的原型進行修改,好比擴展它的方法。
@mewing(0)
class Cat { constructor() { console.log("喵!"); } } function mewing(num: number) { return (target: any) => { target.prototype.cute = () => { console.log("🐱"); }; }; } new Cat().cute(); 複製代碼
注意不能夠在類裝飾器中直接修改 target,由於此時已經完成了類的定義,只能在這裏替換掉原有定義或者擴展原型。
方法裝飾器只能應用於 class 的方法上。
與類裝飾器不一樣的是,它多了一個 descriptor 參數。這個參數會在構建目標大於等於 es5 版本時有效。
若是構建目標是 es3 版本,那麼該參數爲 undefined。實際上如今幾乎已經沒有人會去構建 es3 版本的 JavaScript 代碼了。
descriptor 的做用就是設置該條屬性的描述,你能夠直接修改上面的屬性,或者返回一個新的對象替換掉默認的描述。
let defaultDescriptor = {
value: [Function], writable: true, enumerable: true, configurable: true, }; 複製代碼
下面是一個來自魔改官方 Demo 的 Demo。
class Cat {
constructor() { console.log("喵!"); } @enumerable(true) cute() { console.log("🐱"); } } function enumerable(value: boolean) { return function ( target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any> ) { descriptor.enumerable = value; }; } for (let i in new Cat()) { console.log(i); } 複製代碼
你會發現 cute 方法變成了不可枚舉的狀態。
方法裝飾器能夠修改對應的方法,也能夠新增長方法。好比:
class Cat {
constructor() { console.log("喵!"); } @wawa cute() { console.log("🐱"); } } function wawa( target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any> ) { descriptor.value = () => console.log("wawa"); target.dudu = () => console.log("dudu"); } new Cat().cute(); new Cat().dudu(); 複製代碼
屬性裝飾器和訪問器裝飾器很是類似,就放在一塊兒講了。
實際上屬性裝飾器就是類的構形成員裝飾器。
好比能夠設置默認值。
class Cat {
@rename name: string | undefined; } function rename(target: any, propertyKey: string) { target[propertyKey] = "小花"; } console.log(new Cat().name); 複製代碼
固然也能夠作一些更奇怪的事情,好比給這條屬性和訪問器設置屬性描述。
class Cat {
private _age: number = 1; @initName name: string | undefined; @watch get age() { return this._age; } set age(v: number) { this._age = v; } } function initName(target: any, propertyKey: string) { target[propertyKey] = "我叫阿呆"; } function watch( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { let { set, get } = descriptor; descriptor.set = function (v: number) { console.log(`你在設置新的 ${propertyKey}, 新的值爲: ${v}。`); set?.call(target, v); }; descriptor.get = get?.bind(target); } let cat = new Cat(); cat.age = 2; console.log(`name: ${cat.name} age: ${cat.age}`); 複製代碼
訪問器裝飾器實際上和屬性裝飾器做用很相似。
可是因爲裝飾器不能訪問到實例對象,因此訪問者裝飾器就顯得有些雞肋。
若是你要修改 descriptor 上的任意一個屬性,那麼其餘屬性都須要從新設置。若是仍然想使用原來類中設置的屬性,那麼須要將 this 綁定到 target 上。雖說 target 就是類的構造函數,可是通過裝飾器裝飾以後,一切都會變成新的。若是設置了多個訪問者裝飾器,那麼在多個訪問者裝飾器中,是共享同一個 target 和 descriptor 的。
好比再添加一個@watch2 裝飾器,將其中綁定 get 的邏輯移到@watch2 中,預編譯結束後,@watch 中設置的 set 並無被覆蓋掉。
class Cat {
private _age: number = 1; @initName name: string | undefined; @watch @watch2 get age() { return this._age; } set age(v: number) { this._age = v; } } function initName(target: any, propertyKey: string) { target[propertyKey] = "我叫阿呆"; } function watch( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { let { set } = descriptor; descriptor.set = function (v: number) { console.log(`你在設置新的 ${propertyKey}, 新的值爲: ${v}。`); set?.call(target, v); }; } function watch2( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { let { get } = descriptor; descriptor.get = get?.bind(target); } let cat = new Cat(); cat.age = 2; console.log(`name: ${cat.name} age: ${cat.age}`); 複製代碼
參數裝飾器有 3 個參數,分別是類的構造、方法名和參數的下標。
class Cat {
constructor() { console.log("喵!"); } cute(@emoji emoji: string) { console.log(emoji); } } function emoji( target: any, propertyKey: string | symbol, parameterIndex: number ) { let params = []; params[parameterIndex] = "🐈"; target[propertyKey] = target[propertyKey].bind(target, ...params); } new Cat().cute(); 複製代碼
參數裝飾器沒法改變它所屬函數的定義,因此上面的代碼是不符合預期的。
但它可以增長其餘方法,好比像下面這樣:
class Cat {
constructor() { console.log("喵!"); } cute(@emoji emoji: string) { console.log(emoji); } } function emoji( target: any, propertyKey: string | symbol, parameterIndex: number ) { let params = []; params[parameterIndex] = "🐈"; target.cute2 = target[propertyKey].bind(target, ...params); } new Cat().cute2(); 複製代碼
固然也是有辦法修改它所屬方法定義的,只不過很是麻煩。能夠把對應的數據掛在到 target 上,再經過方法裝飾器從 target 中取出來,最終在方法裝飾器中完成方法的從新定義。
以管道的形式調用,自頂向下調用每一個裝飾工廠方法,再自內向外調用裝飾器方法。整個過程可類比洋蔥模型。
@a
@b c 複製代碼
會變成相似於下面這樣。
a(b(c));
複製代碼
從最後一個參數開始執行。
class Cat {
constructor() { console.log("喵!"); } cute(@emoji emoji: string, @voice voice: string) { console.log(emoji, voice); } } function emoji( target: any, propertyKey: string | symbol, parameterIndex: number ) { console.log("emoji call"); } function voice( target: any, propertyKey: string | symbol, parameterIndex: number ) { console.log("voice call"); } // voice call // emoji call 複製代碼
除了類裝飾器和參數裝飾器之外,其餘裝飾器按照出現順序順序執行。
在方法裝飾器中,若是碰到參數裝飾器,按照參數裝飾器的順序執行。
最後執行的永遠是類裝飾器。
下面用 50 行代碼搞清楚執行順序。
@clazz()
class CallSequence { @property() property: undefined; @method() mtehod(@parameter1() p: any, @parameter2() p2: any) {} private _a = 1; @accessor() get a() { return this._a; } } function clazz() { console.log("ClassDecorator before"); return (target: any) => { console.log("ClassDecorator after"); }; } function method() { console.log("MethodDecorator before"); return (t: any, k: any, p: any) => { console.log("MethodDecorator after"); }; } function property() { console.log("PropertyDecorator before"); return (t: any, k: any) => { console.log("PropertyDecorator after"); }; } function accessor() { console.log("AccessorDecorators before"); return (t: any, k: any) => { console.log("AccessorDecorators after"); }; } function parameter1() { console.log("ParameterDecorator1 before"); return (t: any, k: any, i: any) => { console.log("ParameterDecorator1 after"); }; } function parameter2() { console.log("ParameterDecorator2 before"); return (t: any, k: any, i: any) => { console.log("ParameterDecorator2 after"); }; } 複製代碼
打印結果:
PropertyDecorator before
PropertyDecorator after
MethodDecorator before
ParameterDecorator1 before
ParameterDecorator2 before
ParameterDecorator2 after
ParameterDecorator1 after
MethodDecorator after
AccessorDecorators before
AccessorDecorators after
ClassDecorator before
ClassDecorator after
複製代碼
裝飾器的目的就是爲了更好的進行元編程。關於元編程,還有另一個庫,reflect-metadata,該庫誕生於 2015 年 3 月 12 日,距今已經 5 年有餘,然而卻連提議都沒有。由於它須要等待裝飾器的提案經過才能提議。
不過得益於當前轉換編譯器的強大,若是你須要,能夠爲所欲爲的使用它們。
你能夠利用它們作任何想作的事情,好比打印日誌,參數注入等等,也能夠拿去作框架。
由於裝飾器沒法訪問到類的實例,因此有時會給人一種掣肘的感受,實際上這不是裝飾器設計的初衷。
裝飾器的初衷是在類被建立前改變類的定義。
除此之外的其餘目的,都不是裝飾器的初衷,但裝飾器也並無作出限制。
具體可以將它應用到什麼程度,還要看我的發揮了。
上面介紹的特性、概念以及用法僅適用於當前階段(2020 年 6 月 9 日凌晨,Stage 2 階段),後續仍然有改動的可能,具體變化須要持續關注 ts39。