[ES6深度解析]11:代理(Proxies)

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

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

这是怎么回事?我们正在拦截这个对象的属性访问。我们重载了.操作符。

代理是如何实现的?

计算机技术中中最好的技巧叫做虚拟化。这是一种非常通用的技术,用于做令人惊讶的事情。下面是它的工作原理。

  1. 随便拿一张照片:image
  2. 在这个照片中随便把某个东西圈起来:image
  3. 白色圆圈把照片分成两部分:圈内和圈外。现在我们可以把圈内或者圈外的部分用其他任何东西替换掉。只有一个规则,向后兼容规则image
    如上图,圈内部分被替换,圈内的替代者必须表现得像以前一样,以至于圈外的人注意到有什么变化。

在经典的计算机科学电影中,比如《楚门的世界》(The Truman Show)和《黑客帝国》(The Matrix),你会对这种黑客手法很熟悉。在这些电影中,一个人处于“圈内”,而世界的其余部分被一种精心设计的正常幻觉所取代。

为了满足向后兼容性规则,替代者可能需要巧妙地设计。但真正的技巧在于画出正确的“圈”

所谓“圈”,就是API的边界 —— 一个接口。接口指定了两段代码如何交互以及每个部分对另一部分的期望。因此,如果一个接口被设计到系统中,那么边界就已经为你画好了。你知道你可以替换任何一方,而另一方不会在意。

当没有现成的接口时,你就必须发挥创造性。一些最酷的软件专家一直在以前没有API边界的地方绘制API边界,并通过巨大的工程努力把接口创造出来。

虚拟内存、硬件虚拟化、Docker、Valgrind等等在不同程度上,所有这些项目都涉及到在现有系统中创造出新的和出乎意料的接口。在某些情况下,需要花费数年时间和新的操作系统特性,甚至是新的硬件才能使新接口正常工作。

最好的虚拟化技术人员会对正在被虚拟化的东西有新的理解。要为某些东西编写API,你必须理解它。一旦你理解了,你就能做出惊人的事情。

ES6引入了对JavaScript最基本概念——对象的虚拟化支持。

什么是对象?

花点时间。考虑考虑。当你知道什么是对象时向下滚动。
image

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

这是意外吗?定义基础概念一直非常困难,看看在《欧几里得元素》中,最初的几个定义是如何做出的。因此,当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.propobj[key]时调用。obj是当前搜索的对象;receiver是我们第一次开始搜索这个属性的对象。有时我们必须搜索几个对象。obj可能是receiver原型链上的一个对象。

  • obj.[[Set]](key, value, receiver) 赋值给对象的属性。当JS代码执行obj.prop = valueobj[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.propObject.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 !== targetproxy有时会通不过target通过的类型检查。例如,即使proxytarget是一个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" }}}

注意所有中间对象branch1branch2branch3是如何在需要时神奇地自动创建的。方便,对吗?这怎么可能呢?直到现在,这一切都不可能成功。但是对于代理,这只需要几行代码。我们只需要利用tree.[[Get]]()。如果你喜欢挑战,你可能会想在继续阅读之前尝试一下。

image

下面是我的方案:

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;
  },

  ...
};

这还不够。其他方法也需要类似的代码,包括getPrototypeOfgetOwnPropertyDescriptor。然后还有更多的问题。当通过这种代理调用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]]操作等等的一种东西。

posted @ 2021-08-26 11:43  Max力出奇迹  阅读(238)  评论(0编辑  收藏  举报
返回顶部↑