ES6 in Depth: Proxies

ES6 In Depth: Proxies

这是我们今天要做的事情:

1
2
3
4
5
6
7
8
9
10
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);
  }
});

对于这第一个例子可能有些复杂,我将会下面进行各部分的说明。现在,先来检查一下我们创建的对象:

1
2
3
4
5
6
> obj.count = 1;
    setting count!
> ++obj.count;
    getting count!
    setting count!
    2

这里发生了什么?我们拦截了这个对象的属性访问,重载了”.”操作符。

它是怎么工作的?

计算机领域中最好技巧为虚拟化,这是用非常普通的技术来做不可思议的事情。这里说明是怎么做的。

  1. 使用任意图片

img1

  1. 在图片中的某部分周围画个轮廓

img2

  1. 现在,使用意外的东西,来替换所有轮廓里面或者外面的部分。这里只有一个规则,那就是向后兼容的规则。你的替换操作需要做到的是,之前在其它地方使用的人不会感觉到它的改变。

img3

通过计算机科学的电影如楚门的世界(The Truman Show)和黑客帝国(Teh Matrix),你可能非常熟悉这类型的hack。电影中,一个人在轮廓的内部,但是剩余的其它世界都已经被近乎合理的替代了。

为了满足向后兼容的规则,你的替代方式需要巧妙的设计,但真正的关键点在于画好轮廓。

对象轮廓,我理解为一个API的边界,一个接口。接口定义了两部分代码是怎么交互的,每部分期望其它提供什么信息。所以,如果在系统中存在一个接口,也就是说这个轮廓已经画好给你了。你知道,你可以替换这一部分,而其它部分就不用关心了。

当没有存在已有接口时,那么你就不得不创建了。一些酷酷的软件hack所有时间都在没有API的系统中画API的边界,同时将接口编写入现有系统中是个巨大的工程。

虚拟内存(https://en.wikipedia.org/wiki/Virtual_memory )、硬件虚拟化(https://en.wikipedia.org/wiki/Hardware_virtualization )、Docker(https://en.wikipedia.org/wiki/Docker_%28software%29 )、Valgrind(http://valgrind.org/ )、rr(http://rr-project.org/ ) —- 这些所有的项目在不同层次上将新的和甚至非期望的接口编写对现有系统中。在某些时候,它会消耗多年,然后一个新的操作系统特性、甚至一个新的硬件会出现,从而使得新边界工作得更好。

最好的虚拟化hack需要理解清楚虚拟化什么东西。在为某个事物编写API时,你不得不理解它。只要你理解了,你才能做出令人惊奇的事情。

ES6 为JS推出了虚拟化的最为基础的概念:对象。

什么是对象

不,真的,需要等等。想一下,当你知道对象是什么之后再往下看。

img4

这问题对我来说太难了,我从来没有听到过一个真正满意的定义。

这是不是很奇怪?定义一个基础的概念总是很难的 —- 有时可以查看一下 Euclid 的元素。ECMAScript 指导说明书或许是个好伴侣,但是,它定义一个对象为“类型对象的成员”,完全没有帮助的定义!

后来,说明书添加到“对象是属性的集合”,这也不怎么坏。如果你想要一个定义,现在可以做,但我们将在以后回来讨论它。

我之前有说过,要为某个事物编写API,需要了解它。所以,按照这道理,我认为如果我们做到所有的这些事情,我们需要深入了解对象,这样我们才能可能做些惊奇的事情。

所以,让我们跟随着ECMAScript委员会的步骤,来看看它是怎么使用对象来定义一个API、一个接口的。我们需要哪些方法?对象可能做些什么?

这些依赖于具体的对象。DOM元素对象可以做些确定的事情,AudioNode对象则可以做其它事情。但是,所有的对象都有那么一些基础的能力:

  • 对象拥有属性。你可以获取和设置属性,删除它们,等等。
  • 对象拥有原型。这是JS继承的原因。
  • 某些对象是函数或者构建器,你可以调用它们。

几乎所有的JS代码,使用属性、原型和函数就可以了。甚至,某些特殊的Element或AudioNode对象行为可以通过调用方法来作用,但这只不过是继承其函数属性。

所以,ECMAScript标准委员会定义了一堆、也就是14个内置的方法,所有的对象都会拥有的接口。这些并不令人感到奇怪,他们最终还是关注于这三个基本点上。

完整的接口方法可以在版本5和6的标准库 找到。这里,我将会对部分进行说明。怪异的双中括号,[[]],用于强调其是内部的方法,对普通的JS代码是隐藏的。不同于普通方法,你不能调用、删除或者重写这些方法。

  • obj.[Get] – – 获取属性的值

当JS代码使用obj.prop或者obj[key]时,其会被调用。

obj 是当前查找的对象,receiver 是第一个查找对象属性的对象。有时,我们不得不查找多个对象,obj可能会是receiver对象原型链上的对象。

  • obj.[Set] – – 为一个对象的属性指派值

当JS代码使用obj.prop - value或者obj[key] = value时,其会被调用。

在如obj.prop += 2的指派值中,[[GET]]方法会先调用,接着才调用[[Set]]方法。同理于++--

当JS代码使用key in obj时,会调用。

  • obj.[Enumerate] – – 列出obj对象的可列举的属性。

当JS代码使用for (key in obj) ...时,会调用。

这方法会返回一个迭代器对象,这就是for-in循环获得一个对象属性名称的原因。

当JS代码使用obj.__proto__或者Object.getPrototypeOf(obj)时,会调用。

  • functionObj.[Call] – – 调用一个函数。

当JS代码使用functionObj()或者 x.method(),会调用。这是可选的,并不是每个对象都是函数。

*constructerObj.[Construst] – – 调用一个构造器。

例如,当JS代码使用new Date(2890, 6, 2)时,会调用。这是可选的,并不是每个对象都是个构造器。

其中,newTarget 会在子类中参数扮演着一个角色,我们将会再之后的文章中进行说明。

也许,你可以猜测一下其它的七个方法。

贯穿着ES6的标准库,无论如何,针对对象的任意小语法或者内置函数,都是根据这内置的十四个方法来具体化的。ES6 为一个对象的核心划定了一个边界,而通过任意的JS代码作为代理,来替代各种核心方法。

当我们开始讨论重写这些内置的方法时,记住,我们正在讨论是 重写核心代码的方法,如obj.prop,内置的函数,如Object.keys(),还有更多。

代理

ES6 定义了一个新的全局构造器,Proxy。它有两个参数:一个target对象,一个handler对象。所以,一个简单的例子看起来如:

1
2
var target = {}, handler = {};
var proxy = new Proxy(target, handler);

让我们先把handler对象放一会儿,关注proxy和target对象是怎么关联的。

我可以用一句话来说明proxy是怎么工作的:所有的proxy的内部方法都会面向target。例如,如果某时调用proxy.[[Enumerate]](),它将会返回target.[[Enumerate]]()

让我们试一下,我们做些事,它将会触发proxy.[[Set]]()

1
proxy.color = "pink";

好了,刚才发生了什么?proxy.[[Set]]()应该会调用target.[[Set]](),所以,target应该有一个新的属性,是不是呢?

1
2
> target.color
    "pink"

是的,它是这样子的。还有,其它的内部方法也是一样的。在大多时候,proxy代理对象的行为也和target一样。

这样高保真的操作是受到限制的,是错觉。你可以发现proxy !== target。同时,当target通过类型检查时,其proxy代理对象有时并不能通过。甚至,例如,如果target是一个DOM元素,代理对象proxy并不是真正的一个元素,所以,有时如document.body.appendChild(proxy)会出现TypeError的错误。

代理处理器(Proxy handlers)

现在,让我们返回讨论handler对象,它是让代理proxy可用的原因。

这个handler对象的方法可以重载做任意proxy中的内部方法。

例如,如果你想拦截所有设置对象属性的操作,你可以定义handler.set()来做到。

1
2
3
4
5
6
7
8
9
10
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.

所有的handle方法可以在 MDN的Proxy文档 看到。这里有十四个方法,这十四个方法也被提到ES6定义为内置方法中。

所有的handler方法都是可选的,如果一个内置方法没有被拦截,那么它将会传递给target,就如之前我们看到的一样。

例子:“不可能” 自动流对象

现在,我们对proxy代理已经有足够的了解了,在试用时有时显得很怪异,但有些又不可能离开proxy。

这里,有我们第一个训练的例子。让函数Tree()可以做到:

1
2
3
4
5
6
7
8
9
> 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 当需要时会神奇地自动创建。很方便,是不是?它可能是怎么工作的?

直到现在,并没有方法可以做到。但是,通过proxy代理,只需要几行代码。我们只需要控制tree.[[Get]]()方法。如果你喜欢挑战,你也许可以试图在阅读下面内容之前自己实现它。

这里是我的解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
function Tree() {
  return new Proxy({}, handler);
}

var handler = {
  get: function (target, key, receiver) {
    if (!(key in target)) {
      target[key] = Tree(); // auto-create a sub-Tree
    }
    return Reflect.get(target, key, receiver);
  }
};

注意,最后有Reflect.get()的调用,这证明其非常普通的需求。在这里的 handler 方法中,它代表的意思是“现在只要授权给target进行默认操作”。所以,ES6定义了一个新的 Reflect 对象 并包含有14个方法,你可以真正地使用起来。

例子:一个只读视图

我想,我可能会给人一种错误的印象,proxy代理是容易使用的。让我们做更多的一个例子,看看这看板是否正确吧。

这次,我们的分配更加复杂些:我们需要实现这个函数readOnlyView(object),它可接收任意对象并返回一个proxy代理,代理行为看起来像接收对象一样,除了代理对象是不可变的。所以,例如,它应该看起来这样:

1
2
3
4
5
6
7
> 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

我们应该怎么实现它呢?

第一步,我们需要修改穿过方法的目标对象,截获它所有的内部方法。这里需要修改五个内部方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,
  sePrototypeOf: NOPE
};

function readOnlyView(target) {
  return new Proxy(target, handler);
}

这是可行的,通过这只读视图,它会阻止设值、属性定义等。

这结构有什么漏洞吗?

这里最大的问题是[[Get]]方法,还有其它,可能会返回可变的对象。所以,当某个对象 x 是个只读的视图,x.prop 是可变的。这是个大大的漏洞。

为了修补它,我们需要添加一个handler.get()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var handler = {
  ...

  // Wrap other results i read-only veiws.
  get: function (target, key, receiver) {
    // Start by just doing the default behavior.
    var result = Reflect.get(target, key, receiver);

    // Make sure not to return a mutable object!
    if (Object(result) === result) {
      // result is an object.
      return readOnlyView(result);
    }
    // result is a primitive, so already immutable.
    return result;
  },

  ...
};

这样依然没有达到效果,类似的代码需要在其它方法中实现,包含getPrototypeOfgetOwnPropertyDescriptor

然后,会有些更多的问题。当一个 getter 或者 方法在这类的代理中被调用时,这个this值会传递给proxy代理对象本身。但是,就像之前我们看到的,proxy代理对象在很多的接收器(accessors)和方法的类型检查中是不会通过的,这里最好是对目标对象来替代代理对象。你能指出怎么做吗?

这文章看到创建一个代理对象是十分方便的,但是想创建一个拥有直觉行为的代理对象是十分困难的。

零碎的东西

  • proxy代理善于做什么

当你想监视或者记录一个对象的访问时,它确实是有用的。这样,它可以用来调试。测试框架可以用它来创建模拟对象(Mock object)

当你希望的行为只是一个普通可以做的,proxy代理是有用的,如:懒惰的流行属性设置。

尽管我很不喜欢这方式,但是,当使用代理时,想要知道其代码是怎么运行的,最好的办法之一是将代理中的handler对象使用另外一个代理进行包裹起来,从而用来记录每次handler方法的访问。

代理可以限制一个对象的访问,就像我们刚才做的readOnlyView。这类型的使用场景在应用代码中比较少见,但是Firefox使用代理对不同域名中的安全边界 进行了内部实现。代理是我们(Firefox)安全模型的一个关键点。

  • 代理 结合 WeakMaps 。在我们的readOnlyView例子中,每次的对象访问中我们会创建一个新的代理。使用 WeakMap 来创建每个代理对象,其可以缓存并减少内存的消耗,所以,虽然readOnlyView会传递很多的对象,但是真正只有一个代理对象被创建。

这是一个刺激使用WeakMap的使用场景。

  • 可废除的代理。ES6 还定义另外的函数Proxy.revocable(target, handler),它会创建一个代理,就像new Proxy(target, handler),但这个代理对象在之后是可以废除的。(Proxy.revocable返回一个包含有.proxy属性和.revoke方法的对象。)只要一个代理对象被废除,它就不能再工作了;所有的内部方法都会抛出(错误提示?)。

  • 对象不变量。在某些场景中,ES6会要求proxy的处理方法得出的结果包含有target对象的状态,这是为了想得到强制性的对所有对象的不可变的规则,甚至包括代理对象。例如,一个代理不能声明为不可扩展的,除非它的target对象是真正不能扩展的。

准确的规则在这里来说是比较复杂的,但是,如果你看到一个错误的信息如"proxy can't report a non-existent property as non-configurable"时,这就是原因。这有点像补救而改变代理对象怎么输出它自己;另外的可能是改变target对象用来进行反射,而不理会代理对象怎么输出。

现在对象是什么?

我想,我们已经偏离了“对象是属性的集合”。

我并不乐意这个定义,即使当然地包括原型(prototypes)和callability。我认为 “集合”这一词太笼统了,它并不能表明代理对象是什么,代理对象的处理方法可以做任意事情,它可以返回任意的结果。

为了说明一个对象能做什么,对对象方法进行标准化、虚拟化第一类的特性以至于每一个人都能使用,ECMAScript标准委员会正扩展其可能性。

现在,对象可能是任何事物。

也许,更为诚实的问题是“什么是一个对象?”,现在是,其有十二个内部方法,一个对象在JS程序中有[[Get]]操作,有[[Set]]操作,等等。

现在,我们是否理解了对象真正是什么了吗?我并不确定!我们做了不可思异的事情?是的,我们做了之前JS不可能做的事情。

今天我可以使用代理吗?

不。无论如何,它并不能在Web开发中使用。只有Firefox和微软的Edge浏览器支持代理,也没有额外的转换工具。

自从V8现实了代理的旧版本后,要在 Node.js 和 io.js 使用代理,需要使用默认关闭的选项(-- harmony_proxies) 和 harmony-reflect 工具。

所以,可以尝试使用代理。创建一个大的镜子,里面包括有成千的对象复制品,所有都是类似的,都是很难调试的。现在是时候使用代理了,但对于生产环境你的代理代码可能还是会有些危险的。

代理的第一次实现是在2010年,由Andreas Gal实现,Blake Kaplan进行代码审查。接着,标准委员会又完全重新设计了这一特性。Eddy Bruel 在2012年根据新的说明重新实现了。

我实现了Reflect,代理审查由 Jeff Walden 进行,它将会在这周末的Firefox的Nightly版本中出现 – – 除了Reflect.enumerate()没有实现。

下一步,我们将讨论ES6中最富有争议的特性,一个在Firefox内部的提出人更优于实现人的特性。所以,在于下周加入我们,Mozilla工程师 Eric Faust 展示 深入ES6 的类

Comments