請看這樣一段代碼:
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
顯示結果可能與我們的理解不太一樣,爲什麼會輸出“setting count
”和“getting count
”?其實,我們攔截了這個對象的屬性訪問方法,然後將“.”運算符重載了。
它是如何做到的?
計算領域最好的的技巧是虛擬化,這種技術一般用來實現驚人的功能。它的工作機制如下:
-
隨便選一張照片。
-
在圖片中圍繞某物勾勒出一個輪廓。
-
現在替換掉輪廓中的內容,或者替換掉輪廓外的內容,但是始終要遵循向後兼容的規則,替換前後的圖片要儘可能相似,不能讓輪廓兩側的圖像過於突兀。
你可能在《楚門的世界》和《黑客帝國》這類經典的計算機科學電影中見到過類似的hack方法,將世界劃分爲兩個部分,主人公生活在內部世界,外部世界被精心編造的常態幻覺所替換。
爲了滿足向後兼容的規則,你需要巧妙地設計填補進去的圖片,但是真正的技巧是正確地勾勒輪廓。
我所謂的輪廓是指一個API邊界或接口,接口可以詳細說明兩段代碼的交互方式以及交互雙方對另一半的需求。所以如果一旦在系統中設計好了接口,輪廓自然就清晰了,這樣就可以任意替換接口兩側的內容而不影響二者的交互過程。
如果沒有現成的接口,就需要施展你的創意才華來創造新接口,有史以來最酷的軟件hack總是會勾勒一些之前從未有過的API邊界,然後通過大量的工程化實踐將接口引入到現有的體系中去。
虛擬內存、硬件虛擬化、Docker、Valgrind、rr等不同抽象程度的項目都會基於現有的系統推動開發一些令人意想不到的新接口。在某些情況下,需要花費數年的時間、新的操作系統特性甚至是新的硬件來使新的邊界良好運轉。
最棒的虛擬化hack會帶來對需要虛擬的東西的新的理解。想要編寫一個API,你需要充分理解你所面向的對象,一旦你理解透徹,就能實現出令人驚異的成果。
而ES6則爲JavaScript中最基本的概念“對象(object)”引入了虛擬化支持。
所以,對象到底是什麼?
噢,我是說真的,請花費一點時間仔細想想這個問題的答案。當你清楚自己知道對象是什麼的的時候再向下滾動。
這個問題於我而言太難了!我從未聽到過一個非常滿意的定義。
這會讓你感到驚訝麼?定義基礎概念向來很困難——抽空看看歐幾里得在《幾何原本》中的前幾個定義你就知道了。ECMAScript語言規範很棒,可是卻將對象定義爲“type對象的成員”,這種定義真的對我們沒什麼幫助。
後來,規範中又添加了一個定義:“對象是屬性的集合”。這句話沒錯,目前來說可以這樣定義,我們稍後繼續討論。
我之前說過,想要編寫一個API,你需要充分理解你所面向的對象。所以在某種程度上,我也算對本文做出一個承諾,我們會一起深入理解對象的細節,然後一起實現酷炫的功能。
那麼我們就跟隨ECMAScript標準委員會的腳步,爲JavaScript對象定義一個API,一個接口。問題是我們需要什麼方法?對象又可以做什麼呢?
這個問題的答案一定程度上取決於對象的類型:DOM元素對象可以做一部分事情,音頻節點對象又可以做另外一部分事情,但是所有對象都會共享一些基礎功能:
- 對象都有屬性。你可以get、set或刪除它們或做更多操作。
- 對象都有原型。這也是JS中繼承特性的實現方式。
- 有一些對象是可以被調用的函數或構造函數。
幾乎所有處理對象的JS程序都是使用屬性、原型和函數來完成的。甚至元素或聲音節點對象的特殊行爲也是通過調用繼承自函數屬性的方法來進行訪問。
所以ECMAScript標準委員會定義了一個由14種內部方法組成的集合,亦即一個適用於所有對象的通用接口,屬性、原型和函數這三種基礎功能自然成爲它們關注的核心。
我們可以在ES6標準列表5和6中找到全部的14種方法,我只會在這裏講解其中一部分。雙方括號[[ ]]代表內部方法,在一般的JS代碼中不可見,你可以調用、刪除或覆寫普通方法,但是無法操作內部方法。
-
obj.[[Get]](key, receiver) – 獲取屬性值。
當JS代碼執行以下方法時被調用:
obj.prop
或obj[key]
。obj是當前被搜索的對象,receiver是我們首先開始搜索這個屬性的對象。有時我們必須要搜索幾個對象,obj可能是一個在receiver原型鏈上的對象。
-
obj.[[Set]](key, value, receiver) – 爲對象的屬性賦值。
當JS代碼執行以下方法時被調用:
obj.prop = value
或obj[key] = value
。執行類似
obj.prop += 2
這樣的賦值語句時,首先調用[[Get]]方法,然後調用[[Set]]方法。對於++和--操作符來說亦是如此。 -
obj.[HasProperty] – 檢測對象中是否存在某屬性。
當JS代碼執行以下方法時被調用:
key in obj
。 -
obj.[Enumerate] – 列舉對象的可枚舉屬性。
當JS代碼執行以下方法時被調用:
for (key in obj)
…這個內部方法會返回一個可迭代對象,
for-in
循環可通過這個方法得到對象屬性的名稱。 -
obj.[GetPrototypeOf] – 返回對象的原型。
當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標準中,只要有可能,任何語法或對象相關的內建函數都是基於這14種內部方法構建的。ES6在對象的中樞系統周圍劃分了一個清晰的界限,你可以藉助代理特性用任意JS代碼替換標準中樞系統的內部方法。
既然我們馬上要開始討論覆寫內部方法的相關問題,請記住,我們要討論的是諸如obj.prop
的核心語法、諸如Object.keys()
的內建函數等的行爲。
代理 Proxy
ES6規範定義了一個全新的全局構造函數:代理(Proxy)。它可以接受兩個參數:目標對象(target)與句柄對象(handler)。請看一個簡單的示例:
var target = {}, handler = {};
var proxy = new Proxy(target, handler);
我們先來探討代理和目標對象之間的關係,然後再研究句柄對象的功用。
代理的行爲很簡單:將代理的所有內部方法轉發至目標。簡單來說,如果調用proxy.[[Enumerate]]()
,就會返回target.[[Enumerate]]()
。
現在,讓我們嘗試執行一條能夠觸發調用proxy.[[Set]]()
方法的語句。
proxy.color = "pink";
好的,剛剛都發生了什麼?proxy.[[Set]]()
應該調用target.[[Set]]()
方法,然後在目標上創建一個新的屬性。實際的結果如何?
> target.color
"pink"
是的,它做到了!對於所有其它內部方法而言同樣可以做到。新創建的代理會儘可能與目標的行爲一致。
當然,它們也不完全相同,你會發現proxy !== target
。有時也有目標能夠通過類型檢測而代理無法通過的情況發生,舉個例子,如果代理的目標是一個DOM元素,相應的代理就不是,此時類似document.body.appendChild(proxy)
的操作會觸發類型錯誤(TypeError
)。
代理句柄
現在我們繼續來討論一個讓代理充滿魔力的功能:句柄對象。
句柄對象的方法可以覆寫任意代理的內部方法。
舉個例子,你可以定義一個handler.set()
方法來攔截所有給對象屬性賦值的行爲:
var target = {};
var handler = {
set: function (target, key, value, receiver) {
throw new Error("請不要爲這個對象設置屬性。");
}
};
var proxy = new Proxy(target, handler);
> proxy.name = "angelina";
Error: 請不要爲這個對象設置屬性。
句柄方法的完整列表可以在MDN有關代理的頁面上找到,一共有14種方法,與ES6中定義的14中內部方法一致。
所有句柄方法都是可選的,沒被句柄攔截的內部方法會直接指向目標,與我們之前看到的別無二致。
小試牛刀(一):“不可能實現的”自動填充對象
到目前爲止,我們對於代理的瞭解程度足夠嘗試去做一些奇怪的事情,實現一些不借助代理根本無法實現的功能。
我們的第一個實踐,創建一個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" }}}
請注意,當我們需要時,所有中間對象branch1、branch2和branch3都可以自動創建。這固然很方便,但是如何實現呢?
在這之前,沒有可以實現這種特性的方法,但是通過代理,我們只用寥寥幾行就可以輕鬆實現,然後只需要接入tree.[[Get]]()
就可以。如果你喜歡挑戰,在繼續閱讀前可以嘗試自己實現。
這裏是我的解決方案:
function Tree() {
return new Proxy({}, handler);
}
var handler = {
get: function (target, key, receiver) {
if (!(key in target)) {
target[key] = Tree(); // 自動創建一個子樹
}
return Reflect.get(target, key, receiver);
}
};
注意最後的Reflect.get()
調用,在代理句柄方法中有一個極其常見的需求:只執行委託給目標的默認行爲。所以ES6定義了一個新的反射(Reflect)對象
,在其上有14種方法,你可以用它來實現這一需求。
小試牛刀(二):只讀視圖
我想我可能傳達給你們一個錯誤的印象,也就是代理易於使用。接下來的這個示例可能會讓你稍感困頓。
這一次我們的賦值語句更復雜:我們需要實現一個函數,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 = {
// 覆寫所有五種可變方法。
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) {
// 從執行默認行爲開始。
var result = Reflect.get(target, key, receiver);
// 確保返回一個不可變對象!
if (Object(result) === result) {
// result是一個對象。
return readOnlyView(result);
}
// result是一個原始原始類型,所以已經具備不可變的性質。
return result;
},
...
};
這仍然不夠,getPrototypeOf
和getOwnPropertyDescriptor
這兩個方法也需要進行同樣的處理。
然而還有更多問題,當通過這種代理調用getter或方法時,傳遞給getter或方法的this
的值通常是代理自身。但是正如我們之前所見,有時代理無法通過訪問器和方法執行的類型檢查。在這裏用目標對象代替代理更好一些。聰明的小夥伴,你知道如何解決這個問題麼?
由此可見,創建代理非常簡單,但是創建一個具有直觀行爲的代理相當困難。
隻言片語
-
代理到底好在哪裏?
代理可以幫助你觀察或記錄對象訪問,當調試代碼時助你一臂之力,測試框架也可以用代理來創建模擬對象(mock object)。
代理可以幫助你強化普通對象的能力,例如:惰性屬性填充。
我不太想提到這一點,但是如果要想了解代理在代碼中的運行方式,將代理的句柄對象包裹在另一個代理中是一個非常不錯的辦法,每當句柄方法被訪問時就可以將你想要的信息輸出到控制檯中。
正如上文中只讀視圖的示例
readOnlyView
,我們可以用代理來限制對象的訪問。當然在應用代碼中很少遇到這種用例,但是Firefox在內部使用代理來實現不同域名之間的安全邊界,是我們的安全模型的關鍵組成部分。 -
與WeakMap深度結合。在我們的
readOnlyView
示例中,每當對象被訪問的時候創建一個新的代理。這種做法可以幫助我們節省在WeakMap
中創建代理時的緩存內存,所以無論傳遞多少次對象給readOnlyView
,只會創建一個代理。這也是一個動人的WeakMap用例。
-
代理可解除。ES6規範中還定義了另外一個函數:
Proxy.revocable(target, handler)
。這個函數可以像new Proxy(target, handler)
一樣創建代理,但是創建好的代理後續可被解除。(Proxy.revocable
方法返回一個對象,該對象有一個.proxy
屬性和一個.revoke
方法。)一旦代理被解除,它即刻停止運行並拋出所有內部方法。 -
對象不變性。在某些情況下,ES6需要代理的句柄方法來報告與目標對象狀態一致的結果,以此來保證所有對象甚至是代理的不變性。舉個例子,除非目標不可擴展(inextensible),否則代理不能被聲明爲不可擴展的。
不變性的規則非常複雜,在此不展開詳述,但是如果你看到類似“proxy can't report a non-existent property as non-configurable
”這樣的錯誤信息,就可以考慮從不變性的角度解決問題,最可能的補救方法是改變代理報告本身,或者在運行時改變目標對象來反射代理的報告指向。
現在,你認爲對象是什麼?
我記得我們之前的見解是:“對象是屬性的集合。”
我不喜歡這個定義,即使給定義疊加原型和可調用能力也不會讓我改變看法。我認爲“集合(collection)”這個詞太危險了,不適合用作對象的定義。對象的句柄方法可以做任何事情,它們也可以返回隨機結果。
ECMAScript標準委員會針對這個問題開展了許多研究,搞清楚了對象能做的事情,將那些方法進行標準化,並將虛擬化技術作爲每個人都能使用的一等特性添加到語言的新標準中,爲前端開發領域拓展了無限可能。
完善後的對象幾乎可以表示任何事物。
對象是什麼?可能現在最貼切的答案需要用12個內部方法進行定義:對象是在JS程序中擁有[[Get]]、[[Set]]等操作的實體。
我不太確定我們是否比之前更瞭解對象,但是我們絕對做了許多驚豔的事情,是的,我們實現了舊版JS根本做不到的功能。
我現在可以使用代理麼?
不!在Web平臺上無論如何都不行。目前只有Firefox和微軟的Edge支持代理,而且還沒有支持這一特性polyfill。
如果你想在Node.js或io.js環境中使用代理,首先你需要添加名爲harmony-reflect的polyfill,然後在執行時啓用一個非默認的選項(--harmony_proxies
),這樣就可以暫時使用V8中實現的老版本代理規範。
放輕鬆,讓我們一起來做試驗吧!爲每一個對象創建成千上萬個相似的副本鏡像卻不能調試?現在就解放自己!不過目前來看,請不要將欠考慮的有關代理的代碼泄露到產品中,這非常危險。
代理特性在2010年由Andreas Gal首先實現,由Blake Kaplan進行代碼審查。標準委員會後來完全重新設計了這個特性。Eddy Bruel在2012年實現了新標準。
我實現了反射(Reflect)
特性,由Jeff Walden進行代碼審查。Firefox Nightly已經支持除Reflect.enumerate()
外的所有特性。