天天看點

掌握 JS 進階程式設計基礎 - Reflect Metadata

原創 JSCON 淘系技術  6月9日

掌握 JS 進階程式設計基礎 - Reflect Metadata

曆史淵源和設計标準

我們知道,在 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 的訴求,簡單概括就是:

  1. 其他 C#、Java、Pythone 語言已經有的進階功能,我 JS 也應該要有(諸如C# 和 Java 之類的語言支援将中繼資料添加到類型的屬性或注釋,以及用于讀取中繼資料的反射API,而目前 JS 缺少這種能力)
  2. 許多用例(組合/依賴注入,運作時類型斷言,反射/鏡像,測試)都希望能夠以一緻的方式向類中添加其他中繼資料。
  3. 為了使各種工具和庫能夠推理出中繼資料,需要一種标準一緻的方法;
  4. 中繼資料不僅可以用在對象上,也可以通過相關捕獲器用在 Proxy 上
  5. 對開發人員來說,定義新的中繼資料生成裝飾器應該簡潔易用;

什麼是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 的四個概念:

  1. Metadata Key {Any}(後文簡寫 k)。中繼資料的 Key,對于一個對象來說,它可以有很多中繼資料,每一個中繼資料都對應有一個 Key。一個很簡單的例子就是說,你可以在一個對象上面設定一個叫做 'name' 的 Key 用來設定他的名字,用一個 'created time' 的 Key 來表示他建立的時間。這個 Key 可以是任意類型。在後面會講到内部本質就是一個 Map 對象。
  2. Metadata Value {Any} (後文簡寫 v)。中繼資料的類型,任意類型都行。
  3. Target {Object} (後文簡寫 o)。表示要在這個對象上面添加中繼資料
  4. 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 個類型相關的中繼資料。

掌握 JS 進階程式設計基礎 - Reflect Metadata

之是以會有,這是因為我們在 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)      

列印輸出為:

掌握 JS 進階程式設計基礎 - Reflect Metadata

但是要注意:

  1. 沒有裝飾的 target 是 get 不到這些 metadata 的
  2. 必須手動指定類型, 否則無法進行推斷。比如 method 方法如果不指定傳回值為 boolean, 那麼 t2 将會是 undefined
  3. 應用的順序為 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 對象的時候,經常可以看到這樣的代碼:

掌握 JS 進階程式設計基礎 - Reflect Metadata

參考文章

[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.