ES6 深入 是一系列关于在 ECMAScript 标准第 6 版(简称 ES6)中添加到 JavaScript 编程语言的新功能的文章。
今天我们要做的事情是这样的。
<pre>
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);
}
});
</pre>
对于第一个示例来说,这有点复杂。我会稍后解释所有部分。现在,看看我们创建的对象
<pre>
> obj.count = 1;
setting count!
> ++obj.count;
getting count!
setting count!
2
</pre>
这里发生了什么?我们正在拦截对该对象属性的访问。我们正在重载“.”运算符。
它是如何完成的
计算中最棒的技巧叫做虚拟化。这是一种非常通用的技术,可以用来完成惊人的事情。以下是它的工作原理。
-
随便拿一张照片。
照片来源:Martin Nikolaj Bech -
在图片中围绕某物画一个轮廓。
-
现在用一些完全意想不到的东西替换轮廓内部或外部的所有东西。只有一条规则,向后兼容规则。你的替换必须与之前的东西足够相似,以至于边界另一侧的人不会注意到有任何变化。
照片来源:Beverley Goodwin.
你会从经典的计算机科学电影中熟悉这种黑客技术,比如《楚门的世界》和《黑客帝国》,一个人在轮廓内,而世界其他地方则被替换为一个精心制作的正常幻觉。
为了满足向后兼容规则,你的替换可能需要巧妙地设计。但真正的技巧在于画出正确的轮廓。
我所说的轮廓指的是 API 边界。一个接口。接口指定两段代码如何交互以及每部分对另一部分的期望。因此,如果在系统中设计了接口,那么轮廓就已经为你画好了。你知道你可以替换任何一方,而另一方不会在意。
当你没有现有的接口时,你需要发挥创意。历史上一些最酷的软件黑客技术都涉及到在之前没有接口的地方绘制 API 边界,并通过惊人的工程努力使该接口成为现实。
虚拟内存、硬件虚拟化、Docker、Valgrind、rr——在不同程度上,所有这些项目都涉及到将新的、相当意想不到的接口驱动到现有系统中。在某些情况下,需要花费数年时间以及新的操作系统功能,甚至新的硬件才能使新边界良好地运行。
最好的虚拟化黑客技术带来了对正在虚拟化的内容的新理解。要为某物编写 API,你必须理解它。一旦你理解了,你就能做一些不可思议的事情。
ES6 引入了对 JavaScript 最基本概念(对象)的虚拟化支持。
什么是对象?
不,真的。花点时间思考一下。当你了解什么是对象时,向下滚动。

这个问题对我来说太难了!我从未听说过一个真正令人满意的定义。
令人惊讶吗?定义基本概念总是很难——看看欧几里得《几何原本》中前几个定义。因此,当 ECMAScript 语言规范毫无帮助地将对象定义为“Object 类型的一个成员”时,它就在好公司之中。
后来,规范补充说“对象是属性的集合”。这还不错。如果你想要一个定义,现在就足够了。我们以后再回来讨论它。
我之前说过,要为某物编写 API,你必须理解它。 因此,从某种意义上说,我已经承诺,如果我们能克服所有这些困难,我们就能更好地理解对象,并且能够做一些不可思议的事情。
所以,让我们追随 ECMAScript 标准委员会的脚步,看看定义 JavaScript 对象的 API(接口)需要什么。我们需要哪些方法?对象能做什么?
这在一定程度上取决于对象。DOM 元素对象可以做某些事情;AudioNode 对象可以做其他事情。但是,所有对象都有一些基本的能力。
- 对象具有属性。你可以获取和设置属性、删除属性等等。
- 对象具有原型。这就是 JS 中继承的工作原理。
- 一些对象是函数或构造函数。你可以调用它们。
JS 程序对对象的几乎所有操作都是使用属性、原型和函数完成的。即使是 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.<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/proto" target="_blank">__proto__</a>
或<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getPrototypeOf" target="_blank">Object.getPrototypeOf</a>(obj)
。 -
functionObj.[[Call]](thisValue, arguments) – 调用函数。
当 JS 代码执行以下操作时调用:
functionObj()
或x.method()
。可选。并非所有对象都是函数。
-
constructorObj.[[Construct]](arguments, newTarget) – 调用构造函数。
当 JS 代码执行以下操作时调用:例如,
new Date(2890, 6, 2)
。可选。并非所有对象都是构造函数。
newTarget 参数在子类化中起作用。我们将在以后的文章中介绍它。
也许你可以猜到其他七个。
在整个 ES6 标准中,只要可能,任何对对象进行操作的语法或内置函数都将根据 14 个内部方法进行定义。ES6 在对象的“大脑”周围画了一个清晰的边界。代理让你能够做的就是用任意 JS 代码替换标准的“大脑”。
当我们稍后开始讨论覆盖这些内部方法时,请记住,我们正在讨论覆盖类似 obj.prop
的核心语法、类似 <a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/keys" target="_blank">Object.keys()</a>
的内置函数等等。
代理
ES6 定义了一个新的全局构造函数,<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy" target="_blank">Proxy</a>
。它接受两个参数:一个目标对象和一个处理器对象。因此,一个简单的示例看起来像这样
<pre>
var target = {}, handler = {};
var proxy = new Proxy(target, handler);
</pre>
让我们暂时把处理器对象放在一边,重点关注proxy 和 target 的关系。
我可以一句话告诉你proxy 将如何表现。proxy 的所有内部方法都将转发到 target。也就是说,如果某物调用 proxy.[[Enumerate]](),它只会返回 target.[[Enumerate]]()。
让我们试一试。我们将做一些导致调用 proxy.[[Set]]() 的操作。
<pre>
proxy.color = "pink";
</pre>
好的,刚刚发生了什么?proxy.[[Set]]() 应该已经调用了 target.[[Set]](),因此应该在 target 上创建了一个新的属性。是吗?
<pre>
> target.color
"pink"
</pre>
是的。其他所有内部方法也一样。这个代理在大多数情况下将与它的目标表现完全一样。
这种幻觉的保真度是有限制的。你会发现 proxy !== target
。并且代理有时会通过目标会通过的类型检查。例如,即使代理的目标是 DOM 元素,代理也不真正是一个元素;因此,像 document.body.appendChild(proxy)
这样的操作将以 TypeError
失败。
代理处理器
现在让我们回到处理程序对象。这就是代理有用的原因。
处理程序对象的方法可以覆盖代理的任何内部方法。
例如,如果你想拦截所有对对象属性赋值的尝试,你可以通过定义一个 `<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/set" target="_blank">handler.set()</a>` 方法来做到这一点。
<pre>
var target = {};
var handler = {
set: function (target, key, value, receiver) {
throw new Error("请不要在这个对象上设置属性。");
}
};
var proxy = new Proxy(target, handler);
> proxy.name = "angelina";
Error: 请不要在这个对象上设置属性。
</pre>
处理程序方法的完整列表在 MDN 的 `Proxy` 页面上记录。有 14 种方法,它们与 ES6 中定义的 14 种内部方法一致。
所有处理程序方法都是可选的。如果内部方法没有被处理程序拦截,那么它将被转发到目标,就像我们之前看到的那样。
示例:“不可能”自动填充对象
我们现在对代理有了足够的了解,可以尝试使用它们来做一些非常奇怪的事情,一些没有代理就无法做到的事情。
这是我们的第一个练习。创建一个可以做以下事情的函数 `Tree()`:
<pre>
> 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" }}}
</pre>
注意所有中间对象 `branch1`、`branch2` 和 `branch3` 在需要时是如何神奇地自动创建的。方便吧?它怎么可能起作用呢?
到目前为止,没有办法让它起作用。但有了代理,这只需要几行代码。我们只需要利用 `tree`.[[Get]]()。如果你喜欢挑战,你可能想在继续阅读之前尝试自己实现它。

这是我的解决方案
<pre>
function Tree() {
return new Proxy({}, handler);
}
var handler = {
get: function (target, key, receiver) {
if (!(key in target)) {
target[key] = Tree(); // 自动创建一个子树
}
return Reflect.get(target, key, receiver);
}
};
</pre>
请注意最后对 `Reflect.get()` 的调用。事实证明,在代理处理程序方法中,有一种非常常见的需求是能够说“现在只需执行将操作委托给 `target` 的默认行为”。因此 ES6 定义了一个新的 `Reflect` 对象,它上面有 14 种方法,你可以使用它们来实现这一点。
示例:只读视图
我认为我可能给出了代理易于使用的错误印象。让我们再做一次示例来验证一下是否属实。
这一次我们的任务更复杂:我们必须实现一个函数 `readOnlyView(object)`,该函数接收任何对象并返回一个代理,该代理的行为与该对象完全相同, *除了* 无法对其进行修改。因此,例如,它应该表现得像这样:
<pre>
> var newMath = readOnlyView(Math);
> newMath.min(54, 40);
40
> newMath.max = Math.min;
Error: 无法修改只读视图
> delete newMath.sin;
Error: 无法修改只读视图
</pre>
我们该如何实现它呢?
第一步是拦截所有内部方法,如果我们让它们通过,它们会修改目标对象。共有五种这样的方法。
<pre>
function NOPE() {
throw new Error("无法修改只读视图");
}
var handler = {
// 覆盖所有五种修改方法。
set: NOPE,
defineProperty: NOPE,
deleteProperty: NOPE,
preventExtensions: NOPE,
setPrototypeOf: NOPE
};
function readOnlyView(target) {
return new Proxy(target, handler);
}
</pre>
这有效。它通过只读视图阻止赋值、属性定义等。
这种方案是否存在任何漏洞呢?
最大的问题是 [[Get]] 方法和其他方法仍然可能返回可变对象。因此,即使某些对象 `x` 是只读视图,`x.prop` 也可能是可变的!这是一个巨大的漏洞。
为了修复它,我们必须添加一个 `handler.get()` 方法
<pre>
var handler = {
...
// 将其他结果包装在只读视图中。
get: function (target, key, receiver) {
// 从执行默认行为开始。
var result = Reflect.get(target, key, receiver);
// 确保不要返回可变对象!
if (Object(result) === result) {
// result 是一个对象。
return readOnlyView(result);
}
// result 是一个原始类型,因此已经是不可变的。
return result;
},
...
};
</pre>
这也不够。其他方法也需要类似的代码,包括 `getPrototypeOf` 和 `getOwnPropertyDescriptor`。
然后还存在其他问题。当 getter 或方法通过这种类型的代理调用时,传递给 getter 或方法的 `this` 值通常是代理本身。但正如我们之前看到的,许多访问器和方法执行类型检查,代理无法通过。在这里用目标对象替换代理会更好。你能想出如何做吗?
从中学到的经验是,创建代理很容易,但创建具有直观行为的代理却非常困难。
零零碎碎
-
代理到底擅长什么?
当你想观察或记录对对象的访问时,它们当然很有用。它们对于调试会很方便。测试框架可以使用它们来创建 模拟对象。
如果你需要普通对象无法提供的行为,代理很有用:例如延迟填充属性。
我几乎不愿意提起这件事,但要查看使用代理的代码的最佳方法之一是……将代理的处理程序对象包装在 *另一个代理* 中,该代理在每次访问处理程序方法时都会向控制台记录日志。
代理可以用来限制对对象的访问,就像我们在 `readOnlyView` 中所做的那样。这种用例在应用程序代码中很少见,但 Firefox 在内部使用代理来实现 不同域之间的安全边界。它们是我们安全模型的关键部分。
-
**代理 ♥ WeakMaps。** 在我们的 `readOnlyView` 示例中,我们每次访问对象时都会创建一个新的代理。将我们创建的每个代理缓存到 `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”` 这样的错误消息,那就是原因。最可能的解决方法是更改代理报告自身的方式。另一种可能性是在运行时修改目标以反映代理报告的任何内容。
现在什么是对象?
我认为我们最后说的是:“对象是属性的集合。”
我对这个定义并不完全满意,即使我们假设我们也添加了原型和可调用性。我认为“集合”这个词过于宽泛,因为代理的定义很差。它的处理程序方法可以做任何事情。它们可以返回随机结果。
通过弄清楚对象可以做什么,标准化这些方法,并将虚拟化作为每个人都可以使用的头等功能添加进来,ECMAScript 标准委员会已经扩展了可能性范围。
对象现在几乎可以是任何东西。
也许现在对“什么是对象?”这个问题最诚实的回答是将 12 种必需的内部方法作为定义。对象是在 JS 程序中具有 [[Get]] 操作、[[Set]] 操作等的对象。
在经历了这一切之后,我们是否对对象有了更好的理解?我不确定!我们是否做了惊人的事情?是的。我们做了一些以前在 JS 中从未可能实现的事情。
我今天可以使用代理吗?
不!至少在 Web 上不行。只有 Firefox 和 Microsoft Edge 支持代理,并且没有 polyfill。
在 Node.js 或 io.js 中使用代理需要一个默认情况下禁用的选项(`--harmony_proxies`) *和* harmony-reflect polyfill,因为 V8 实现了一个较旧版本的 `Proxy` 规范。(本文的早期版本中包含有关此问题的错误信息。感谢 Mörre 和 Aaron Powell 在评论中纠正了我的错误。)
因此,请随意尝试使用代理!创建一个镜像殿堂,在那里似乎存在成千上万个所有对象的一模一样的副本,而且无法调试任何东西!现在是时候了。你的不合理的代理代码很少有泄露到生产环境中的危险……至少现在还没有。
代理最初由 Andreas Gal 在 2010 年实现,Blake Kaplan 对其进行了代码审查。然后标准委员会完全重新设计了该功能。Eddy Bruel 在 2012 年实现了新的规范。
我实现了 `Reflect`,Jeff Walden 对其进行了代码审查。它将在本周末开始出现在 Firefox Nightly 中——除了 `Reflect.enumerate()`,该方法尚未实现。
接下来,我们将讨论 ES6 中最具争议的功能,而谁比在 Firefox 中实现它的人更适合来介绍它呢?所以,下周请加入我们,Mozilla 工程师 Eric Faust 将深入介绍 ES6 类。
13 条评论