JavaScript 中的元编程

元编程

在日常业务开发过程中,我们的代码接收并处理用户的输入,实现对应业务逻辑,然后对响应给用户,这也是我们通常意义上说的业务程序。

而所谓元编程(meta programming),就是能够操作代码的代码。说的更直接一点,也就是让我们的代码在运行过程中有能力来访问和操作程序所定义的类、对象、函数和变量等等。

抛开具体实现方式来讨论,我们通常认为一门编程语言的元编程能力主要包括以下三个方面:

  • 自省(Introspection): 读取程序对象数据信息的能力,例如程序本身的结构等等。
  • 自举(Self-modification):动态修改程序自身对象的能力,例如动态地为某个对象增加一个属性。
  • 代理(Intercession):动态代理或者自定义对象原有行为的能力。

不同编程语言的元编程能力差异很大,对上面所说的三个方面支持程度和实现方式也大不相同,例如java提供了反射机制,C++采用了模板等等。 JavaScript的对象体系自身具有高度的动态性,我们很多日常用到的ObjectAPI对于对象的操作实质上都属于元编程的范畴。

2. Proxy

ES6 新引入的 Proxy 为JavaScript提供了运行阶段重新定义对象默认行为的能力,也就是上面说到的Intercession。 通过Proxy,我们可以拦截到外部对于该对象的访问和操作,这种机制为我们提供了重新定义对象上的各种默认行为的能力。

Proxy构造函数可以创建一个对象的proxy实例,它接收两个参数:

let objProxy=new Proxy(
  target, 
  handler 
)
  • target: proxy 所要拦截和代理的目标对象
  • handler:对目标对象每个具体的操作,handler对象中都对应了一个具体的操作方法(trap),覆写该方法就可以重新定义该操作

更多关于如何使用Proxy的知识可以参考ECMAScript6 Proxy

3. 设计思想

3.1 分层:将业务编程和元编程分离

JavaScript中,元编程和通用业务编程在语法和API调用方面并没有很明显的使用界限。 举个例子:

const object1 = {a:1};
console.log(object1.hasOwnProperty('a'));
// expected output: true

hasOwnProperty方法可以判断对象中是否具有指定的属性,这在分类上明显是属于元编程层面上的API。 在JavaScript的业务代码中我们可以像对象本身的方法一样很方便的调用。

问题在于,如果业务编程和元编程操作不加以区分,让业务代码中可以轻易地访问到这类API就有可能埋下意外Bug, 举个例子:

object1.hasOwnProperty=null;
console.log(object1.hasOwnProperty('a'));
//Error: object1.hasOwnProperty is not a function

这里object1对象的hasOwnProperty方法被显式地置为null,后续的调用就会报错。

为了规避这种情况的出现,Proxy 在设计上刻意地将业务层面的API和元编程层面的API作了区分和隔离,元编程层面的操作都封装到了Handler中,而暴露给业务代码层面的只是Proxy对象,避免了上述这种问题。

3.2 代理对象与操作拦截

在设计原则上,Proxy对于target对象的封装是完全透明的:我们无法直接判断一个对象是否是一个Proxy对象。 另外,我们也无法直接访问到一个Proxy对象的handler方法。

如果我们想知道一个对象是否被代理掉,只得自己去实现相应的记录操作:统一创建、记录和维护。

通常情况下,Proxy的应用场景可以分为两类:

  • 作为target对象的封装器: 一个非常简易的“代理模式”,通过拦截对target的访问操作,来改变target对象的原有行为。

  • 将Proxy对象当做一个虚拟对象来使用: 这种场景下,target对象本身只是为了创建proxy对象。 在proxy对象的handler中定义了一些特定的行为操作,当外部调用这些操作的时候,会被拦截并执行对应的操作。被封装的target对象对于这些特殊的操作是完全无感知的。

3.3 元对象协议

ECMAScript规范中规定了JavaScript是如何执行的,这其中包括了如何操作对象的协议 —— 元对象协议(MOP)。 协议中定义了能够在对象上进行的操作,以及执行这些操作的规则。 更具体一点,MOP定义了13个最基础的内置方法(internal method)

[[GetPrototypeOf]]()
[[SetPrototypeOf]](V)
[[IsExtensible]]()
[[PreventExtensions]]()
[[GetOwnProperty]](P)
[[DefineOwnProperty]](P, Desc)
[[HasProperty]](P)
[[Get]](P, Receiver)
[[Set]](P, V, Receiver)
[[Delete]](P)
[[OwnPropertyKeys]]()
[[Call]]()
[[Construct]]()

我们对于一个对象进行任意的操作,都会被转换为这些内置方法的执行。

事实上,Proxy之所以具有重新定义对象默认行为的能力,就是因为JavaScript引擎允许使用handler中的trap来自定义内置方法。 handler中可以被覆写的trap与上述的内置方法一一对应。

在保持灵活性的同时,为了避免由于滥用内置方法产生不可预知的Bug, ES标准规范也设定了一系列的限制,例如限制函数的返回值类型等等。

Reflect

在实际使用过程中,我们很多场景都是希望Hook到target对象的某些操作,在不改变target对象默认行为的情况下,自定义一些额外的操作。 这个时候Reflect对象所提供的API就非常有用了.

Reflect对象提供的13个静态方法和Proxy实例中的13个trap一一对应, 它保留了对应trap的默认行为。

总结

JavaScript在先前的设计中并没有将业务与元编程层面的API进行隔离, 我们在编写业务层面的代码时也常常会使用JavaScript在自省和自举的动态方面的能力。 而对于动态地Hook掉对象的方法这种操作,往往会在框架开发中使用的比较频繁。