[譯]ES6 中的超程式設計: 第三部分 —— 代理(Proxies)

吳曉軍發表於2017-11-17

ES6 中的超程式設計: 第三部分 —— 代理(Proxies)

這是我的 ES6 超程式設計系列的第三部分,也是最後一部分,還記得這個系列的文章我一年之前就開始動筆了,並且承諾不會花一年才寫完,但現實就是我還真花費了如此多的時間去完成。在最後這篇文章中,我們要看看可能是 ES6 中最酷的反射特性:代理(Proxy)。由於反射和本文的部分內容有關,如果你還沒讀過上一篇講述 ES6 Reflect API 的文章,以及更早的、講述 ES6 Symbols 的文章,先倒回去閱讀一下,這樣才能更好地理解本文。和其他部分一樣,我先引用一下在第一部分提到過的觀點:

  • Symbols 是 實現了的反射(Reflection within implementation)—— 你將 Symbols 應用到你已有的類和物件上去改變它們的行為。
  • Reflect 是 通過自省(introspection)實現反射(Reflection through introspection) —— 通常用來探索非常底層的程式碼資訊。
  • Proxy 是 通過調解(intercession)實現反射(Reflection through intercession) —— 包裹物件並通過自陷(trap)來攔截物件行為。

因此,Proxy 是一個全新的全域性建構函式(類似 Date 或者 Number),你可以傳遞給其一個物件,以及一些鉤子(hook),它能為你返回一個 新的 物件,新的物件使用這些充滿魔力的鉤子包裹了老物件。現在,你擁有了代理,希望你喜歡它,我也高興你回到這個系列中來。

關於代理,有很多需要闡述的。但對新手來說,先讓我們看看怎麼建立一個代理。

建立代理

Proxy 建構函式接受兩個引數,其一是你想要代理的初始物件,其二是一系列處理鉤子(handler hooks)。我們先忽略第二個鉤子引數,看看怎麼為現有物件建立代理。線索即在代理這個名字中:它們維持了一個你建立物件的引用,但是如果你有了一個原始物件的引用,任何你和原始物件的互動,都會影響到代理,類似地,任何你對代理做的改變,反過來也都會影響到原始物件。換句話說,Proxy 返回了一個包裹了傳入物件的新物件,但是任何你對二者的操作,都會影響到它們彼此。為了證實這一點,請看程式碼:

var myObject = {};
var proxiedMyObject = new Proxy(myObject, {/*以及一系列處理鉤子*/});

assert(myObject !== proxiedMyObject);

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

proxiedMyObject.bar = true;
assert(myObject.bar === true);複製程式碼

目前為止,我們什麼目的也沒達到,相較於直接使用被代理物件,代理並不能提供任何額外收益。只有用上了處理鉤子,我們才能在代理上做一些有趣的事兒。

代理的處理鉤子

處理鉤子是一系列的函式,每一個鉤子都有一個具體名字以供代理識別,每一個鉤子也控制了你如何和代理互動(因此,也控制了你和被包裹物件的互動)。處理鉤子勾住了 JavaScript 的 “內建方法”,如果你對此感覺熟悉,是因為我們在 上一篇介紹 Reflect API 的文章 中提到了內建方法。

是時候鋪開來說代理了。我把代理放到系列的最後一部分的重要原因是:由於代理和反射就像一對苦命鴛鴦交織在一起,因此我們需要先知道反射是如何工作的。如你所見,每一個代理鉤子都對應到一個反射方法,反之亦然,每一個反射方法都有一個代理鉤子。完整的反射方法及對應的代理處理鉤子如下:

  • apply (以一個 this 引數和一系列 arguments(引數序列)呼叫函式)
  • construct(以一系列 arguments 及一個可選的、指明瞭原型的建構函式呼叫一個類函式或者建構函式)
  • defineProperty (在物件上定義一個屬性,並宣告該屬性中諸如物件可迭代性這樣的元資訊)
  • getOwnPropertyDescriptor (獲得一個屬性的 “屬性描述子”:描述子包含了諸如物件可迭代性這樣的元資訊)
  • deleteProperty (從物件上刪除某個屬性)
  • getPrototypeOf (獲得某例項的原型)
  • setPrototypeOf (設定某例項的原型)
  • isExtensible (判斷一個物件是否是 “可擴充套件的”,亦即判斷是否可以為其新增屬性)
  • preventExtensions (阻止物件被擴充套件)
  • get (得到物件的某個屬性)
  • set (設定物件的某個屬性)
  • has (在不斷言(assert)屬性值的情況下,判斷物件是否含有某個屬性)
  • ownKeys (獲得某個物件自身所有的 key,排除掉其原型上的 key)

反射那一部分中(再囉嗦一遍,如果你沒看過,趕快去看),我們已經瀏覽過上述所有方法了(並附帶有例子)。代理用相同的引數集實現了每一個方法。實際上, 代理的預設行為實際上已經實現了對每個處理程式鉤子的反射呼叫(其內部機制對於不同的 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,
});複製程式碼

現在,我可以深入到每個代理鉤子的工作細節中去了,但是基本上都是複製貼上反射中的例子(只需要修改很少的部分)。如果只是介紹每個鉤子的功能,對代理來說就不太公平,因為代理是去實現一些炫酷用例的。所以,本文剩餘內容都將為你展示通過代理完成的炫酷的東西,甚至是一些你沒了代理就無法完成的事。

同時,為了讓內容更具互動性,我為每個例子都建立一個小的庫來展示對應的功能。我會給出每個例子對應的程式碼倉庫連結。

用代理來......

構建一個可無限連結(chainable)的 API

以前面的例子為基礎 —— 我們仍使用 [[Get]] 自陷:只需要再施加一點魔法,我們就能構建一個擁有無數方法的 API,當你最終呼叫其中某個方法時,將返回所有你被你連結的值。fluent API(流暢 API) 為 web 請求構建了各個 URL,Chai 這類的測試框架將各個英文單詞連結組成高可讀的測試斷言,通過這些,我們知道可無限連結的 API 是多麼有用。

為了實現這個 API,我們就需要鉤子勾住 [[Get]],將取到的屬性儲存到陣列中。代理 ( Proxy ) 將包裝一個函式,返回所有檢索到的支援的Array,並清空陣列,以便可以重用它。我們也會勾住 [[HasProperty]],因為我們想告訴 API 的使用者,任何屬性都是存在的。

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')複製程式碼

你也可以用相同的模式實現一個樹遍歷的 fluent 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/p… 上,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,該方法能夠按預期被執行。對於不存在方法,比如 foo 或者 this_method_does_not_exist,在呼叫時會被 method_missing 替代。另外,method_missing 接受方法名作為第一個引數,這對於判斷使用者意圖非常有用。

我們可以通過混入 ES6 Symbol 實現類似的功能:使用一個函式包裹類,該函式將返回使用了 get[[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 on Rails 中的 ActiveRecord,其源於 “動態查詢(dynamic finders)”。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/p…,npm 上也可以到同名的包。

getOwnPropertyNamesObject.keysin 等所有迭代方法中隱藏所有的屬性

我們可以使用代理讓一個物件的所有的屬性都隱藏起來,除非是要獲得屬性的值。下面羅列了所有 JavaScript 中你可以判斷某屬性是否存在於一個物件的方法:

  • Reflect.hasObject.hasOwnPropertyObject.prototype.hasOwnProperty 以及 in 運算子全部使用了 [[HasProperty]]。代理可以通過 has 攔截它。
  • Object.keys/Object.getOwnPropertyNames 都使用了 [[OwnPropertyKeys]]。代理可以通過 ownKeys 進行攔截。
  • Object.entries (一個即將到來的 ES2017 特性),也使用了 [[OwnPropertyKeys]],代理仍然可以通過 ownKeys 進行攔截。
  • Object.getOwnPropertyDescriptor 使用了 [[GetOwnProperty]]。特別特別讓人興奮的是,代理可以通過 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/p…,它能讓你單獨地設定某個屬性不可見了,而不是一鍋端地讓所有屬性不可見。

實現一個觀察者模式,也稱作 Object.observe

對新規範所新增的內容一直敏銳追蹤的人們可能已經注意到了, Object.observe 開始被考慮納入 ES2016 了。Object.observe 的擁護者已經開始計劃 起草包含有有 Object.observe 的提案,他們對此有一個非常好的理由:草案初衷就是要幫助框架作者解決資料繫結(Data Binding)的問題。現在,隨著 React 和 Polymer 1.0 的釋出,資料繫結框架有所降溫,不可變資料(immutable data)開始變得流行。

慶幸的是,代理讓諸如 Object.observe 這樣的規範變得多餘,現在我們可以通過代理實現一個更加底層的 Object.observe。為了更加接近 Object.observe 所具有的特性,我們需要鉤住 [[Set]][[PreventExtensions]][[Delete]] 以及 [[DefineOwnProperty]] 這些內建方法 —— 代理分別可以使用 setpreventExtensionsdeletePropertydefineProperty 進行攔截:

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 是能夠改變物件的,而代理則返回了一個新物件,並且 unobserver 函式也不是全域性的。

和其他例子一樣,我也寫了關於此的一個庫並放在了 github.com/keithamus/p… 以及 npm 上。

獎勵關卡:可撤銷代理

代理還有最後一個大招:一些代理可以被撤銷。為了建立一個可撤銷的代理,你需要使用 Proxy.revocable(target, handler) (而不是 new Proxy(target, handler)),並且,最終返回一個結構為 {proxy, revoke()} 的物件來替代直接返回一個代理物件。例子如下:

function youOnlyGetOneSafetyNet(object) {
  var revocable = Proxy.revocable(object, {
    get(target, property) {
      if (Reflect.has(target, property)) {
        return Reflect.get(target, 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 返回一致(這會使得代理冗餘,並讓物件表現得好像從未設定過代理一樣),將使該特性更加有用。這個特性被放在了本文壓軸部分,也是因為我也不真正確定可撤回代理的具體用例。

總結

我希望這篇文章讓你認識到代理是一個強大到不可思議的工具,它彌補了 JavaScript 內部曾經的缺失。在方方面面,Symbol、Reflect、以及代理都為 JavaScript 開啟了新的篇章 —— 就如同 const 和 let,類和箭頭函式那樣。const 和 let 不再讓程式碼顯得混亂骯髒,類和箭頭函式讓程式碼更簡潔,Symbol、Reflect、和 Proxy 則開始給予開發者在 JavaScript 中進行底層的超程式設計。

這些新的超程式設計工具不會在短時間內放慢發展的速度:EcamScript 的新版本正逐漸完善,並新增了更多有趣的行為,例如 Reflect.isCallableReflect.isConstructor 的提案,亦或 stage 0 關於 Reflect.type 的提案,亦或 function.sent 這個元屬性的提案
,亦或這個包含了更多函式元屬性的提案。這些新的 API 也激發了一些關於新特性的有趣討論,例如 這個關於新增 Reflect.parse 的提案,就引起了關於建立一個 AST(Abstract Syntax Tree:抽象語法樹)標準的討論。

你是怎麼看待新的 Proxy API 的?已經計劃用在你的專案裡面了?可以在 Twitter 上給我留言讓我知道你的想法,我是 @keithamus


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章