凯小凯

ES6中的元编程部分3 - Proxies

原文链接: www.keithcirkel.co.uk

08 Aug 2016

in JavaScript, ES6, Metaprogramming

ES6中的元编程部分3 - Proxies

在我ES6元编程的第三也是最后一章,请记住,那些文章我写了超过一年的时间,尽管起初我并不想花很长时间。在最后一篇文章,让我们尽可能近地来看一下ES6的反射特征:Proxies。那些看了我目录的人可能已经读了我上一篇文章,关于介绍ES6 Reflect API的,和 之前那篇介绍ES6 Symbols的文章 — 那些没有提前看并对那些知识点不熟悉人的人,在继续往下读之前,建议您先去看一下,因为这里和Reflect有很大的关联。就像其他的文章一样,我要引用第1部分中的一点:

  • Symbols是具体实现中的反射——您在现有的类和对象上嵌入它们来改变行为。
  • Reflect是通过自省进行的反射——用于发现关于代码底层的信息。
  • Proxy是通过中间层来进行反射的——包装对象和通过陷阱拦截他们本来的行为。

所以,Proxy是一种新的全局构造函数(如DateNumber),你传入一个对象和一组处理程序钩子,然后返回一个新对象,这个新对象就是将旧对象用传入的那些钩子包裹起来。这样,你就有了一个proxy!文章结束,希望您喜欢,让我们回家!

好吧,当然还有更多。首先,让我们看一下构造函数。

创建代理

Proxy构造函数需要两个参数,一个要代理的初始对象和一组处理程序钩子。让我们暂时忘掉这些钩子,看看我们如何在对象上创建代理。线索就在代理名称:它们对您创建的对象保留了一份参考,但如果您有一个原始参考对象,那么您仍然可以与它进行交互,并且代理对象也会受到影响,同样的,您对代理对象做的任何改变都会反映到原始对象。换句话说,代理返回一个包装传入对象的新对象,但是您对其中一个对象做的任何操作都会影响另一个对象。证明:

var myObject = {};
var proxiedMyObject = new Proxy(myObject, {/*handler hooks*/});

assert(myObject !== proxiedMyObject);

myObject.foo = true;
assert(proxiedMyObject.foo === true);

proxiedMyObject.bar = true;
assert(myObject.bar === true);

到目前为止,我们还没有取得任何成果,我们的代理并没有给我们带来任何好处,除了正常地使用对象。要用它做有趣的事情,我们就需要使用处理程序钩子。

代理处理程序钩子

处理程序钩子是一系列函数,每个函数都有一个Proxy所知道的特定名称,每个函数控制您与代理对象(被包裹后的新对象)如何进行交互。这些处理程序连通JavaScript的“内部方法”,如果这听起来很熟悉,那是因为我们已经在关于Reflect API的文章中讨论了内部方法。

好了,是时候揭露秘密了。我有一个很好的理由保存代理直到最后,那是因为我们需要理解Reflect是如何工作的,因为Proxy和Reflect是交织在一起的,就好像一对“苦命恋人”。您知道,每个代理处理程序钩子都有一个反射方法,或者换句话说,每一个反射方法都有一个代理处理钩子。反射方法/代理处理程序钩子的完整列表是:

  • apply (调用函数,并带有this参数和arguments参数组)

  • construct (调用类函数/构造函数,并带有arguments参数组和原型的可选构造函数)

  • defineProperty (定义一个对象的属性,包括它的可数性和一些元数据)

  • getOwnPropertyDescriptor (得到一个属性”属性说明”:关于某个对象的属性的元数据比如可枚举性)

  • deleteProperty (从对象中删除属性)

  • getPrototypeOf (获取实例原型)

  • setPrototypeOf (设置实例原型)

  • isExtensible (决定一个对象是否是可扩展的(可以给它添加属性))

  • preventExtensions (阻止对象可扩展)

  • get (获取对象上某个属性的值。)

  • set (设置对象上某个属性的值。)

  • has (检查对象是否具有特定属性,不管属性具体的值)

  • ownKeys (检索对象所有自己的key:自有的key,不算原型上的key)

我把所有这些方法(代码示例)写在了 Reflect文章中。(再重申一次,如果您还没有读,去读一下)。Proxy实现了其中的每一个,并具有完全相同的参数集。事实上,Proxy的默认行为本质上实现了对每个处理程序钩子的反射调用(JavaScript引擎的内部机制可能略有不同,但我们可以假定未被特殊指明的处理程序钩子会按照它们默认行为执行)。这也意味着任何您未特殊指明的处理程序钩子会表现得像平常一样,就好像从来没有被代理:

// 这个代理,我们指定的行为还是和默认行为一致:
proxy = new Proxy({}, {
  apply: Reflect.apply,
  construct: Reflect.construct,
  defineProperty: Reflect.defineProperty,
  getOwnPropertyDescriptor: Reflect.getOwnPropertyDescriptor,
  deleteProperty: Reflect.deleteProperty,
  getPrototypeOf: Reflect.getPrototypeOf,
  setPrototypeOf: Reflect.setPrototypeOf,
  isExtensible: Reflect.isExtensible,
  preventExtensions: Reflect.preventExtensions,
  get: Reflect.get,
  set: Reflect.set,
  has: Reflect.has,
  ownKeys: Reflect.ownKeys,
});

现在,我可以详细介绍这些代理处理程序钩子的工作原理,但基本上是复制粘贴Reflect的例子,只有一些小的修改。这对Proxy有点不公平,因为Proxy都是很酷的使用案例,而不是单个方法的效用。所以这篇文章的其余部分将向您展示您使用Proxy可以做的很酷的事情,包括一些没有它们您永远也做不到的事情。

另外,为了使事情更具交互性,我为每个示例创建了小的库,这些示例演示了功能。我会为每个例子添加上下载链接。

使用Proxy,可以...

...完成一个无限链式调用的API

继续构建之前的例子 —— 使用同样的[[Get]] 陷阱:用一点魔法,我们可以生成一个有无数个方法的API,当您调用其中最后一个方法时,它将返回您所链接的所有东西。这会很有用的,比如在制作可以构建Web请求的URL的流畅API的时候,或者是在制作某种测试断言框架的时候,将英语单词链在一起,增强断言的可读性,就像 Chai

为此,我们需要使用[[Get]]钩子,并将要检索属性放入数组中。Proxy包装了一个函数,这个函数返回一个由所有要检索的属性组成的数组,并清空这个数组,以便重新使用。我们也会使用[[HasProperty]]钩子,因为,像以前一样,我们要给我们的用户展示任何属性的存在。

function urlBuilder(domain) {
  var parts = [];
  var proxy = new Proxy(function () {
    var returnValue = domain + '/' + parts.join('/');
    parts = [];
    return returnValue;
  }, {
    has: function () {
      return true;
    },
    get: function (object, prop) {
      parts.push(prop);
      return proxy;
    },
  });
  return proxy;
}
var google = urlBuilder('http://google.com');
assert(google.search.products.bacon.and.eggs() === 'http://google.com/search/products/bacon/and/eggs')

您也可以使用同样的模式来生成树遍历流畅API,您可能在jQuery中见到过,也可能在React的选择器工具中见到:

function treeTraverser(tree) {
  var parts = [];
  var proxy = new Proxy(function (parts) {
    let node = tree; // 从根节点开始
    for (part of parts) {
      if (!node.props || !node.props.children || node.props.children.length === 0) {
        throw new Error(`Node ${node.tagName} has no more children`);
      }
      // 如果这部分是子标签, 深入到那个子节点为了下一步的遍历
      let index = node.props.children.findIndex((child) => child.tagName == part);
      if(index === -1) {
        throw new Error(`Cannot find child: ${part} in ${node.tagName}`);
      }
      node = node.props.children[index];
    }
    return node.props;
  }, {
    has: function () {
      return true;
    },
    get: function () {
      parts.push(prop);
      return proxy;
    }
  });
  return proxy;
}
var myDomIsh = treeTraverserExample({
  tagName: 'body',
  props: {
    children: [
      {
        tagName: 'div',
        props: {
          className: 'main',
          children: [
            {
              tagName: 'span',
              props: {
                className: 'extra',
                children: [
                  { tagName: 'i', props: { textContent: 'Hello' } },
                  { tagName: 'b', props: { textContent: 'World' } },
                ]
              }
            }
          ]
        }
      }
    ]
  }
});
assert(myDomIsh.div.span.i().textContent === 'Hello');
assert(myDomIsh.div.span.b().textContent === 'World');

我已经做了一个复用性稍微增强的版本github.com/keithamus/proxy-fluent-api。可以用npm加相同名称直接下载。

...实现“方法丢失”钩子

其他编程语言使您能够使用已知的反射方法来重写类的行为,比如PHP中的__call,Ruby中的method_missing,Python中可以用__getattr__来模仿。JavaScript没有这样的机制——但是现在我们有了代理,允许我们像这样做一些很酷的事情。

为了弄清楚我们究竟要做什么,让我们来看看Ruby的例子,以获得一些灵感:

class Foo
  def bar()
    print "you called bar. Good job!"
  end
  def method_missing(method)
    print "you called `#{method}` but it doesn't exist!"
  end
end

foo = Foo.new
foo.bar()
#=> you called bar. Good job!
foo.this_method_does_not_exist()
#=> you called this_method_does_not_exist but it doesn't exist

所以在这个bar例子中,对于已经存在的方法,这个方法就会像您期望的那样被执行。对于不存在的方法,像foothis_method_does_not_existmethod_missing方法就会代替它们被执行。此外,它将调用的方法名作为第一个参数,这对于确定用户想要什么非常有用。

我们可以使用ES6的Symbols和一个函数来完成类似的功能,这个函数需要包装这个类,并且返回一个使用了get ([[Get]])陷阱的代理。

function Foo() {
  return new Proxy(this, {
    get: function (object, property) {
      if (Reflect.has(object, property)) {
        return Reflect.get(object, property);
      } else {
        return function methodMissing() {
          console.log('you called ' + property + ' but it doesn\'t exist!');
        }
      }
    }
  });
}

Foo.prototype.bar = function () {
  console.log('you called bar. Good job!');
}

foo = new Foo();
foo.bar();
//=> you called bar. Good job!
foo.this_method_does_not_exist()
//=> you called this_method_does_not_exist but it doesn't exist

如果您有一组方法,它们的功能基本相同,差异可以从名字中区分出来,这就真的非常有用了。实际上,将函数参数加入到函数名称中会得到更可读的语法。举个例子,你可以很容易地制作一个API来交换两种值,比如货币,或者基值:

const baseConvertor = new Proxy({}, {
  get: function baseConvert(object, methodName) {
    var methodParts = methodName.match(/base(\d+)toBase(\d+)/);
    var fromBase = methodParts && methodParts[1];
    var toBase = methodParts && methodParts[2];
    if (!methodParts || fromBase > 36 || toBase > 36 || fromBase < 2 || toBase < 2) {
      throw new Error('TypeError: baseConvertor' + methodName + ' is not a function');
    }
    return function (fromString) {
      return parseInt(fromString, fromBase).toString(toBase);
    }
  }
});

baseConvertor.base16toBase2('deadbeef') === '11011110101011011011111011101111';
baseConvertor.base2toBase16('11011110101011011011111011101111') === 'deadbeef';

当然,您可以手动键入可用的所有1296种排列方式,或者创建一个循环来单独创建所有这些方法,但都需要更多的代码。

这方面的一个更具体的例子体现在Ruby的Rails ActiveRecord,它带有“动态查询”。它实现的method_missing允许您通过它的列来查询列表。而不是传进一个复杂的对象,您传入的参数会去和方法名去匹配,例如:

Users.find_by_first_name('Keith'); # [ Keith Cirkel, Keith Urban, Keith David ]
Users.find_by_first_name_and_last_name('Keith', 'David');  # [ Keith David ]

我们可以使用上面的模式在JavaScript中实现类似的东西:

function RecordFinder(options) {
  this.attributes = options.attributes;
  this.table = options.table;
  return new Proxy({}, function findProxy(methodName) {
    var match = methodName.match(new RegExp('findBy((?:And)' + this.attributes.join('|') + ')'));
    if (!match){
      throw new Error('TypeError: ' + methodName + ' is not a function');
    }
  });
});

就如其他的例子,我做了一个小的库在github.com/keithamus/proxy-method-missing,同样可以用npm下载。

...隐藏所有属性的枚举性方法包括getOwnPropertyNames, Object.keys, in 等。

我们可以使用代理使一个对象中的所有属性完全隐藏,除了获取值时。下面是JavaScript中所有判断一个属性是否存在的方法:

  • Reflect.has,Object.hasOwnProperty,Object.prototype.hasOwnProperty, 以及 in 操作符都是使用[[HasProperty]]。Proxy可以has来实现。

  • Object.keys/Object.getOwnPropertyNames都是使用 [[OwnPropertyKeys]]。 Proxy可以使用ownKeys来实现。

  • Object.entries (即将到来的ES2017特性), 同样使用[[OwnPropertyKeys]] - 同样 - 可以使用ownKeys来实现。

  • Object.getOwnPropertyDescriptor 使用 [[GetOwnProperty]]。意外的惊喜,Proxy可以使用getOwnPropertyDescriptor来实现。

var example = new Proxy({ foo: 1, bar: 2 }, {
  has: function () { return false; },
  ownKeys: function () { return []; },
  getOwnPropertyDescriptor: function () { return false; },
});
assert(example.foo === 1);
assert(example.bar === 2);
assert('foo' in example === false);
assert('bar' in example === false);
assert(example.hasOwnProperty('foo') === false);
assert(example.hasOwnProperty('bar') === false);
assert.deepEqual(Object.keys(example), [ ]);
assert.deepEqual(Object.getOwnPropertyNames(example), [ ]);

我不想说谎,我想不出这种模式有什么超级有用的用途。尽管如此,我还是建立了一个库,github.com/keithamus/proxy-hide-properties,它可以帮助您隐藏指定的属性,而不是所有的属性。

...实现观察者模式,又称Object.observe。

那些敏锐地遵循新规范的人可能已经注意到Object.observe正在被考虑加入到ES2016中。然而,最近,Object.observe的拥护者计划撤回这个提议,并有很好的理由:它最初设计的目的是为了解决框架作者们碰到的数据绑定问题。现在,有了React和Polymer 1.0,双向数据绑定框架的趋势正在下降,而固定数据结构框架正变得越来越流行。

值得庆幸的是,实际上,Proxy使类似Object.observe的规范变得多余,因为现在通过Proxy我们有一个底层的API,能真正实现类似Object.observe的东西。为了能够十分接近Object.observe,我们需要[[Set]], [[PreventExtensions]], [[Delete]]以及[[DefineOwnProperty]]内部的方法 —— set, preventExtensions, deletePropertydefineProperty

function observe(object, observerCallback) {
  var observing = true;
  const proxyObject = new Proxy(object, {
    set: function (object, property, value) {
      var hadProperty = Reflect.has(object, property);
      var oldValue = hadProperty && Reflect.get(object, property);
      var returnValue = Reflect.set(object, property, value);
      if (observing && hadProperty) {
        observerCallback({ object: proxyObject, type: 'update', name: property, oldValue: oldValue });
      } else if(observing) {
        observerCallback({ object: proxyObject, type: 'add', name: property });
      }
      return returnValue;
    },
    deleteProperty: function (object, property) {
      var hadProperty = Reflect.has(object, property);
      var oldValue = hadProperty && Reflect.get(object, property);
      var returnValue = Reflect.deleteProperty(object, property);
      if (observing && hadProperty) {
        observerCallback({ object: proxyObject, type: 'delete', name: property, oldValue: oldValue });
      }
      return returnValue;
    },
    defineProperty: function (object, property, descriptor) {
      var hadProperty = Reflect.has(object, property);
      var oldValue = hadProperty && Reflect.getOwnPropertyDescriptor(object, property);
      var returnValue = Reflect.defineProperty(object, property, descriptor);
      if (observing && hadProperty) {
        observerCallback({ object: proxyObject, type: 'reconfigure', name: property, oldValue: oldValue });
      } else if(observing) {
        observerCallback({ object: proxyObject, type: 'add', name: property });
      }
      return returnValue;
    },
    preventExtensions: function (object) {
      var returnValue = Reflect.preventExtensions(object);
      if (observing) {
        observerCallback({ object: proxyObject, type: 'preventExtensions' })
      }
      return returnValue;
    },
  });
  return { object: proxyObject, unobserve: function () { observing = false } };
}

var changes = [];
var observer = observe({ id: 1 }, (change) => changes.push(change));
var object = observer.object;
var unobserve = observer.unobserve;
object.a = 'b';
object.id++;
Object.defineProperty(object, 'a', { enumerable: false });
delete object.a;
Object.preventExtensions(object);
unobserve();
object.id++;
assert.equal(changes.length, 5);
assert.equal(changes[0].object, object);
assert.equal(changes[0].type, 'add');
assert.equal(changes[0].name, 'a');
assert.equal(changes[1].object, object);
assert.equal(changes[1].type, 'update');
assert.equal(changes[1].name, 'id');
assert.equal(changes[1].oldValue, 1);
assert.equal(changes[2].object, object);
assert.equal(changes[2].type, 'reconfigure');
assert.equal(changes[2].oldValue.enumerable, true);
assert.equal(changes[3].object, object);
assert.equal(changes[3].type, 'delete');
assert.equal(changes[3].name, 'a');
assert.equal(changes[4].object, object);
assert.equal(changes[4].type, 'preventExtensions');

您可以看到,我们用一个小代码块实现了相对完整的object.observe。它和提议规范的主要的差异是object.observe可以改变一个对象,而代理返回一个新的对象——observe和unobserve函数都不是全局的。

奖励:可撤销代理

代理还有最后一个窍门:可以撤销某些代理。为了创建一个可撤销的代理,您需要使用Proxy.revocable(target, handler)(而不是new Proxy(target, handler)),和直接返回代理不同,它会返回一个看起来像{ proxy, revoke(){} }的对象。一个例子:

function youOnlyGetOneSafetyNet(object) {
  var revocable = Proxy.revocable(object, {
    get(property) {
      if (Reflect.has(this, property)) {
        return Reflect.get(this, property);
      } else {
        revocable.revoke();
        return 'You only get one safety net';
      }
    }
  });
  return revocable.proxy;
}

var myObject = youOnlyGetOneSafetyNet({ foo: true });

assert(myObject.foo === true);
assert(myObject.foo === true);
assert(myObject.foo === true);

assert(myObject.bar === 'You only get one safety net');
myObject.bar // TypeError
myObject.bar // TypeError
Reflect.has(myObject, 'bar') // TypeError

可悲的是,正如您所见,例子的最后,当任一处理函数被触发的时候,被撤销的代理会抛出一个TypeError——甚至是没有设置这些处理函数。我觉得这限制了可撤销代理的能力。如果所有的处理程序返回它们的Reflect等价物(有效地使代理变得冗余,对象的行为就好像从未使用代理),这将是一个更有用的特性。可悲的是,事实并非如此。正因为如此,这个特性一直留在这篇文章的脚注中,因为我不太确定可撤销代理的具体用例。

就好像其他例子,这个例子的库在github.com/keithamus/proxy-object-observe——npm同样适用。

结论

我希望这篇文章向您展示了Proxy是一个非常强大的工具,可以用来干扰JavaScript内部机制。在许多方面,Symbol、Reflect和Proxy都打开了JavaScript的新篇章——就好像const和let,或者classes和箭头函数。const和let可以降低代码混乱,classes和箭头函数使得代码更简洁,Symbol,Reflect,和Proxy开始给予开发者关于JavaScript的真正底层的元编程钩子。

这些新的元编程工具的出现不会放缓速度:对于未来EcmaScript版本的提议一直在出现,其他有趣的特性在正在被增加,比如这个针对Reflect.isCallableReflect.isConstructor的新提议, 或者这个针对Reflect.type0阶段的提议, or 这个针对function.sent 元属性的提议, 或者这一组函数的更多元属性。而且,这些新API启发了一些有趣的关于一些新特性的讨论,比如这个关于增加 Reflect.parse的提议, 随后引出了围绕生成AST(抽象语法树)标准的讨论。

您觉得新的Proxy API怎么样?计划在您的项目中使用它吗?让我知道,在下面的评论或Twitter@keithamus上。