代理

在ES6中被加入的最明显的元编程特性之一就是proxy特性。

一个代理是一种由你创建的特殊的对象,它“包”着另一个普通的对象 —— 或者说挡在这个普通对象的前面。你可以在代理对象上注册特殊的处理器(也叫 机关(traps)),当对这个代理实施各种操作时被调用。这些处理器除了将操作 传送 到原本的目标/被包装的对象上之外,还有机会运行额外的逻辑。

一个这样的 机关 处理器的例子是,你可以在一个代理上定义一个拦截[[Get]]操作的get —— 它在当你试图访问一个对象上的属性时运行。考虑如下代码:

  1. var obj = { a: 1 },
  2. handlers = {
  3. get(target,key,context) {
  4. // 注意:target === obj,
  5. // context === pobj
  6. console.log( "accessing: ", key );
  7. return Reflect.get(
  8. target, key, context
  9. );
  10. }
  11. },
  12. pobj = new Proxy( obj, handlers );
  13. obj.a;
  14. // 1
  15. pobj.a;
  16. // accessing: a
  17. // 1

我们将一个get(..)处理器作为 处理器 对象的命名方法声明(Proxy(..)的第二个参数值),它接收一个指向 目标 对象的引用(obj),属性的 名称("a"),和self/接受者/代理本身(pobj)。

在追踪语句console.log(..)之后,我们通过Reflect.get(..)将操作“转送”到obj。我们将在下一节详细讲解ReflectAPI,但要注意的是每个可用的代理机关都有一个相应的同名Reflect函数。

这些映射是故意对称的。每个代理处理器在各自的元编程任务实施时进行拦截,而每个Reflect工具将各自的元编程任务在一个对象上实施。每个代理处理器都有一个自动调用相应Reflect工具的默认定义。几乎可以肯定你将总是一前一后地使用ProxyReflect

这里的列表是你可以在一个代理上为一个 目标 对象/函数定义的处理器,以及它们如何/何时被触发:

  • get(..):通过[[Get]],在代理上访问一个属性(Reflect.get(..).属性操作符或[ .. ]属性操作符)
  • set(..):通过[[Set]],在代理对象上设置一个属性(Reflect.set(..)=赋值操作符,或者解构赋值 —— 如果目标是一个对象属性的话)
  • deleteProperty(..):通过[[Delete]],在代理对象上删除一个属性 (Reflect.deleteProperty(..)delete)
  • apply(..)(如果 目标 是一个函数):通过[[Call]],代理作为一个普通函数/方法被调用(Reflect.apply(..)call(..)apply(..),或者(..)调用操作符)
  • construct(..)(如果 目标 是一个构造函数):通过[[Construct]]代理作为一个构造器函数被调用(Reflect.construct(..)new
  • getOwnPropertyDescriptor(..):通过[[GetOwnProperty]],从代理取得一个属性的描述符(Object.getOwnPropertyDescriptor(..)Reflect.getOwnPropertyDescriptor(..)
  • defineProperty(..):通过[[DefineOwnProperty]],在代理上设置一个属性描述符(Object.defineProperty(..)Reflect.defineProperty(..)
  • getPrototypeOf(..):通过[[GetPrototypeOf]],取得代理的[[Prototype]]Object.getPrototypeOf(..)Reflect.getPrototypeOf(..)__proto__, Object#isPrototypeOf(..),或instanceof
  • setPrototypeOf(..):通过[[SetPrototypeOf]],设置代理的[[Prototype]]Object.setPrototypeOf(..)Reflect.setPrototypeOf(..),或__proto__
  • preventExtensions(..):通过[[PreventExtensions]]使代理成为不可扩展的(Object.preventExtensions(..)Reflect.preventExtensions(..)
  • isExtensible(..):通过[[IsExtensible]],检测代理的可扩展性(Object.isExtensible(..)Reflect.isExtensible(..)
  • ownKeys(..):通过[[OwnPropertyKeys]],取得一组代理的直属属性和/或直属symbol属性(Object.keys(..)Object.getOwnPropertyNames(..)Object.getOwnSymbolProperties(..)Reflect.ownKeys(..),或JSON.stringify(..)
  • enumerate(..):通过[[Enumerate]],为代理的可枚举直属属性及“继承”属性请求一个迭代器(Reflect.enumerate(..)for..in
  • has(..):通过[[HasProperty]],检测代理是否拥有一个直属属性或“继承”属性(Reflect.has(..)Object#hasOwnProperty(..),或"prop" in obj

提示: 关于每个这些元编程任务的更多信息,参见本章稍后的“Reflect API”一节。

关于将会触发各种机关的动作,除了在前面列表中记载的以外,一些机关还会由另一个机关的默认动作间接地触发。举例来说:

  1. var handlers = {
  2. getOwnPropertyDescriptor(target,prop) {
  3. console.log(
  4. "getOwnPropertyDescriptor"
  5. );
  6. return Object.getOwnPropertyDescriptor(
  7. target, prop
  8. );
  9. },
  10. defineProperty(target,prop,desc){
  11. console.log( "defineProperty" );
  12. return Object.defineProperty(
  13. target, prop, desc
  14. );
  15. }
  16. },
  17. proxy = new Proxy( {}, handlers );
  18. proxy.a = 2;
  19. // getOwnPropertyDescriptor
  20. // defineProperty

在设置一个属性值时(不管是新添加还是更新),getOwnPropertyDescriptor(..)defineProperty(..)处理器被默认的set(..)处理器触发。如果你还定义了你自己的set(..)处理器,你或许对context(不是target!)进行了将会触发这些代理机关的相应调用。

代理的限制

这些元编程处理器拦截了你可以对一个对象进行的范围很广泛的一组基础操作。但是,有一些操作不能(至少是还不能)被用于拦截。

例如,从pobj代理到obj目标,这些操作全都没有被拦截和转送:

  1. var obj = { a:1, b:2 },
  2. handlers = { .. },
  3. pobj = new Proxy( obj, handlers );
  4. typeof obj;
  5. String( obj );
  6. obj + "";
  7. obj == pobj;
  8. obj === pobj

也许在未来,更多这些语言中的底层基础操作都将是可拦截的,那将给我们更多力量来从JavaScript自身扩展它。

警告: 对于代理处理器的使用来说存在某些 不变量 —— 它们的行为不能被覆盖。例如,isExtensible(..)处理器的结果总是被强制转换为一个boolean。这些不变量限制了一些你可以使用代理来自定义行为的能力,但是它们这样做只是为了防止你创建奇怪和不寻常(或不合逻辑)的行为。这些不变量的条件十分复杂,所以我们就不再这里全面阐述了,但是这篇博文(http://www.2ality.com/2014/12/es6-proxies.html#invariants)很好地讲解了它们。

可撤销的代理

一个一般的代理总是包装着目标对象,而且在创建之后就不能修改了 —— 只要保持着一个指向这个代理的引用,代理的机制就将维持下去。但是,可能会有一些情况你想要创建一个这样的代理:在你想要停止它作为代理时可以被停用。解决方案就是创建一个 可撤销代理

  1. var obj = { a: 1 },
  2. handlers = {
  3. get(target,key,context) {
  4. // 注意:target === obj,
  5. // context === pobj
  6. console.log( "accessing: ", key );
  7. return target[key];
  8. }
  9. },
  10. { proxy: pobj, revoke: prevoke } =
  11. Proxy.revocable( obj, handlers );
  12. pobj.a;
  13. // accessing: a
  14. // 1
  15. // 稍后:
  16. prevoke();
  17. pobj.a;
  18. // TypeError

一个可撤销代理是由Proxy.revocable(..)创建的,它是一个普通的函数,不是一个像Proxy(..)那样的构造器。此外,它接收同样的两个参数值:目标处理器

new Proxy(..)不同的是,Proxy.revocable(..)的返回值不是代理本身。取而代之的是,它返回一个带有 proxyrevoke 两个属性的对象 —— 我们使用了对象解构(参见第二章的“解构”)来将这些属性分别赋值给变量pobjprevoke

一旦可撤销代理被撤销,任何访问它的企图(触发它的任何机关)都将抛出TypeError

一个使用可撤销代理的例子可能是,将一个代理交给另一个存在于你应用中、并管理你模型中的数据的团体,而不是给它们一个指向正式模型对象本身的引用。如果你的模型对象改变了或者被替换掉了,你希望废除这个你交出去的代理,以便于其他的团体能够(通过错误!)知道要请求一个更新过的模型引用。

使用代理

这些代理处理器带来的元编程的好处应当是显而易见的。我们可以全面地拦截(而因此覆盖)对象的行为,这意味着我们可以用一些非常强大的方式将对象行为扩展至JS核心之外。我们将看几个模式的例子来探索这些可能性。

代理前置,代理后置

正如我们早先提到过的,你通常将一个代理考虑为一个目标对象的“包装”。在这种意义上,代理就变成了代码接口所针对的主要对象,而实际的目标对象则保持被隐藏/被保护的状态。

你可能这么做是因为你希望将对象传递到某个你不能完全“信任”的地方去,如此你需要在它的访问权上强制实施一些特殊的规则,而不是传递这个对象本身。

考虑如下代码:

  1. var messages = [],
  2. handlers = {
  3. get(target,key) {
  4. // 是字符串值吗?
  5. if (typeof target[key] == "string") {
  6. // 过滤掉标点符号
  7. return target[key]
  8. .replace( /[^\w]/g, "" );
  9. }
  10. // 让其余的东西通过
  11. return target[key];
  12. },
  13. set(target,key,val) {
  14. // 仅设置唯一的小写字符串
  15. if (typeof val == "string") {
  16. val = val.toLowerCase();
  17. if (target.indexOf( val ) == -1) {
  18. target.push(val);
  19. }
  20. }
  21. return true;
  22. }
  23. },
  24. messages_proxy =
  25. new Proxy( messages, handlers );
  26. // 在别处:
  27. messages_proxy.push(
  28. "heLLo...", 42, "wOrlD!!", "WoRld!!"
  29. );
  30. messages_proxy.forEach( function(val){
  31. console.log(val);
  32. } );
  33. // hello world
  34. messages.forEach( function(val){
  35. console.log(val);
  36. } );
  37. // hello... world!!

我称此为 代理前置 设计,因为我们首先(主要、完全地)与代理进行互动。

我们在与messages_proxy的互动上强制实施了一些特殊规则,这些规则不会强制实施在messages本身上。我们仅在值是一个不重复的字符串时才将它添加为元素;我们还将这个值变为小写。当从messages_proxy取得值时,我们过滤掉字符串中所有的标点符号。

另一种方式是,我们可以完全反转这个模式,让目标与代理交互而不是让代理与目标交互。这样,代码其实只与主对象交互。达成这种后备方案的最简单的方法是,让代理对象存在于主对象的[[Prototype]]链中。

考虑如下代码:

  1. var handlers = {
  2. get(target,key,context) {
  3. return function() {
  4. context.speak(key + "!");
  5. };
  6. }
  7. },
  8. catchall = new Proxy( {}, handlers ),
  9. greeter = {
  10. speak(who = "someone") {
  11. console.log( "hello", who );
  12. }
  13. };
  14. // 让 `catchall` 成为 `greeter` 的后备方法
  15. Object.setPrototypeOf( greeter, catchall );
  16. greeter.speak(); // hello someone
  17. greeter.speak( "world" ); // hello world
  18. greeter.everyone(); // hello everyone!

我们直接与greeter而非catchall进行交互。当我们调用speak(..)时,它在greeter上被找到并直接使用。但当我们试图访问everyone()这样的方法时,这个函数并不存在于greeter

默认的对象属性行为是向上检查[[Prototype]]链(参见本系列的 this与对象原型),所以catchall被询问有没有一个everyone属性。然后代理的get()处理器被调用并返回一个函数,这个函数使用被访问的属性名("everyone")调用speak(..)

我称这种模式为 代理后置,因为代理仅被用作最后一道防线。

“No Such Property/Method”

一个关于JS的常见的抱怨是,在你试着访问或设置一个对象上还不存在的属性时,默认情况下对象不是非常具有防御性。你可能希望为一个对象预定义所有这些属性/方法,而且在后续使用不存在的属性名时抛出一个错误。

我们可以使用一个代理来达成这种想法,既可以使用 代理前置 也可以 代理后置 设计。我们将两者都考虑一下。

  1. var obj = {
  2. a: 1,
  3. foo() {
  4. console.log( "a:", this.a );
  5. }
  6. },
  7. handlers = {
  8. get(target,key,context) {
  9. if (Reflect.has( target, key )) {
  10. return Reflect.get(
  11. target, key, context
  12. );
  13. }
  14. else {
  15. throw "No such property/method!";
  16. }
  17. },
  18. set(target,key,val,context) {
  19. if (Reflect.has( target, key )) {
  20. return Reflect.set(
  21. target, key, val, context
  22. );
  23. }
  24. else {
  25. throw "No such property/method!";
  26. }
  27. }
  28. },
  29. pobj = new Proxy( obj, handlers );
  30. pobj.a = 3;
  31. pobj.foo(); // a: 3
  32. pobj.b = 4; // Error: No such property/method!
  33. pobj.bar(); // Error: No such property/method!

对于get(..)set(..)两者,我们仅在目标对象的属性已经存在时才转送操作;否则抛出错误。代理对象应当是进行交互的主对象,因为它拦截这些操作来提供保护。

现在,让我们考虑一下反过来的 代理后置 设计:

  1. var handlers = {
  2. get() {
  3. throw "No such property/method!";
  4. },
  5. set() {
  6. throw "No such property/method!";
  7. }
  8. },
  9. pobj = new Proxy( {}, handlers ),
  10. obj = {
  11. a: 1,
  12. foo() {
  13. console.log( "a:", this.a );
  14. }
  15. };
  16. // 让 `pobj` 称为 `obj` 的后备
  17. Object.setPrototypeOf( obj, pobj );
  18. obj.a = 3;
  19. obj.foo(); // a: 3
  20. obj.b = 4; // Error: No such property/method!
  21. obj.bar(); // Error: No such property/method!

在处理器如何定义的角度上,这里的 代理后置 设计相当简单。与拦截[[Get]][[Set]]操作并仅在目标属性存在时转送它们不同,我们依赖于这样一个事实:不管[[Get]]还是[[Set]]到达了我们的pobj后备对象,这个动作已经遍历了整个[[Prototype]]链并且没有找到匹配的属性。在这时我们可以自由地、无条件地抛出错误。很酷,对吧?

代理黑入 [[Prototype]]

[[Get]]操作是[[Prototype]]机制被调用的主要渠道。当一个属性不能在直接对象上找到时,[[Get]]会自动将操作交给[[Prototype]]对象。

这意味着你可以使用一个代理的get(..)机关来模拟或扩展这个[[Prototype]]机制的概念。

我们将考虑的第一种黑科技是创建两个通过[[Prototype]]循环链接的对象(或者说,至少看起来是这样!)。你不能实际创建一个真正循环的[[Prototype]]链,因为引擎将会抛出一个错误。但是代理可以假冒它!

考虑如下代码:

  1. var handlers = {
  2. get(target,key,context) {
  3. if (Reflect.has( target, key )) {
  4. return Reflect.get(
  5. target, key, context
  6. );
  7. }
  8. // 假冒循环的 `[[Prototype]]`
  9. else {
  10. return Reflect.get(
  11. target[
  12. Symbol.for( "[[Prototype]]" )
  13. ],
  14. key,
  15. context
  16. );
  17. }
  18. }
  19. },
  20. obj1 = new Proxy(
  21. {
  22. name: "obj-1",
  23. foo() {
  24. console.log( "foo:", this.name );
  25. }
  26. },
  27. handlers
  28. ),
  29. obj2 = Object.assign(
  30. Object.create( obj1 ),
  31. {
  32. name: "obj-2",
  33. bar() {
  34. console.log( "bar:", this.name );
  35. this.foo();
  36. }
  37. }
  38. );
  39. // 假冒循环的 `[[Prototype]]` 链
  40. obj1[ Symbol.for( "[[Prototype]]" ) ] = obj2;
  41. obj1.bar();
  42. // bar: obj-1 <-- 通过代理假冒 [[Prototype]]
  43. // foo: obj-1 <-- `this` 上下文环境依然被保留
  44. obj2.foo();
  45. // foo: obj-2 <-- 通过 [[Prototype]]

注意: 为了让事情简单一些,在这个例子中我们没有代理/转送[[Set]]。要完整地模拟[[Prototype]]兼容,你会想要实现一个set(..)处理器,它在[[Prototype]]链上检索一个匹配得属性并遵循它的描述符的行为(例如,set,可写性)。参见本系列的 this与对象原型

在前面的代码段中,obj2凭借Object.create(..)语句[[Prototype]]链接到obj1。但是要创建反向(循环)的链接,我们在obj1的symbol位置Symbol.for("[[Prototype]]")(参见第二章的“Symbol”)上创建了一个属性。这个symbol可能看起来有些特别/魔幻,但它不是的。它只是允许我使用一个被方便地命名的属性,这个属性在语义上看来是与我进行的任务有关联的。

然后,代理的get(..)处理器首先检查一个被请求的key是否存在于代理上。如果每个有,操作就被手动地交给存储在targetSymbol.for("[[Prototype]]")位置中的对象引用。

这种模式的一个重要优点是,在obj1obj2之间建立循环关系几乎没有入侵它们的定义。虽然前面的代码段为了简短而将所有的步骤交织在一起,但是如果你仔细观察,代理处理器的逻辑完全是范用的(不具体地知道obj1obj2)。所以,这段逻辑可以抽出到一个简单的将它们连在一起的帮助函数中,例如setCircularPrototypeOf(..)。我们将此作为一个练习留给读者。

现在我们看到了如何使用get(..)来模拟一个[[Prototype]]链接,但让我们将这种黑科技推动的远一些。与其制造一个循环[[Prototype]],搞一个多重[[Prototype]]链接(也就是“多重继承”)怎么样?这看起来相当直白:

  1. var obj1 = {
  2. name: "obj-1",
  3. foo() {
  4. console.log( "obj1.foo:", this.name );
  5. },
  6. },
  7. obj2 = {
  8. name: "obj-2",
  9. foo() {
  10. console.log( "obj2.foo:", this.name );
  11. },
  12. bar() {
  13. console.log( "obj2.bar:", this.name );
  14. }
  15. },
  16. handlers = {
  17. get(target,key,context) {
  18. if (Reflect.has( target, key )) {
  19. return Reflect.get(
  20. target, key, context
  21. );
  22. }
  23. // 假冒多重 `[[Prototype]]`
  24. else {
  25. for (var P of target[
  26. Symbol.for( "[[Prototype]]" )
  27. ]) {
  28. if (Reflect.has( P, key )) {
  29. return Reflect.get(
  30. P, key, context
  31. );
  32. }
  33. }
  34. }
  35. }
  36. },
  37. obj3 = new Proxy(
  38. {
  39. name: "obj-3",
  40. baz() {
  41. this.foo();
  42. this.bar();
  43. }
  44. },
  45. handlers
  46. );
  47. // 假冒多重 `[[Prototype]]` 链接
  48. obj3[ Symbol.for( "[[Prototype]]" ) ] = [
  49. obj1, obj2
  50. ];
  51. obj3.baz();
  52. // obj1.foo: obj-3
  53. // obj2.bar: obj-3

注意: 正如在前面的循环[[Prototype]]例子后的注意中提到的,我们没有实现set(..)处理器,但对于一个将[[Set]]模拟为普通[[Prototype]]行为的解决方案来说,它将是必要的。

obj3被设置为多重委托到obj1obj2。在obj2.baz()中,this.foo()调用最终成为从obj1中抽出foo()(先到先得,虽然还有一个在obj2上的foo())。如果我们将连接重新排列为obj2, obj1,那么obj2.foo()将被找到并使用。

同理,this.bar()调用没有在obj1上找到bar(),所以它退而检查obj2,这里找到了一个匹配。

obj1obj2代表obj3的两个平行的[[Prototype]]链。obj1和/或obj2自身可以拥有委托至其他对象的普通[[Prototype]],或者自身也可以是多重委托的代理(就像obj3一样)。

正如先前的循环[[Prototype]]的例子一样,obj1obj2obj3的定义几乎完全与处理多重委托的范用代理逻辑相分离。定义一个setPrototypesOf(..)(注意那个“s”!)这样的工具将是小菜一碟,它接收一个主对象和一组模拟多重[[Prototype]]链接用的对象。同样,我们将此作为练习留给读者。

希望在这种种例子之后代理的力量现在变得明朗了。代理使得许多强大的元编程任务成为可能。