这就是我们今天要做的事情:
var obj = new Proxy({}, {
get: function (target, key, receiver) {
console.log(`getting ${key}!`);
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
console.log(`setting ${key}!`);
return Reflect.set(target, key, value, receiver);
}
});
把这段代码当成第一个例子对你来说有点复杂。稍后我会解释所有部分。现在,看看我们创建的对象:
> obj.count = 1;
setting count!
> ++obj.count;
getting count!
setting count!
2
这是怎么回事?我们正在拦截这个对象的属性访问。我们重载了.
操作符。
代理是如何实现的?
计算机技术中中最好的技巧叫做虚拟化。这是一种非常通用的技术,用于做令人惊讶的事情。下面是它的工作原理。
- 随便拿一张照片:
- 在这个照片中随便把某个东西圈起来:
- 白色圆圈把照片分成两部分:圈内和圈外。现在我们可以把圈内或者圈外的部分用其他任何东西替换掉。只有一个规则,向后兼容规则。
如上图,圈内部分被替换,圈内的替代者必须表现得像以前一样,以至于圈外的人注意到有什么变化。
在经典的计算机科学电影中,比如《楚门的世界》(The Truman Show)和《黑客帝国》(The Matrix),你会对这种黑客手法很熟悉。在这些电影中,一个人处于“圈内”,而世界的其余部分被一种精心设计的正常幻觉所取代。
为了满足向后兼容性规则,替代者可能需要巧妙地设计。但真正的技巧在于画出正确的“圈”。
所谓“圈”,就是API的边界
—— 一个接口。接口指定了两段代码如何交互以及每个部分对另一部分的期望。因此,如果一个接口被设计到系统中,那么边界
就已经为你画好了。你知道你可以替换任何一方,而另一方不会在意。
当没有现成的接口时,你就必须发挥创造性。一些最酷的软件专家一直在以前没有API边界的地方绘制API边界,并通过巨大的工程努力把接口创造出来。
虚拟内存、硬件虚拟化、Docker、Valgrind等等在不同程度上,所有这些项目都涉及到在现有系统中创造出新的和出乎意料的接口。在某些情况下,需要花费数年时间和新的操作系统特性,甚至是新的硬件才能使新接口正常工作。
最好的虚拟化技术人员会对正在被虚拟化的东西有新的理解。要为某些东西编写API,你必须理解它。一旦你理解了,你就能做出惊人的事情。
ES6引入了对JavaScript最基本概念——对象的虚拟化支持。
什么是对象?
花点时间。考虑考虑。当你知道什么是对象时向下滚动。
这个问题对我来说太难了!我从来没听过一个真正令人满意的定义。
这是意外吗?定义基础概念一直非常困难,看看在《欧几里得元素》中,最初的几个定义是如何做出的。因此,当ECMAScript语言规范没有其他帮助性概念时,只能将对象定义为“object类型的成员”,这就是一个很好的例子。
后来,规范补充说:“对象是属性的集合。”这还算是个不错的定义。如果你想要一个定义,现在就这个基本可以了。
我之前说过,要为一个对象写一个API,你必须理解它。所以在某种程度上,我承诺过,如果我们完成了对象API和接口的编写,我们将更好地理解Object,我们将能够做令人惊奇的事情。
因此,让我们跟随ECMAScript标准委员会的脚步,看看如何为JavaScript对象定义API和接口。我们需要什么样的方法?Object能做什么?
这多少取决于Object。DOM元素对象可以做某些事情;AudioNode对象做其他事情。但是有一些基本的能力是所有Object都共有的:
- 对象有属性。可以获取和设置属性、删除它们等等。
- 对象有原型(prototypes)。这就是继承在JS中的工作方式。
- 有些对象是函数或构造函数。你可以调用他们。
几乎所有JS程序对Object的处理都是使用属性、原型和函数完成的。甚至Element或AudioNode对象的特殊行为也可以通过调用方法来访问,这些方法只是继承了函数属性。
因此,当ECMAScript标准委员会定义了一组14个内部方法(所有对象的通用接口)时,他们最终聚焦于这三个基本的东西就不足为奇了。
完整的列表可以在ES6标准的表5和表6中找到。在这里我只描述一些。奇怪的双括号[[]]
强调这些是内部方法,对普通JS代码是隐藏的。不能像普通方法那样调用、删除或覆盖这些方法。
-
obj.[[Get]](key, receiver)
获取属性的值。当JS代码执行obj.prop
或obj[key]
时调用。obj
是当前搜索的对象;receiver
是我们第一次开始搜索这个属性的对象。有时我们必须搜索几个对象。obj
可能是receiver
原型链上的一个对象。 -
obj.[[Set]](key, value, receiver)
赋值给对象的属性。当JS代码执行obj.prop = value
或obj[key] = value
时调用。在类似obj.prop += 2
这样的赋值中,[[Get]]
方法先被调用,然后又调用了[[Set]]
。++
和--
也是如此。 -
obj.[[HasProperty]](key)
检查属性是否存在。当JS代码执行key in obj
时调用。 -
obj.[[Enumerate]]()
列出obj的可枚举属性。当JS代码执行for (key in obj) ...
时调用。这将返回一个迭代器对象,这就是for-in
循环获取对象属性名的方式。 -
obj.[[GetPrototypeOf]]()
返回obj对象的原型。当JS代码执行obj.__proto__
或Object.getPrototypeOf(obj)
时调用。 -
functionObj.[[Call]](thisValue, arguments)
调用一个方法。当JS代码执行functionObj()
或x.method()
时调用。 -
constructorObj.[[Construct]](arguments, newTarget)
调用一个构造函数。当JS代码执行new Date(2890, 6, 2)
类似的代码时调用。newTarget参数
在这里扮演了子类的作用。
在整个ES6标准中,只要有可能,对Object做任何事情的语法或内置函数都是根据14个内部方法来实现的。ES6在Object的核心周围划出了清晰的边界。代理
可以让您用任意的JS代码替换标准类型的Object核心部分。
当我们开始讨论重写这些内部方法时,记住,我们讨论的是重写核心语法的行为,比如obj.prop
、Object.keys()
等内置函数。
Proxy 代理
ES6定义了一个新的全局构造函数Proxy。它有两个参数:一个目标对象(target)和一个处理程序对象(handler)。一个简单的例子如下:
var target = {}, handler = {};
var proxy = new Proxy(target, handler);
Proxy - target对象
我可以用一句话告诉你proxy
的行为:所有Proxy的内部方法都被转发到target对象。也就是说,如果某个函数调用proxy.[[Enumerate]]()
,JS会执行target.[[Enumerate]]()
。
我们将做一些导致proxy.[[Set]]()
被调用的事情。
proxy.color = "pink";
刚刚发生了什么?proxy.[[Set]]()
应该已经调用了target.[[Set]]()
,所以应该已经在target
上创建了一个新属性。
> target.color
"pink"
在大多数情况下,这个proxy
的行为与它的target
完全相同。这种错觉的真实性是有限度的。你会发现proxy !== target
。proxy
有时会通不过target
通过的类型检查。例如,即使proxy
的target
是一个DOM元素,proxy
也不是一个真正的元素;所以像document.body.appendChild(proxy)
这样的代码会因为TypeError
而失败。
Proxy - handler对象
现在让我们回到处理程序对象。这就是让代理变得有用的地方。处理程序对象(handler object)的方法可以覆盖代理(proxy)的任何内部方法。
例如,如果你想拦截所有对对象属性
赋值的尝试,你可以通过定义handler.set()
方法来实现:
var target = {};
var handler = {
set: function (target, key, value, receiver) {
throw new Error("Please don‘t set properties on this object.");
}
};
var proxy = new Proxy(target, handler);
> proxy.name = "angelina";
Error: Please don‘t set properties on this object.
处理程序方法的完整列表记录在Proxy的MDN页面上。有14个方法,它们与ES6中定义的14个内部方法一致。所有处理程序方法都是可选的。如果内部方法没有被处理程序拦截,那么它将被转发到target,就像我们前面看到的那样。
示例1:不可思议的自动创建对象
我们现在对代理有足够的了解,可以尝试用它们来做一些非常奇怪的事情,一些没有代理就不可能做的事情。这是我们的第一个练习。创建一个函数Tree()
,能做这些事情:
> var tree = Tree();
> tree
{ }
> tree.branch1.branch2.twig = "green";
> tree
{ branch1: { branch2: { twig: "green" } } }
> tree.branch1.branch3.twig = "yellow";
{ branch1: { branch2: { twig: "green" },
branch3: { twig: "yellow" }}}
注意所有中间对象branch1
、branch2
和branch3
是如何在需要时神奇地自动创建的。方便,对吗?这怎么可能呢?直到现在,这一切都不可能成功。但是对于代理,这只需要几行代码。我们只需要利用tree.[[Get]]()
。如果你喜欢挑战,你可能会想在继续阅读之前尝试一下。
下面是我的方案:
function Tree() {
return new Proxy({}, handler);
}
var handler = {
get: function (target, key, receiver) {
if (!(key in target)) {
target[key] = Tree(); // 自动创建一个子tree
}
return Reflect.get(target, key, receiver);
}
};
注意最后对Reflect.get()
的调用。事实证明,在代理处理程序方法(handler)中,有一种非常常见的需求,即能够达到这样的效果:“现在只需执行委托给目标target的默认行为”。所以ES6定义了一个新的Reflect对象,其中有14个方法,你可以用它们来完成这个任务。
示例2:只读视图
我想我可能给人留下了代理很容易使用的错误印象。再举一个例子,看看是否正确。这一次,我们的赋值更加复杂:我们必须实现一个函数readOnlyView(object)
,它接受任何对象,并返回一个行为与该对象类似的代理,只是不能改变这个对象。例如,它应该是这样的:
> var newMath = readOnlyView(Math);
> newMath.min(54, 40);
40
> newMath.max = Math.min;
Error: can‘t modify read-only view(只读视图)
> delete newMath.sin;
Error: can‘t modify read-only view(只读视图)
我们如何实现它?
第一步是拦截所有内部方法,如果我们允许它们通过,这些方法将修改目标对象。有五个这样的方法:
function NOPE() {
throw new Error("can‘t modify read-only view");
}
var handler = {
// Override all five mutating methods.
set: NOPE,
defineProperty: NOPE,
deleteProperty: NOPE,
preventExtensions: NOPE,
setPrototypeOf: NOPE
};
function readOnlyView(target) {
return new Proxy(target, handler);
}
这样就起作用了。它阻止了通过只读视图进行赋值、属性定义等操作。这个计划有漏洞吗?
最大的问题是[[Get]]方法
和其他方法
仍然可能返回可变对象。因此,即使某个对象x是只读视图,x.prop
也可以是可变的!那是个大漏洞。要填补这个漏洞,我们必须添加一个handler.get()
方法:
var handler = {
...
// 把可能的结果都包装成只读
get: function (target, key, receiver) {
// 最开始,执行默认的行为get
var result = Reflect.get(target, key, receiver);
// 确保get返回的是一个不可变对象
if (Object(result) === result) {
// 返回结果是一个对象
return readOnlyView(result);
}
// 返回结果是一个简单类型,已经是不可变的
return result;
},
...
};
这还不够。其他方法也需要类似的代码,包括getPrototypeOf
和getOwnPropertyDescriptor
。然后还有更多的问题。当通过这种代理调用getter
或其他方法
时,传递给getter
或其他方法
的this
值通常是代理proxy
本身。但是正如我们前面看到的,许多访问器
和方法
执行了代理无法通过的类型检查。最好在这里用目标对象target
代替代理proxy
。你知道怎么做吗?
从中得到的教训是,创建代理很容易,但创建具有直觉行为的代理却相当困难。
其他小细节
-
代理真正有用的是什么?
1.当你希望观察或记录对对象
的访问时,它们将便于调试。测试框架可以使用它们来创建模拟对象。
2.如果你需要稍微超出普通对象能力的行为:例如惰性填充属性
,代理就很有用。
3.我讨厌提出这个问题,但要了解使用代理的代码中发生了什么,最好的方法之一是将代理的处理程序对象
包装在另一个代理
中,该代理每次访问处理程序方法时都会记录到控制台。
4.代理可以用来限制对对象的访问,就像我们对readOnlyView
所做的那样。 -
代理中利用WeakMap
在我们的readOnlyView
示例中,每次访问一个对象(例如.branch1
)时,我们都会创建一个新的代理。把我们创建的每个代理都缓存在WeakMap
中可以节省大量内存,因此无论一个对象被传递给readOnlyView
多少次,都只会为它创建一个代理。这是WeakMap
的一个好用例。 -
可撤销的代理
ES6还定义了另一个函数Proxy.revocable(target, handler)
。它创建一个代理,就像new Proxy(target, handler)
一样,只是这个代理可以稍后撤销
。(Proxy.revocable
返回一个带有.proxy
属性和.revoke
方法的对象。)一旦代理被撤销,它就不再工作了;它的所有内部方法都回收。 -
对象一致性(特性不变)
在某些情况下,ES6需要代理处理程序方法
来报告与目标对象
状态一致的结果。它这样做是为了在所有对象(甚至是代理)中强制执行关于不变性的规则。例如,代理不能声明为是不可扩展的,除非它的目标确实不可扩展。
确切的规则太复杂了,不能在这里详细说明,但如果您曾经看到过类似“代理不能将不存在的属性报告为不可配置的”(proxy can‘t report a non-existent property as non-configurable)这样的错误消息,这就是原因所在。最有可能的补救办法是改变代理报告本身的内容。另一种可能性是在动态中改变目标,以反映代理报告的内容。
现在我们知道什么是Object对象了吗?
我们刚刚说的是:“对象是属性的集合。”
我并不完全满意这个定义,甚至认为我们理所当然地加入了原型
和可调用性
。我认为“集合”
这个词太过慷慨了,因为代理的定义很糟糕。它的处理程序方法可以做任何事情。他们可以返回随机结果。
通过弄清楚对象可以做什么,对这些方法进行标准化,并将虚拟化添加为每个人都能使用的一流特性,ECMAScript标准委员会已经扩展了可能性领域。
对象现在几乎可以是任何东西。
对于“什么是对象”这个问题,也许最诚实的答案是:现在把12个必需的内部方法作为一个定义。对象是JS程序中具有[[Get]]
操作、[[Set]]
操作等等的一种东西。