译自:Metaprogramming with proxies
Proxy 是什么
Proxy
使你能够拦截和定制对象执行的操作(如获取属性),是一种元编程特性。
下面的例子中,Proxy
是一个我们正在拦截操作的代理对象,handler
是处理拦截操作的对象。
在这个例子中,我们只拦截一个简单的操作: get
1 | const target = {} |
编程和元编程
在我们了解 Proxy
是什么,以及它为什么有用之前,应该先来了解下什么是元编程。
编程,可以分为以下两个级别:
- 基础级别(
Base level
也叫,程序级别),代码用来处理用户的输入,即上层应用级别的代码 - 元级别(
Meta level
),其中的代码用来处理基础基础级别的代码,更接近于底层
基础级别和元级别的代码可能来自于不同语言的,下面这段元程序代码中,元编程语言是 JavaScript
,基础编程语言是 Java
1 | const str = 'Hello' + '!'.repeat(3); |
元编程可以采取不同的形式,在先前的例子中,我们使用 console
打印了 Java
的代码。让我们使用 JavaScript
分别用于元编程语言和基础编程语言,典型的例子就是 eval()
函数,它能够动态的对 JavaScript
代码进行求值
或编译
。当然 eval()
在实际中并不太推荐使用,所以实际的应用场景并不多。
下面示例中,我们使用 eval()
对 5 + 2 进行求值运算
1 | console.log(eval(5 + 2)) // 7 |
其他的一些 JavaScript
看上去不像元编程,如果仔细看的话,它们确实是:
1 | // Base level |
这段程序在运行时,遍历自己的结构。这看起来不太像元编程,因为在 JavaScript
中,程序结构和数据结构的概念是比较模糊的。所有的 Object.*
的方法是可以被看做是元编程函数的
元编程的种类
反射元编程,是指程序处理自身的过程。三种反射元编程的区别:
- 内省(Introspection):运行时检查对象类型
- 自修改(Self-modification):可以用来改变程序结构
- 反射(Intercession):可以用来重新定义一些语言层面的操作
看一些例子
内省:Object.keys()
就是执行了自省操作,用来读取一个结构体(看看先前的例子)
自修改:下面的函数 moveProperty
把 source
属性转移到 target
中,通过括号操作符对属性进行访问、赋值操作符和删除操作符进行自修改。
1 | function moveProperty(source, propertyName, target) { |
ES5
中不支持调解,但是 Proxy
却填补了这一空白。
Proxy 用法
ES6
中的 Proxy
给 JavaScript
带来了反射(Intercession)
,它的工作原理如下,可以对对象 obj
执行许多操作,例如:
- 获取对象
obj
的prop
属性(obj.prop
) - 检查对象
obj
是否存在prop
属性('prop' in obj
)
Proxy
是一个特殊的对象,允许我们定制一些对象的操作, Proxy
构造函数需要两个参数:
target:如果处理程序
handler
没有任何拦截某个操作,那么该操作将会使用原目标对象(target
)的。也就是说target
为Proxy
提供了后备操作。某种程度上,Proxy
包装了目标对象target
handler:每个操作,都有相应的处理程序(
handler
)方法执行该操作,这种方法拦截操作,成为陷阱(trap)
(借用词汇:操作系统陷阱)
下面的例子中,handler
拦截了 get
和 has
操作:
1 | const target = {} |
handler
没有没有实现陷阱 set
,因此,设置 proxy.bar
将会指向 target
,并对 target.bar
进行设置
1 | proxy.bar = 'abc' |
函数专用的陷阱(trap)
如果 target
是一个函数,可以使用另外两个拦截操作:
applay 进行函数调用, 通过以下方式:
proxy()
proxy.call(···)
proxy.apply(···)
construct 构造函数调用,通过以下方式:
new proxy(···)
这两个 trap
只对函数有效的原因是很简单的,因为除此之外,你也不能将这些 trap
应用到其他对象上不是吗?
拦截方法调用
如果你想通过 Proxy
拦截一个方法调用,这将会是一个挑战:你可以拦截一个 get
操作,也可以拦截 apaly
操作,但是关于函数调用的拦截是没有的。
因为函数调用通常被看做两个分离的操作:首先 get
检索一个函数,然后使用 apply
调用函数
因此,我们必须拦截 get
,并返回一个拦截函数调用的函数。下面的代码演示了如何做到这一点:
1 | function traceMethodCall(obj) { |
让我们使用下面的 obj
来试验一下 traceMethodCalls()
1 | const obj = { |
可以看到,traceObj
是 obj
的一个函数被追踪版本。
很好的一点是,即使在 traceObj.squared()
中调用 this.traceObj
, 它同样也会被跟踪。这是因为 this
一直都在引用 proxy
当然,这又不是特别有效的方法方案,例如,可以使用缓存方法。此外,代理本身对性能有影响。
可撤销(revocable)的 Proxy
ES6
中,可以创建 可撤销的 Proxy
1 | const {proxy, revoke} = Proxy.revocable(target, handler); |
在运算符=
左边,我们使用解构
的方式得到 Proxy.revocable
返回的 proxy
和 revoke
首次调用 revoke()
后,作用于 proxy
的任何操作都将抛出异常,后续的 revoke()
调用,将没有任何效果:
1 | const target = {} |
将代理(Proxy)作为原型(prototype)
代理对象 proto
可以作为对象 obj
的原型,从 obj
执行的一些操作可能指向 proto
,例如下面的 get
操作:
1 | const proto = new Proxy({}, { |
在 obj
中并不存在 foo
属性,因此会继续向原型对象 proto
中查找,然后出发陷阱(tap) get
转发拦截操作
处理程序 hanlder
未实现陷阱trap
的操作,将自动转发的目标对象 target
。有时候除了转发操作,我们还想执行一些其他的操作,例如:handler
拦截了所有操作,并进行打印,但是不会去阻止操作专项目标对象 target
1 | const handler = { |
对于每个陷阱,我们首先记录操作的名称,然后通过手动执行将其转发。ES 6
有类似模块的对象 Reflect
,这有助于为每个陷阱(trap
)转发
1 | handler.trap(target, arg_1, ···, arg_n) |
Reflect
使用形式如下(其中trap
指代拦截的方法统称,非Reflect
的方法):
1 | Reflect.trap(target, arg_1, ···, arg_n) |
如果我们使用 Reflect
,那么先前的例子应该看起来像下面这样:
1 | const handler = { |
不是所有的对象都可以通过代理透明包装
handler
可以看作是对其目标对象(target
)执行的拦截操作——代理包装目标。代理(Proxy
)的处理程序对象(handler
)类似于代理的观察者或侦听器。它通过指定实现相应的方法(读取属性的get
等)来拦截哪些操作。如果缺少操作的处理程序方法,则不会拦截该操作。它只是被转发到目标。
因此,如果处理程序handler
是空对象,代理可以透明地包装目标。但是,这并不总是奏效的
包装对象会影响到 this
在深入之前,让我们快速回顾一下包装目标 target
是如何影响 this
的
1 | const target = { |
可以看到,如果使用 target
调用 target.foo()
,this
是指向 target
对象的;如果我们使用 proxy
调用 proxy.foo()
, this
是指向 proxy
对象的。
这样做的目的其实是,如果目标对象(target
) this
调用方法,那么 proxy
也会在循环中保持其 this
不变。
无法透明包装的对象
通常,代理的处理程序对象handler
为空对象时,代理将不会改变目标对象 target
的行为。
但是,如果目标对象 target
没有任何拦截(handler
为 {}
)的Proxy
包裹,那么我们会遇到一个问题:
事情会变的跟我们想的不一样,因为对象的信息关联依赖于目标对象target
是否被包装。
例如,下面 Person
对象的私有属性(WeakMap
) _name
1 | const _name = new WeakMap(); |
可以看到,具有私有属性的 Person
实例,被没有任何拦截的 Proxy
包装之后,会使我们访问不到私有属性。
1 | class Person2 { |
不适用私有属性,就不存上面说的问题。
包装内置构造函数的实例
大多数内置构造函数,也是不能被 Proxy
所包装的,他们因此也不能被显示的包装。例如 Date
构造函数:
1 | const target = new Date(); |
这种不受 Proxy
影响的机制称为内部槽
,这些内部槽是与实例相关联的伪属性。规范处理这些插槽,就好像它们是用方括号括起来的属性一样,例如,下面的方法是内部的,可以在所有对象 o 上调用:
1 | O.[[GetPrototypeOf]]() |
但是,访问内部插槽并不是通过正常的 get
和 set
操作进行的。如果 getDate()
是通过代理调用的,它就无法在 this
中找到它需要的内部槽位,并会报 TypeError
。
对于 Date
方法,语言规范声明:
除非另有明确说明,下面定义的 Number
原型对象的方法不是通用的,传递给它们的这个值必须是一个 Number
值或一个具有 [[NumberData]]
内部槽的对象,该槽已经初始化为一个 Number
值
数组可以被透明包装
与其他内置程序相比,数组可以透明包装。
1 | const proxy = new Proxy(new Array(), {}) |
之所以数组是可包装的,是因为尽管属性访问是定制的,以使 length
可被访问。数组方法不依赖于内部插槽——它们是通用的
可变通性
作为一种变通方式,我们可以更改处理程序如何转发方法调用,并有选择的设置 this
是指向 target
还是 proxy
1 | const handler = { |
这种方法的缺点是,该方法在此上执行的任何操作都不通过 proxy
。
Proxy 使用案例
下面用一些例子来展示 Proxy
的使用场景,并在实际应用中熟悉一下 Proxy
的 API
追踪属性访问(get, set)
假设我们有一个方法 tracePropAccess(obj, propKeys)
,每当设置或者获取 obj
中的属性(且该属性存在于 propKeys
数组中),我们便打印一些信息。在下面的代码中,我们将该函数应用于类 Point
的实例。
1 | class Point { |
设置或者获取被追踪的对象 point
会产生如下效果:
1 | > point.x |
有趣的是,无论何时 point
的属性被访问,追踪都将产生效果。因为 this
是始终指向 追踪对象(Proxy
)的,而不是 point
在 ES 5
中,我们可能会像下面这样实现 tracePropAccess(obj, propKeys)
1 | function tracePropAccess(obj, propKeys) { |
注意,我们正在破坏性地更改原始实现,这意味着我们是元编程。
在 ES 6
中,我们可以使用基于 Proxy
的解决方案:
1 | function tracePropAccess(obj, propKeys) { |
可以看到,我们只是拦截了 get
和 set
方法,并通过 Reflect
将方法转发给 obj
自身的调用,没有去改变 obj
原生方法的调用。
关于未知的警告(get, set)
当涉及到访问属性时,JavaScritp
是比较包容的。例如,当我们访问一个拼写错误或者不存在的属性时,程序将不会抛出异常,而只是返回一个 undefined
。因此如果,我们想要到达抛出异常的效果,不妨使用 Proxy
来实现。
1 | const PropertyChecker = new Proxy({}, { |
让我们使用 PropertyChecker
创建一个对象:
1 | const obj = { |
如果我们把 PropertyChecker
编程一个构造函数,那么我们可以在 ES 6
通过继承的方式在对象中使用:
1 | const proxy = new Proxy({}, { |
如果你担心对象属性被以外修改,有两个选择:
- 使用
Proxy
的陷阱方法set
对对象进行包装拦截 - 使用
Object.preventExtensions(obj)
来显示的设置obj
为不可扩展
数组的负值索引
一些数组方法允许通过 -1 引用最后一个元素,通过 -2 引用倒数第二个元素,等等,例如:
1 | > ['a', 'b', 'c'].slice(-1) |
但是,当我们直接使用方括号([]
)语法访问数组下标时,就没办法达到上述效果了。那么,我们可以使用 Proxy
来给数组添加这个能力。下面的 createArray()
创建的数组将支持负索引。
1 | function createArray(...elements) { |
数据绑定(set)
数据绑定是一种对象之间数据保持同步的方式。一个流行的用例是基于 MVC
模式的视图数据更新: 使用数据绑定,如果更改模型Model
(视图可视化的数据),视图将自动更新数据。
1 | function createObserverArray(callback) { |
访问基于 rest 风格的 web 服务
可以使用代理创建一个对象,在该对象上可以调用任意方法。在下面的示例中,函数 createWebService
就创建了这样的 service
对象。调用 service
上的方法检索具有相同名称的web服务资源的内容。检索结果是通过 ES 6
的 Promise
来处理的
1 | const service = createWebService('http://example.com/data'); |
下面我们看下相对复杂的 ES 5
的实现方式,在没有使用代理的情况下,为了事先定义好调用方法,我们需要传入一个包含调用方法名的 propKeys
数组,以保证 createWebService
创建出来的对象是存在调用的方法的。
1 | function createWebService(baseUrl, propKeys) { |
那么在 ES 6
中,我们通过使用 Proxy
来使实现变得更加简单(因为拦截了 get
方法的缘故,我们无需关心对象调用的方法是否存在,都将将会执行相应请求):
1 | function createWebService(baseUrl) { |
这两个实现都使用以下函数来发出 HTTP GET
请求
1 | function httpGet(url) { |
可撤销引用
可撤销引用的工作方式如下:
客户端不允许直接访问重要资源(对象),只能通过引用(中间对象,资源的包装器)。通常,作用于引用的每个操作都被转发到资源。客户端完成后,通过撤销引用(通过关闭引用)来保护资源。从此以后,对引用执行操作将抛出异常,而不再转发任何内容。
下面例子中,我们为资源创建一个可撤销的引用。然后,通过引用读取资源的一个属性。是能够正常访问到的,因为这时的引用是允许我们访问的。接下来,我们撤销(revoke
)引用。现在引用不再让我们访问属性了。
1 | const resource = { x: 11, y: 8 } |
Proxy
非常适合实现可撤销引用,因为它可以拦截和转发操作。这是createRevocableReference的一个简单的基于代理的实现:
1 | function createRevocableReference(target) { |
可以通过前一节中的代理处理程序(proxy-as-handler
)技术简化代码。这一次,处理程序基本上是 Reflect
对象。因此,get
陷阱通常返回相应的 Reflect
方法。如果引用已被撤销,则会抛出类型错误。
1 | function createRevocableReference2(target) { |
然而,我们不必自己实现可撤销引用,因为ECMAScript 6
允许我们创建可撤销(revocable
)的proxy
。这一次,撤销发生在代理proxy
中,而不是在处理程序handler
中。处理程序所要做的就是将每个操作转发给目标。正如我们所看到的,如果处理程序不实现任何陷阱,就会自动发生这种情况。
1 | function createRevocableReference3(target) { |
Proxy 的全部API
1 | interface ProxyHandler<T extends object> { |