掌握 JS 高级编程基础 - Reflect Metadata

简介: 今天学习的 Reflect Metadata ,算是“高阶编程”范畴的知识,是 JS 中有关反射(元编程)相关知识的。

原创 JSCON 淘系技术  6月9日

640.gif


历史渊源和设计标准


我们知道,在 ES6 的规范当中,ES6 支持元编程,核心是因为提供了对 ProxyReflect 对象的支持。简单来说这个 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 个类型相关的元数据。


image.png

之所以会有,这是因为我们在 TS 中开启了 emitDecoratorMetadatahttps://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)

打印输出为:


image.png


但是要注意:

  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 对象的时候,经常可以看到这样的代码:


image.png


参考文章


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

相关文章
|
2月前
|
存储 JavaScript 前端开发
JavaScript编程实现tab选项卡切换的效果+1
JavaScript编程实现tab选项卡切换的效果+1
|
3月前
|
JavaScript 前端开发 编译器
解锁JavaScript模块化编程新纪元:从CommonJS的基石到ES Modules的飞跃,探索代码组织的艺术与科学
【8月更文挑战第27天】随着Web应用复杂度的提升,JavaScript模块化编程变得至关重要,它能有效降低代码耦合度并提高项目可维护性及扩展性。从CommonJS到ES Modules,模块化标准经历了显著的发展。CommonJS最初专为服务器端设计,通过`require()`同步加载模块。而ES Modules作为官方标准,支持异步加载,更适合浏览器环境,并且能够进行静态分析以优化性能。这两种标准各有特色,但ES Modules凭借其更广泛的跨平台兼容性和现代语法逐渐成为主流。这一演进不仅标志着JavaScript模块化的成熟,也反映了整个JavaScript生态系统的不断完善。
51 3
|
17天前
|
自然语言处理 JavaScript 前端开发
JavaScript闭包:解锁编程潜能,释放你的创造力
【10月更文挑战第25天】本文深入探讨了JavaScript中的闭包,包括其基本概念、创建方法和实践应用。闭包允许函数访问其定义时的作用域链,常用于数据封装、函数柯里化和模块化编程。文章还提供了闭包的最佳实践,帮助读者更好地理解和使用这一强大特性。
12 2
|
2月前
|
JavaScript 前端开发
JavaScript编程实现tab选项卡切换的效果
JavaScript编程实现tab选项卡切换的效果
|
2月前
|
JavaScript 前端开发
用JavaScript编程控制网页上checkbox选择状态:全选、全部取消、反选
用JavaScript编程控制网页上checkbox选择状态:全选、全部取消、反选
|
2月前
|
JavaScript 前端开发 安全
JavaScript编程实现字符和字符串翻转
JavaScript编程实现字符和字符串翻转
|
2月前
|
JavaScript 前端开发
用JavaScript编程定义二维数组并初始化,然后输出元素值
用JavaScript编程定义二维数组并初始化,然后输出元素值
|
3月前
|
JavaScript 前端开发 安全
揭秘TypeScript的魔力:它是如何华丽变身为JavaScript的超能英雄,让您的代码飞入全新的编程维度!
【8月更文挑战第22天】在Web开发领域,JavaScript是最主流的编程语言之一。但随着应用规模的增长,其类型安全和模块化的不足逐渐显现。为解决这些问题,微软推出了TypeScript,这是JavaScript的一个超集,通过添加静态类型检查来提升开发效率。TypeScript兼容所有JavaScript代码,并引入类型注解功能。
38 2
|
3月前
|
JavaScript 前端开发 Oracle
|
3月前
|
JavaScript 前端开发 开发者
震撼揭秘!JS模块化进化史:从混沌到秩序,一场代码世界的华丽蜕变,你怎能错过这场编程盛宴?
【8月更文挑战第23天】在 Web 前端开发领域,JavaScript 模块化已成为处理日益复杂的 Web 应用程序的关键技术。通过将代码分解成独立且可重用的模块,开发者能够更有效地组织和管理代码,避免命名冲突和依赖混乱。从最早的全局函数模式到 IIFE,再到 CommonJS 和 AMD,最终进化到了 ES6 的原生模块支持以及 UMD 的跨环境兼容性。本文通过具体示例介绍了这些模块化规范的发展历程及其在实际开发中的应用。
50 0