原創 JSCON 淘系技術 6月9日

曆史淵源和設計标準
我們知道,在 ES6 的規範當中,ES6 支援元程式設計,核心是因為提供了對 Proxy 和 Reflect 對象的支援。簡單來說這個 API 的作用就是可以實作對變量操作的函數化,也就是反射。
對于其他語言的程式員來講,比如說 Java 或者 C#,元程式設計和 Metadata 是相對熟悉的,而對于 JSer 目前接觸的并非很多,是以相對會陌生一些。
關于 ES6 元程式設計,我之前寫過一篇教程《
ES6 元程式設計(Proxy & Reflect & Symbol)》可以看看,這裡就不展開了
然而 ES6 的 Reflect 規範裡面還缺失一個規範,那就是 Reflect Metadata。這會造成什麼樣的情境呢[2]?
由于 JS/TS 現有的 裝飾器更多的是存在于對函數或者屬性進行一些操作,比如修改他們的值,代理變量,自動綁定 this 等等功能。但是卻無法實作通過反射來擷取究竟有哪些裝飾器添加到這個類/方法上... 這就限制了 JS 中元程式設計的能力。
此時 Relfect Metadata 就派上用場了,可以通過裝飾器來給類添加一些自定義的資訊。然後通過反射将這些資訊提取出來(當然你也可以通過反射來添加這些資訊)。
綜合一下, JS 中對 Reflect Metadata 的訴求,簡單概括就是:
- 其他 C#、Java、Pythone 語言已經有的進階功能,我 JS 也應該要有(諸如C# 和 Java 之類的語言支援将中繼資料添加到類型的屬性或注釋,以及用于讀取中繼資料的反射API,而目前 JS 缺少這種能力)
- 許多用例(組合/依賴注入,運作時類型斷言,反射/鏡像,測試)都希望能夠以一緻的方式向類中添加其他中繼資料。
- 為了使各種工具和庫能夠推理出中繼資料,需要一種标準一緻的方法;
- 中繼資料不僅可以用在對象上,也可以通過相關捕獲器用在 Proxy 上
- 對開發人員來說,定義新的中繼資料生成裝飾器應該簡潔易用;
什麼是metadata(中繼資料)
首先解釋一下 metadata 這個概念,并非所有人都了解這個名詞的含義 —— 其實每個人平時都經常接觸過,隻是不知道它叫 metadata 而已。
簡言之,有資料庫開發經驗的同學,中繼資料概念其實是跟資料庫的字段名(field)一緻 —— 在傳統的資料庫中就天然包含中繼資料的概念。
比如一個人的履歷上寫着 “姓名” 是 “張三”,那麼表示成 JS 對象就是:
{
"name": "張三"
}
那麼我們設計資料庫的時候,一般将 name 設計成存儲列名(field),這樣不同履歷的人就能按照 name 進行檢索了,是以我們稱 “name” 就是一個中繼資料 —— 沒了,就是這麼簡單。
是以但凡你在 JS 中遇到中繼資料程式設計,你就假想成自己在設計資料庫就行了。
講到這裡,我就繼續啰嗦兩句,友善擴散思維。
HTML的 <head> 标簽裡可以定義 <meta> 标簽。<meta> 元素可提供有關頁面的元資訊(meta-information),比如針對搜尋引擎和更新頻度的描述和關鍵詞。"keywords" 是一個經常被用到的,提高網站 SEO 繞不開的一個設定項(某些搜尋引擎在遇到這些關鍵字時,會用這些關鍵字對文檔進行分類):
<meta name="keywords" content="HTML,JavaScript,CSS,前端">
在我們了解完 metadata 的概念後,我們可以開始學習如何使用它了。
如何開始metadata程式設計?
TypeScript 已經完整的實作了裝飾器,後續的講解預設都以 TS 環境(雖然 Babel 也可以,但是需要各種配置,比較繁瑣)。
Reflect Metadata 是 15 年提出的一個提案,現在我們想要使用這個功能,可以借助倉庫 reflect-metadata,先 npm 安裝這個庫:
npm i reflect-metadata --save
TypeScript 支援為帶有 裝飾器 的聲明 生成中繼資料。你需要在指令行或 tsconfig.json裡啟用emitDecoratorMetadata編譯器選項:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
當啟用後,隻要reflect-metadata庫被引入了,設計階段添加的類型資訊可以在運作時使用。
基礎用法
嚴格地說,中繼資料(metadata)和 裝飾器(Decorator) 是 EcmaScript 中兩個獨立的部分。 然而,如果你想實作像是 反射這樣的能力,你總是同時需要它們。
比如下方這個例子:
// 要引入這個庫
import "reflect-metadata";
@Reflect.metadata('name', 'Person')
class Person {
@Reflect.metadata('words', 'hello world')
public speak(): string {
return 'hello world'
}
}
Reflect.getMetadata('name', Person) // 'Person'
Reflect.getMetadata('words', new Person(), 'speak') // 'hello world'
// 這裡為什麼要用 new A(),用 A 不行麼?後文會講到
是不是很簡單?對照這個例子,我們再引出 Metadata 的四個概念:
- Metadata Key {Any}(後文簡寫 k)。中繼資料的 Key,對于一個對象來說,它可以有很多中繼資料,每一個中繼資料都對應有一個 Key。一個很簡單的例子就是說,你可以在一個對象上面設定一個叫做 'name' 的 Key 用來設定他的名字,用一個 'created time' 的 Key 來表示他建立的時間。這個 Key 可以是任意類型。在後面會講到内部本質就是一個 Map 對象。
- Metadata Value {Any} (後文簡寫 v)。中繼資料的類型,任意類型都行。
- Target {Object} (後文簡寫 o)。表示要在這個對象上面添加中繼資料
- Property {String|Symbol} (後文簡寫 p)。用于設定在哪個屬性上添加中繼資料。大家可能會想,這個是幹什麼用的,不是可以在對象上面添加中繼資料了麼?其實不僅僅可以在對象上面添加中繼資料,甚至還可以在對象的屬性上面添加中繼資料。其實大家可以這樣了解,當你給一個對象定義中繼資料的時候,相當于你是預設指定了 undefined 作為 Property。
了解了這 4 個概念,我們閱讀對應的 API 就會比較容易。
API設計
最為全面的 API 可以查閱标準文檔:
https://rbuckton.github.io/reflect-metadata/API 的設計也是非常清晰明了,metadata 畢竟也屬于 “資料”,那麼對應的 API 就是跟資料庫的 CURD 增删改查的操作相對應的。
先羅列一下完整的 API 聲明,然後我們在挨個拆分講解:
namespace Reflect {
// 用于裝飾器
metadata(k, v): (target, property?) => void
// 在對象上面定義中繼資料
defineMetadata(k, v, o, p?): void
// 是否存在中繼資料
hasMetadata(k, o, p?): boolean
hasOwnMetadata(k, o, p?): boolean
// 擷取中繼資料
getMetadata(k, o, p?): any
getOwnMetadata(k, o, p?): any
// 擷取所有中繼資料的 Key
getMetadataKeys(o, p?): any[]
getOwnMetadataKeys(o, p?): any[]
// 删除中繼資料
deleteMetadata(k, o, p?): boolean
}
▐ 建立中繼資料(Reflect.metadata/Reflect.defineMetadata)
兩種方式建立,其最本質都是調用源碼中 OrdinaryDefineOwnMetadata 方法,是以結果是一樣的,隻是使用方式不一樣。
一種是通過裝飾器聲明方式建立,推薦的方式,也是很主流的一種方式:
// 裝飾類
@Reflect.metadata(key, value)
class Example {
}
// 裝飾靜态屬性
class Example {
@Reflect.metadata(key, value)
static staticProperty;
}
// 裝飾執行個體屬性
class Example {
@Reflect.metadata(key, value)
property;
}
// 裝飾靜态方法
class Example {
@Reflect.metadata(key, value)
static staticMethod() { }
}
// 裝飾執行個體方法
class Example {
@Reflect.metadata(key, value)
method() { }
}
另一種就是 “事後”(類建立完後)再給目标對象建立中繼資料:
class Example {
// property declarations are not part of ES6, though they are valid in TypeScript:
// static staticProperty;
// property;
constructor(p) { }
static staticMethod(p) { }
method(p) { }
}
// 給 類本身 加中繼資料
Reflect.defineMetadata("custom:annotation", options, Example);
// 給 類的靜态屬性 新增中繼資料
Reflect.defineMetadata("custom:annotation", options, Example, "staticProperty");
// 給 類的執行個體屬性 新增中繼資料,注意是加在 prototype 上
Reflect.defineMetadata("custom:annotation", options, Example.prototype, "property");
// 給 類的靜态方法 新增中繼資料
Reflect.defineMetadata("custom:annotation", options, Example, "staticMethod");
// 給 類的執行個體方法 新增中繼資料,注意是加在 prototype 上
Reflect.defineMetadata("custom:annotation", options, Example.prototype, "method");
這兩種方式之間的聯系就是,裝飾器方法 Reflect.metadata 其實内部就是用 Reflect.defineMetadata 實作的:
function metadata(metadataKey: any, metadataValue: any): Decorator {
return (target, key?) => Reflect.defineMetadata("custom:annotation", options, target, key);
}
▐ 查詢中繼資料
這裡的 “查詢” 是加引号的,因為中繼資料的查詢的功能很簡單,隻要該中繼資料即可,不需擷取要寫 SQL 那麼複雜。由于涉及到原型鍊,是以每個 API 都存在一對,用以區分是否包含原型鍊的操作。
- 擷取中繼資料(getMetadata/getOwnMetadata)
這裡涉及到兩個相關的 API,getMetadata/getOwnMetadata 兩個方法,它們之間的差別前者會包含原型鍊查找,後者不會查找原型鍊。
(類似于 Object.hasProperty 和 Object.hasOwnProperty 這兩個方法的差別)
舉例如下:
class A {
@Reflect.metadata('name', 'hello')
hello() {}
}
const t1 = new A()
const t2 = new A()
// 給 t2.hello 新增中繼資料内容 otherName -> world
// 此時會新增到 t2.prototype(原型鍊) 上(注意不是 A.prototype 上)
Reflect.defineMetadata('otherName', 'world', t2, 'hello');
// 首先,t1/t2 自己能擷取原本的原型鍊 name 對應的中繼資料
Reflect.getMetadata('name', t1, 'hello') // 'hello'
Reflect.getMetadata('name', t2, 'hello') // 'hello'
// t1 自身上沒定義 otherName 的中繼資料
Reflect.getMetadata('otherName', t1, 'hello') // undefined
// t2 自身定義了 otherName 的中繼資料
Reflect.getMetadata('otherName', t2, 'hello') // 'world'
// t2 自身上隻有 otherName 這個中繼資料,沒有 name 中繼資料(該中繼資料來自父級)
Reflect.getOwnMetadata('name', t2, 'hello') // undefined
Reflect.getOwnMetadata('otherName', t2, 'hello') // 'world'
- 判斷是否存在中繼資料(hasMetadata/hasOwnMetadata)
同樣的涉及到兩個相關的 API,hasMetadata/hasOwnMetadata 兩個方法,它們兩者調用方式一樣,唯一的差別是前者會包含原型鍊查找,後者不會查找原型鍊。而且入參含義跟getMetadata/getOwnMetadata 兩個方法是一樣的:
class Example {
// property declarations are not part of ES6, though they are valid in TypeScript:
// static staticProperty;
// property;
constructor(p) { }
static staticMethod(p) { }
method(p) { }
}
// 類本身 上是否存在中繼資料
result = Reflect.hasMetadata("custom:annotation", options, Example);
// 類的靜态屬性 上是否存在中繼資料
result = Reflect.hasMetadata("custom:annotation", options, Example, "staticProperty");
// 類的執行個體屬性 上是否存在中繼資料,注意是在 prototype 上
result = Reflect.hasMetadata("custom:annotation", options, Example.prototype, "property");
// 類的靜态方法 上是否存在中繼資料
result = Reflect.hasMetadata("custom:annotation", options, Example, "staticMethod");
// 類的執行個體方法 上是否存在中繼資料,注意是在 prototype 上
result = Reflect.hasMetadata("custom:annotation", options, Example.prototype, "method");
- 擷取 metaKeys(getMetadataKeys/getOwnMetadataKeys)
同樣的涉及到兩個相關的 API,getMetadataKeys/getOwnMetadataKeys 兩個方法。
class Example {
// property declarations are not part of ES6, though they are valid in TypeScript:
// static staticProperty;
// property;
constructor(p) { }
static staticMethod(p) { }
method(p) { }
}
// 擷取 類本身 的中繼資料 keys
result = Reflect.getMetadataKeys(Example);
// 擷取 類的靜态屬性 的中繼資料 keys
result = Reflect.getMetadataKeys(Example, "staticProperty");
// 擷取 類的執行個體屬性 的中繼資料 keys,注意是從 prototype 上擷取
result = Reflect.getMetadataKeys(Example.prototype, "property");
// 擷取 類的靜态方法 的中繼資料 keys
result = Reflect.getMetadataKeys(Example, "staticMethod");
// 擷取 類的執行個體方法 的中繼資料 keys,注意是從 prototype 上擷取
result = Reflect.getMetadataKeys(Example.prototype, "method");
▐ 删除中繼資料
用于删除中繼資料的 API 是 deleteMetadata,如果對象上有該中繼資料, 則會删除成功傳回 true, 否則傳回 false。因為比較簡單, 就直接上例子了:
const type = 'type'
@Reflect.metadata(type, 'class')
class DeleteMetadata {
@Reflect.metadata(type, 'static')
static staticMethod() {}
}
const res1 = Reflect.deleteMetadata(type, DeleteMetadata)
const res2 = Reflect.deleteMetadata(type, DeleteMetadata, 'staticMethod')
const res3 = Reflect.deleteMetadata(type, DeleteMetadata)
console.log(res1, res2, res3) // true true false
(注意并沒有 deleteOwnMetadata 這樣的 API)
TS福利:design:類型中繼資料
這裡需要提及一點的是,在 TS 中的 reflect-metadata 是經過擴充[5]的(也就是說功能更加強勁),我們看個例子:
import "reflect-metadata";
class Foo {
@Reflect.metadata("hello", "world")
public say(a: string): string {
return 'foo';
}
}
我們檢視其轉換出來的 JS 代碼,你會發現處理我們自己的中繼資料 "hello"->"world" 之外,還會額外給我們添加 "design:type"、"design:paramtypes" 和 "design:returntype" 這 3 個類型相關的中繼資料。
之是以會有,這是因為我們在 TS 中開啟了 emitDecoratorMetadata(
https://www.typescriptlang.org/tsconfig#emitDecoratorMetadata)編譯選項,這樣 TS 在編譯的時候會将類型中繼資料自動添加上去。這也是 TS 強類型程式設計帶來的額外好處 —— 換言之如果你用 ES6 程式設計,需要自己加這 3 個中繼資料。
(當然,如果你關閉 emitDecoratorMetadata選項,也就沒有這 3 個類型中繼資料了)
design:xxx 的内容分别表示什麼意思呢[4]?
- design:type 表示被裝飾的對象是什麼類型, 比如是字元串? 數字? 還是函數等
- design:paramtypes 表示被裝飾對象的參數類型, 是一個表示類型的數組, 如果不是函數, 則沒有該 key
- design:returntype 表示被裝飾對象的傳回值屬性, 比如字元串,數字或函數等
我們看個例子[4]:
@Reflect.metadata('type', 'class')
class A {
constructor(public name: string, public age: number) {
}
@Reflect.metadata(undefined, undefined)
method():boolean {
return true
}
}
const t1 = Reflect.getMetadata('design:paramtypes', A)
const t2 = Reflect.getMetadata('design:returntype', A.prototype, 'method')
const t3 = Reflect.getMetadata('design:type', A.prototype, 'method')
console.log(...t1, t2, t3)
列印輸出為:
但是要注意:
- 沒有裝飾的 target 是 get 不到這些 metadata 的
- 必須手動指定類型, 否則無法進行推斷。比如 method 方法如果不指定傳回值為 boolean, 那麼 t2 将會是 undefined
- 應用的順序為 type -> paramtypes -> returntype
按理說本文到此就結束了,如果想看 reflect-metadata 源碼是如何的,可以繼續閱讀下去。
進階:reflect-metadata源碼設計解讀
我還是比較推薦閱讀源碼的,何況 reflect-metadata 的源碼(位址:
https://github.com/rbuckton/reflect-metadata/blob/master/Reflect.ts)不算多,咋一看有 1.8k 多行,可真正看下來核心源碼沒幾行的。
首先刨除聲明 + 注釋就已經去了一大半的行數了。
接着就是 polyfill 部分,按照一般的套路,很多 polyfill 庫會讓你提供一些前置的 polyfill ,但是 reflect-metadata 這個庫竟然内部自己實作了很多的 polyfill 和算法 —— 比如 Map, Set, WeakMap, UUID。如果把這部分的 polyfill 源碼也刨除掉,真正實作 reflect-metadata 的邏輯就幾百行代碼,是不是很驚喜意外?
核心源碼比較簡單,這裡就不陳述,這裡主要陳述一下 reflect-metadata 中有關源碼的設計理念,權當抛磚引玉。
▐ 資料結構[3]
應用該庫之後,每個對象都有 [[Metadata]]屬性,該屬性是一個 Map 對象,該對象内 key 值内容對應目标元素上的 property 名(或為 undefined),那對應的值也是一個 Map 對象(該對象的 key/value 内容就是中繼資料的 key/value)—— 這其實就闡明了 metadata 存儲的資料結構,是了解所有 API 行為的基礎。
這裡所言“存在于對象下面的 [[Metadata]] 屬性下面”,一開始我認為是建立一個 Symbol('Metadata'),然而實際上并非如此,取而代之的是一個 WeakMap 中。主要是利用 WeakMap 不增加引用計數的特點,将對象作為 Key,中繼資料集合作為 Value,存到 WeakMap 中去,類似這樣的結構:
WeakMap<any, Map<any, Map<any, any>>>
從調用的角度來思考就會發現這樣的很合理:
weakMap.get(o).get(p).get(k)
先根據對象(object)擷取,然後在根據屬性(property),最後根據中繼資料(metadata)的 Key 擷取最終要的資料。
這裡再展開說明一下具體存儲的位置:
- 當在類 C 本身上使用 metadata 的時候,中繼資料會存儲在 C.[[Metadata]] 屬性中,其對應的 property 值是 undefined
- 定義在類 C 靜态成員上的中繼資料,那麼中繼資料會存儲在C.[[Metadata]] 屬性中,以該屬性(property)名作為 key
- 定義在類 C 執行個體成員上的中繼資料,那麼中繼資料會存儲在C.prototype.[[Metadata]] 屬性中,以該屬性(property)名作為 key
從資料結構上我們可以看出其設計理念也很清晰:給對象添加額外的資訊,但是不影響對象的結構 —— 這一點很重要,當你給對象添加了一個原資訊的時候,對象是不會有任何的變化的,不會多 property,也不會有的 property 被修改了。但卻可以衍生出很多其他的用途(比如可以讓裝飾器擁有真正裝飾對象而不改變對象的能力。讓對象擁有更多語義上的功能)
▐ 内置方法API
在閱讀标準的時候,你會發現,其實每個對象會擁有一系列的内部方法 [[DefineOwnMetadata]], [[GetOwnMetadata]], [[HasOwnMetadata]] 方法,設計用意是:
- 這些内部方法可以被 Proxy 重載,支援額外的 trap
- 這些内部方法将調用一組抽象操作來定義和讀取中繼資料
雙方括号代表這是 JavaScript 引擎内部使用的屬性/方法,一般是可以 console 到控制台中可以幫助 debug(點一下[[FunctionLocation]]就能跳到定義,點一下[[Scopes]]就能檢視閉包,點選 [[proto]] 檢視原型鍊等等),但是正常 JavaScript 代碼(就是我們平時寫代碼的時候)是取不到這些屬性的;
比如我們在控制台中輸出 Proxy 對象的時候,經常可以看到這樣的代碼:
參考文章
[1]. 中繼資料(MetaData)
http://www.ruanyifeng.com/blog/2007/03/metadata.html,阮一峰的文章,通俗易懂
[2]. 什麼是中繼資料?為何需要中繼資料?
https://www.zhihu.com/question/20679872,知乎文章
[3]. JavaScript Reflect Metadata 詳解
https://www.jianshu.com/p/653bce04db0b, XGHeaven。推薦,較為全面的一篇文章,文中很多内容都借鑒自此文。
[4]. reflect-metadata的研究
https://juejin.cn/post/6844904152812748807#heading-9,蔣禮銳. 源碼層面的解讀,推薦
[5]. Reflection in Typescript
https://radzserg.medium.com/reflection-in-typescript-af68a1536ea1, Sergey Radzishevskii.