前面的話
ES5和ES6致力於為開發者提供JS已有卻不可呼叫的功能。例如在ES5出現以前,JS環境中的物件包含許多不可列舉和不可寫的屬性,但開發者不能定義自己的不可列舉或不可寫屬性,於是ES5引入了Object.defineProperty()方法來支援開發者去做JS引擎早就可以實現的事情。ES6新增了一些內建物件,賦予開發者更多訪問JS引擎的能力。代理(Proxy)是一種可以攔截並改變底層JS引擎操作的包裝器,在新語言中通過它暴露內部運作的物件,從而讓開發者可以建立內建的物件。本文將詳細介紹代理(Proxy)和反射(Reflection)
引入
【陣列問題】
在ES6之前,開發者不能通過自己定義的物件模仿JS陣列物件的行為方式。當給陣列的特定元素賦值時,影響到該陣列的length屬性,也可以通過length屬性修改陣列元素
1 2 3 4 5 6 7 8 9 10 |
let colors = ["red", "green", "blue"]; console.log(colors.length); // 3 colors[3] = "black"; console.log(colors.length); // 4 console.log(colors[3]); // "black" colors.length = 2; console.log(colors.length); // 2 console.log(colors[3]); // undefined console.log(colors[2]); // undefined console.log(colors[1]); // "green" |
colors陣列一開始有3個元素,將colors[3]賦值為”black”時,length屬性會自動增加到4,將length屬性設定為2時,會移除陣列的後兩個元素而只保留前兩個。在ES5之前開發者無法自己實現這些行為,現在通過代理可以實現
代理和反射
呼叫new Proxy()可建立代替其他目標(target)物件的代理,它虛擬化了目標,所以二者看起來功能一致
代理可以攔截JS引擎內部目標的底層物件操作,這些底層操作被攔截後會觸發響應特定操作的陷阱函式
反射API以Reflect物件的形式出現,物件中方法的預設特性與相同的底層操作一致,而代理可以覆寫這些操作,每個代理陷阱對應一個命名和引數都相同的Reflect方法。下表總結了代理陷阱的特性
每個陷阱覆寫JS物件的一些內建特性,可以用它們攔截並修改這些特性。如果仍需使用內建特性,則可以使用相應的反射API方法
【建立簡單代理】
用Proxy建構函式建立代理需要傳入兩個引數:目標(target)和處理程式(handler)。處理程式用於定義一個或多個陷阱的物件,在代理中,除了專門為操作定義的陷阱外,其餘操作均使用預設特性。不使用任何陷阱的處理程式等價於簡單的轉發代理
1 2 3 4 5 6 7 8 |
let target = {}; let proxy = new Proxy(target, {}); proxy.name = "proxy"; console.log(proxy.name); // "proxy" console.log(target.name); // "proxy" target.name = "target"; console.log(proxy.name); // "target" console.log(target.name); // "target" |
這個示例中的代理將所有操作直接轉發到目標,將”proxy”賦值給proxy.name屬性時會在目標上建立name,代理只是簡單地將操作轉發給目標,它不會儲存這個屬性。由於proxy.name和target.name引用的都是target.name,因此二者的值相同,從而為target.name設定新值後,proxy.name也一同變化
陷阱代理
【使用set陷阱驗證屬性】
假設建立一個屬性值是數字的物件,物件中每新增一個屬性都要加以驗證,如果不是數字必須丟擲錯誤。為了實現這個任務,可以定義一個set陷阱來覆寫設定值的預設特性
set陷阱接受4個引數
1 2 3 4 |
trapTaqget 用於接收屬性(代理的目標)的物件 key 要寫入的屬性鍵(字串或Symbol型別) value 被寫入屬性的值 receiver 操作發生的物件(通常是代理) |
Reflect.set()是set陷阱對應的反射方法和預設特性,它和set代理陷阱一樣也接受相同的4個引數,以方便在陷阱中使用。如果屬性已設定陷阱應該返回true,如果未設定則返回false。(Reflect.set()方法基於操作是否成功來返回恰當的值)
可以使用set陷阱並檢查傳入的值來驗證屬性值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
let target = { name: "target" }; let proxy = new Proxy(target, { set(trapTarget, key, value, receiver) { // 忽略已有屬性,避免影響它們 if (!trapTarget.hasOwnProperty(key)) { if (isNaN(value)) { throw new TypeError("Property must be a number."); } } // 新增屬性 return Reflect.set(trapTarget, key, value, receiver); } }); // 新增一個新屬性 proxy.count = 1; console.log(proxy.count); // 1 console.log(target.count); // 1 // 你可以為 name 賦一個非數值型別的值,因為該屬性已經存在 proxy.name = "proxy"; console.log(proxy.name); // "proxy" console.log(target.name); // "proxy" // 丟擲錯誤 proxy.anotherName = "proxy"; |
這段程式碼定義了一個代理來驗證新增到target的新屬性,當執行proxy.count=1時,set陷阱被呼叫,此時trapTarget的值等於target,key等於”count”,value等於1,receiver等於proxy
由於target上沒有count屬性,因此代理繼續將value值傳入isNaN(),如果結果是NaN,則證明傳入的屬性值不是數字,同時也丟擲一個錯誤。在這段程式碼中,count被設定為1,所以代理呼叫Reflect.set()方法並傳入陷阱接受的4個引數來新增新屬性
proxy.name可以成功被賦值為一個字串,這是因為target已經擁有一個name屬性,但通過呼叫trapTarget.hasownproperty()方法驗證檢查後被排除了,所以目標已有的非數字屬性仍然可以被操作。
然而,將proxy.anotherName賦值為一個字串時會丟擲錯誤。目標上沒有anotherName屬性,所以它的值需要被驗證,而由於”Proxy”不是一個數字值,因此丟擲錯誤
set代理陷阱可以攔截寫入屬性的操作,get代理陷阱可以攔截讀取屬性的操作
【用get陷阱驗證物件結構(Object Shape)】
JS有一個時常令人感到困惑的特殊行為,即讀取不存在的屬性時不會丟擲錯誤,而是用undefined代替被讀取屬性的值
1 2 |
let target = {}; console.log(target.name); // undefined |
在大多數其他語言中,如果target沒有name屬性,嘗試讀取target.name會丟擲一個錯誤。但JS卻用undefined來代替target.name屬性的值。這個特性會導致重大問題,特別是當錯誤輸入屬性名稱的時候,而代理可以通過檢查物件結構來回避這個問題
物件結構是指物件中所有可用屬性和方法的集合,JS引擎通過物件結構來優化程式碼,通常會建立類來表示物件,如果可以安全地假定一個物件將始終具有相同的屬性和方法,那麼當程式試圖訪問不存在的屬性時會丟擲錯誤。代理讓物件結構檢驗變得簡單
因為只有當讀取屬性時才會檢驗屬性,所以無論物件中是否存在某個屬性,都可以通過get陷阱來檢測,它接受3個引數
1 2 3 |
trapTarget 被讀取屬性的源物件(代理的目標) key 要讀取的屬性鍵(字串或Symbol) receiver 操作發生的物件(通常是代理) |
由於get陷阱不寫入值,所以它復刻了set陷阱中除value外的其他3個引數,Reflect.get()也接受同樣3個引數並返回屬性的預設值
如果屬性在目標上不存在,則使用get陷阱和Reflect.get()時會丟擲錯誤
1 2 3 4 5 6 7 8 9 10 11 12 13 |
let proxy = new Proxy({}, { get(trapTarget, key, receiver) { if (!(key in receiver)) { throw new TypeError("Property " + key + " doesn't exist."); } return Reflect.get(trapTarget, key, receiver); } }); // 新增屬性的功能正常 proxy.name = "proxy"; console.log(proxy.name); // "proxy" // 讀取不存在屬性會丟擲錯誤 console.log(proxy.nme); // 丟擲錯誤 |
此示例中的get陷阱可以攔截屬性讀取操作,並通過in操作符來判斷receiver上是否具有被讀取的屬性,這裡之所以用in操作符檢查receiver而不檢查trapTarget,是為了防止receiver代理含有has陷阱。在這種情況下檢查trapTarget可能會忽略掉has陷阱,從而得到錯誤結果。屬性如果不存在會丟擲一個錯誤,否則就使用預設行為
這段程式碼展示瞭如何在沒有錯誤的情況下給proxy新增新屬性name,並寫入值和讀取值。最後一行包含一個輸入錯誤:proxy.nme有可能是proxy.namer,由於nme是一個不存在的屬性,因而丟擲錯誤
【使用has陷阱隱藏已有屬性】
可用in操作符來檢測給定物件是否含有某個屬性,如果自有屬性或原型屬性匹配這個名稱或Symbol返回true
1 2 3 4 5 |
let target = { value: 42; } console.log("value" in target); // true console.log("toString" in target); // true |
value是一個自有屬性,tostring是一個繼承自Object的原型屬性,二者在物件上都存在,所以用in操作符檢測二者都返回true。在代理中使用has陷阱可以攔截這些in操作並返回一個不同的值
每當使用in操作符時都會呼叫has陷阱,並傳入兩個引數
1 2 |
trapTaqget讀取屬性的物件(代理的目標) key要檢查的屬性鍵(字串或Symbol) |
Reflect.has()方法也接受這些引數並返回in操作符的預設響應,同時使用has陷阱和Reflect.has()可以改變一部分屬性被in檢測時的行為,並恢復另外一些屬性的預設行為。例如,可以像這樣隱藏之前示例中的value屬性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
let target = { name: "target", value: 42 }; let proxy = new Proxy(target, { has(trapTarget, key) { if (key === "value") { return false; } else { return Reflect.has(trapTarget, key); } } }); console.log("value" in proxy); // false console.log("name" in proxy); // true console.log("toString" in proxy); // true |
代理中的has陷阱會檢查key是否為”value”,如果是的話返回false,若不是則呼叫Reflect.has()方法返回預設行為。結果是即使target上實際存在value屬性,但用in操作符檢查還是會返回false,而對於name和tostring則正確返回true
【用deleteProperty陷阱防止刪除屬性】
delete操作符可以從物件中移除屬性,如果成功則返回true,不成功則返回false。在嚴格模式下,如果嘗試刪除一個不可配置(nonconfigurable)屬性則會導致程式丟擲錯誤,而在非嚴格模式下只是返回false
1 2 3 4 5 6 7 8 9 10 11 12 13 |
let target = { name: "target", value: 42 }; Object.defineProperty(target, "name", { configurable: false }); console.log("value" in target); // true let result1 = delete target.value; console.log(result1); // true console.log("value" in target); // false // 注:下一行程式碼在嚴格模式下會丟擲錯誤 let result2 = delete target.name; console.log(result2); // false console.log("name" in target); // true |
用delete操作符刪除value屬性後,第三個console.log()呼叫中的in操作最終返回false。不可配置屬性name無法被刪除,所以delete操作返回false(如果這段程式碼執行在嚴格模式下會丟擲錯誤)。在代理中,可以通過deleteProperty陷阱來改變這個行為
每當通過delete操作符刪除物件屬性時,deleteProperty陷阱都會被呼叫,它接受兩個引數
1 2 |
trapTarget 要刪除屬性的物件(代理的目標) key 要刪除的屬性鍵(字串或Symbol) |
Reflect.deleteProperty()方法為deleteProperty陷阱提供預設實現,並且接受同樣的兩個引數。結合二者可以改變delete的具體表現行為,例如,可以像這樣來確保value屬性不會被刪除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
let target = { name: "target", value: 42 }; let proxy = new Proxy(target, { deleteProperty(trapTarget, key) { if (key === "value") { return false; } else { return Reflect.deleteProperty(trapTarget, key); } } }); // 嘗試刪除 proxy.value console.log("value" in proxy); // true let result1 = delete proxy.value; console.log(result1); // false console.log("value" in proxy); // true // 嘗試刪除 proxy.name console.log("name" in proxy); // true let result2 = delete proxy.name; console.log(result2); // true console.log("name" in proxy); // false |
這段程式碼與has陷阱的示例非常相似,deleteProperty陷阱檢查key是否為”value”,如果是的話返回false,否則呼叫Reflect.deleteProperty()方法來使用預設行為。由於通過代理的操作被捕獲,因此value屬性無法被刪除,但name屬性就如期被刪除了。如果希望保護屬性不被刪除,而且在嚴格模式下不丟擲錯誤,那麼這個方法非常使用
【原型代理陷阱】
Object.setPrototypeOf()方法被用於作為ES5中的Object.getPrototypeOf()方法的補充。通過代理中的setPrototypeOf陷阱和getPrototypeOf陷阱可以攔截這兩個方法的執行過程,在這兩種情況下,Object上的方法會呼叫代理中的同名陷阱來改變方法的行為
兩個陷阱均與代理有關,但具體到方法只與每個陷阱的型別有關,setPrototypeOf陷阱接受以下這些引數
1 2 |
trapTarget 接受原型設定的物件(代理的目標) proto 作為原型使用的物件 |
傳入Object.setPrototypeOf()方法和Reflect.setPrototypeOf()方法的均是以上兩個引數,另一方面,getPrototypeOf陷阱中的Object.getPrototypeOf()方法和Reflect.getPrototypeOf()方法只接受引數trapTarget
原型代理陷阱的執行機制
原型代理陷阱有一些限制。首先,getPrototypeOf陷阱必須返回物件或null,否則將導致執行時錯誤,返回值檢查可以確保Object.getPrototypeOf()返回的總是預期的值;其次,在setPrototypeOf陷阱中,如果操作失敗則返回的一定是false,此時Object.setPrototypeOf()會丟擲錯誤,如果setPrototypeOf返回了任何不是false的值,那麼Object.setPrototypeOf()便假設操作成功
以下示例通過總是返回null,且不允許改變原型的方式隱藏了代理的原型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
let target = {}; let proxy = new Proxy(target, { getPrototypeOf(trapTarget) { return null; }, setPrototypeOf(trapTarget, proto) { return false; } }); let targetProto = Object.getPrototypeOf(target); let proxyProto = Object.getPrototypeOf(proxy); console.log(targetProto === Object.prototype); // true console.log(proxyProto === Object.prototype); // false console.log(proxyProto); // null // 成功 Object.setPrototypeOf(target, {}); // 丟擲錯誤 Object.setPrototypeOf(proxy, {}); |
這段程式碼強調了target和proxy的行為差異。Object.getPrototypeOf()給target返回的是值,而給proxy返回值時,由於getPrototypeOf陷阱被呼叫,返回的是null;同樣,Object.setPrototypeOf()成功為target設定原型,而給proxy設定原型時,由於setPrototypeOf陷阱被呼叫,最終丟擲一個錯誤
如果使用這兩個陷阱的預設行為,則可以使用Reflect上的相應方法。例如,下面的程式碼實現了getPrototypeOf和setPrototypeOf陷阱的預設行為
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
let target = {}; let proxy = new Proxy(target, { getPrototypeOf(trapTarget) { return Reflect.getPrototypeOf(trapTarget); }, setPrototypeOf(trapTarget, proto) { return Reflect.setPrototypeOf(trapTarget, proto); } }); let targetProto = Object.getPrototypeOf(target); let proxyProto = Object.getPrototypeOf(proxy); console.log(targetProto === Object.prototype); // true console.log(proxyProto === Object.prototype); // true // 成功 Object.setPrototypeOf(target, {}); // 同樣成功 Object.setPrototypeOf(proxy, {}); |
由於本示例中的getPrototypeOf陷阱和setPrototypeOf陷阱僅使用了預設行為,因此可以交換使用target和paro×y並得到相同結果。由於Reflect.getPrototypeOf()方法和Reflect.setPrototypeOf()方法與Object上的同名方法存在一些重要差異,因此使用它們是很重要的
為什麼有兩組方法
令人困惑的是,Reflect.getPrototypeOf()方法和Reflect.setPrototypeOf()方法疑似Object.getPrototypeOf()方法和Object.setPrototypeOf()方法,儘管兩組方法執行相似的操作,但兩者間仍有一些不同之處
Object.getPrototypeOf()和Object.setPrototypeOf()是給開發者使用的高階操作;而Reflect.getPrototypeOf()方法和Reflect.setprototypeOf()方法則是底層操作,其賦予開發者可以訪問之前只在內部操作的[[GetPrototypeOf]]和[[setPrototypeOf]]的許可權
Reflect.getPrototypeOf()方法是內部[[GetprototypeOf]]操作的包裹器,Reflect.setPrototypeOf()方法與[[setPrototypeOf]]的關係與之相同。Object上相應的方法雖然也呼叫了[[GetPrototypeOf]]和[[Setprototypeof]],但在此之前會執行一些額外步驟,並通過檢查返回值來決定下一步的操作
如果傳入的引數不是物件,則Reflect.getPrototypeOf()方法會丟擲錯誤,而Object.getPrototypeOf()方法則會在操作執行前先將引數強制轉換為一個物件。給這兩個方法傳入一個數字,會得到不同的結果
1 2 3 4 |
let result1 = Object.getPrototypeOf(1); console.log(result1 === Number.prototype); // true // 丟擲錯誤 Reflect.getPrototypeOf(1); |
Object.getPrototypeOf()方法會強制讓數字1變為Number物件,所以可以檢索它的原型並得到返回值Number.prototype;而由於Reflect.getPrototypeOf()方法不強制轉化值的型別,而且1又不是一個物件,故會丟擲一個錯誤
Reflect.setPrototypeOf()方法與Object.setPrototypeOf()方法也不盡相同。具體而言,Reflect.setPrototypeOf()方法返回一個布林值來表示操作是否成功,成功時返回true,失敗則返回false;而Object.setPrototypeOf()方法一旦失敗則會丟擲一個錯誤
當setPrototypeOf代理陷阱返回false時會導致Object.setPrototypeOf()丟擲一個錯誤。Object.setPrototypeOf()方法返回第一個引數作為它的值,因此其不適合用於實現setPrototypeOf代理陷阱的預設行為
1 2 3 4 5 6 7 |
let target1 = {}; let result1 = Object.setPrototypeOf(target1, {}); console.log(result1 === target1); // true let target2 = {}; let result2 = Reflect.setPrototypeOf(target2, {}); console.log(result2 === target2); // false console.log(result2); // true |
在這個示例中,Object.setPrototypeOf()返回target1,但Reflect.setPrototypeOf()返回的是true。這種微妙的差異非常重要,在object和Reflect上還有更多看似重複的方法,但是在所有代理陷阱中一定要使用Reflect上的方法
【物件可擴充套件性陷阱】
ES5已經通過Object.preventExtensions()方法和Object.isExtensible()方法修正了物件的可擴充套件性,ES6可以通過代理中的preventExtensions和isExtensible陷阱攔截這兩個方法並呼叫底層物件。兩個陷阱都接受唯一引數trapTarget物件,並呼叫它上面的方法。isExtensible陷阱返回的一定是一個布林值,表示物件是否可擴充套件;preventExtensions陷阱返回的也一定是布林值,表示操作是否成功
Reflect.preventExtensions()方法和 Reflect.IsExtensible()方法實現相應陷阱中預設行為,二者都返回布林值
兩個基礎示例
以下這段程式碼是物件可擴充套件性陷阱的實際應用,實現了isExtensible和preventExtensions陷阱的預設行為
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let target = {}; let proxy = new Proxy(target, { isExtensible(trapTarget) { return Reflect.isExtensible(trapTarget); }, preventExtensions(trapTarget) { return Reflect.preventExtensions(trapTarget); } }); console.log(Object.isExtensible(target)); // true console.log(Object.isExtensible(proxy)); // true Object.preventExtensions(proxy); console.log(Object.isExtensible(target)); // false console.log(Object.isExtensible(proxy)); // false |
此示例展示了Object.preventExtensions()方法和Object.isExtensible()方法直接從proxy傳遞到target的過程,當然,可以改變這種預設行為,例如,如果想讓Object.preventExtensions()對於proxy失效,那麼可以在preventExtensions陷阱中返回false
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let target = {}; let proxy = new Proxy(target, { isExtensible(trapTarget) { return Reflect.isExtensible(trapTarget); }, preventExtensions(trapTarget) { return false } }); console.log(Object.isExtensible(target)); // true console.log(Object.isExtensible(proxy)); // true Object.preventExtensions(proxy); console.log(Object.isExtensible(target)); // true console.log(Object.isExtensible(proxy)); // true |
這裡的Object.preventExtensions(proxy)呼叫實際上被忽略了,這是因為preventExtensions陷阱返回了false,所以操作不會轉發到底層目標,Object.isExtensible()最終返回true
【重複的可擴充套件性方法】
Object.isExtensible()方法和Reflect.isExtensible()方法非常相似,只有當傳入非物件值時,Object.isExtensible()返回false,而Reflect.isExtensible()則丟擲一個錯誤
1 2 3 4 |
let result1 = Object.isExtensible(2); console.log(result1); // false // 丟擲錯誤 let result2 = Reflect.isExtensible(2); |
這條限制類似於Object.getPrototypeOf()方法與Reflect.getPrototypeOf()方法之間的差異,因為相比高階功能方法而言,底層的具有更嚴格的錯誤檢査
Object.preventExtensions()方法和Reflect.preventExtensions()方法同樣非常相似。無論傳入Object.preventExtensions()方法的引數是否為一個物件,它總是返回該引數;而如果Reflect.preventExtensions()方法的引數不是物件就會丟擲錯誤;如果引數是一個物件,操作成功時Reflect.preventExtensions()會返回true,否則返回false
1 2 3 4 5 6 7 |
let result1 = Object.preventExtensions(2); console.log(result1); // 2 let target = {}; let result2 = Reflect.preventExtensions(target); console.log(result2); // true // 丟擲錯誤 let result3 = Reflect.preventExtensions(2); |
在這裡,即使值2不是一個物件,Object.preventExtensions()方法也將其透傳作為返回值,而Reflect.preventExtensions()方法則會丟擲錯誤,只有當傳入物件時它才返回true
【屬性描述符陷阱】
ES5最重要的特性之一是可以使用Object.defineProperty()方法定義屬性特性(property attribute)。在早期版本的JS中無法定義訪問器屬性,無法將屬性設定為只讀或不可配置。直到Object.defineProperty()方法出現之後才支援這些功能,並且可以通過Object.getOwnPropertyDescriptor()方法來獲取這些屬性
在代理中可以分別用defineProperty陷阱和getOwnPropertyDescriptor陷阱攔截 Object.defineProperty()方法和Object.getOwnPropertyDescriptor()方法的呼叫。definePropepty陷阱接受以下引數
1 2 3 |
trapTarget 要定義屬性的物件(代理的目標) key 屬性的鍵(字串或Symbol) descriptor 屬性的描述符物件 |
defineProperty陷阱需要在操作成功後返回true,否則返回false。getOwnPropertyDescriptor陷阱只接受trapTarget和key兩個引數,最終返回描述符。Reflect.defineProperty()方法和Reflect.getOwnPropertyDescriptor()方法與對應的陷阱接受相同引數。這個示例實現的是每個陷阱的預設行為
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let proxy = new Proxy({}, { defineProperty(trapTarget, key, descriptor) { return Reflect.defineProperty(trapTarget, key, descriptor); }, getOwnPropertyDescriptor(trapTarget, key) { return Reflect.getOwnPropertyDescriptor(trapTarget, key); } }); Object.defineProperty(proxy, "name", { value: "proxy" }); console.log(proxy.name); // "proxy" let descriptor = Object.getOwnPropertyDescriptor(proxy, "name"); console.log(descriptor.value); // "proxy" |
這段程式碼通過Object.defineProperty()方法在代理上定義了屬性”name”,該屬性的描述符可通過Object.getOwnPropertyDescriptor()方法來獲取
給Object.defineProperty()新增限制
defineProperty陷阱返回布林值來表示操作是否成功。返回true時,Object.defineProperty()方法成功執行;返回false時,Object.defineProperty()方法丟擲錯誤。這個功能可以用來限制Object.defineProperty()方法可定義的屬性型別,例如,如果希望阻止Symbol型別的屬性,則可以當屬性鍵為symbol時返回false
當key是Symbol型別時defineProperty代理陷阱返回false,否則執行預設行為。呼叫Object.defineProperty()並傳入”name”,因此鍵的型別是字串所以方法成功執行;呼叫Object.defineProperty()方法並傳入nameSymbol,defineProperty陷阱返回false所以丟擲錯誤
[注意]如果讓陷阱返回true並且不呼叫Reflect.defineProperty()方法,則可以讓Object.defineProperty()方法靜默失效,這既消除了錯誤又不會真正定義屬性
描述符物件限制
為確保Object.defineProperty()方法和Object.getOwnPropertyDescriptor()方法的行為一致,傳入defineProperty陷阱的描述符物件已規範化。從getOwnPropertyDescriptor陷阱返回的物件由於相同原因被驗證
無論將什麼物件作為第三個引數傳遞給Object.defineProperty()方法,都只有屬性enumerable、configurable、value、writable、get和set將出現在傳遞給defineProperty陷阱的描述符物件中
1 2 3 4 5 6 7 8 9 10 11 |
let proxy = new Proxy({}, { defineProperty(trapTarget, key, descriptor) { console.log(descriptor.value); // "proxy" console.log(descriptor.name); // undefined return Reflect.defineProperty(trapTarget, key, descriptor); } }); Object.defineProperty(proxy, "name", { value: "proxy", name: "custom" }); |
在這段程式碼中,呼叫Object.defineProperty()時傳入包含非標準name屬性的物件作為第三個引數。當defineProperty陷阱被呼叫時,descriptor物件有value屬性卻沒有name屬性,這是因為descriptor不是實際傳入Object.defineProperty()方法的第三個引數的引用,而是一個只包含那些被允許使用的屬性的新物件。Reflect.defineProperty()方法同樣也忽略了描述符上的所有非標準屬性
getOwnPropertyDescriptor陷阱的限制條件稍有不同,它的返回值必須是null、undefined或一個物件。如果返回物件,則物件自己的屬性只能是enumepable、configurable、value、writable、get和set,在返回的物件中使用不被允許的屬性會丟擲一個錯誤
1 2 3 4 5 6 7 8 9 |
let proxy = new Proxy({}, { getOwnPropertyDescriptor(trapTarget, key) { return { name: "proxy" }; } }); // 丟擲錯誤 let descriptor = Object.getOwnPropertyDescriptor(proxy, "name"); |
屬性描述符中不允許有name屬性,當呼叫Object.getOwnPropertyDescriptor()時,getOwnPropertyDescriptor的返回值會觸發一個錯誤。這條限制可以確保無論代理中使用了什麼方法,Object.getOwnPropertyDescriptor()返回值的結構總是可靠的
重複的描述符方法
再一次在ES6中看到這些令人困惑的相似方法:看起來Object.defineProperty()方法和Object.getOwnPropertyDescriptor()方法分別與Reflect.defineProperty()方法和Reflect.getOwnPropertyDescriptor()方法做了同樣的事情。這4個方法也有一些微妙但卻很重要的差異
Object.defineProperty()方法和Reflect.defineProperty()方法只有返回值不同:Object.defineProperty()方法返回第一個引數,而Reflect.defineProperty()的返回值與操作有關,成功則返回true,失敗則返回false
1 2 3 4 5 |
let target = {}; let result1 = Object.defineProperty(target, "name", { value: "target "}); console.log(target === result1); // true let result2 = Reflect.defineProperty(target, "name", { value: "reflect" }); console.log(result2); // true |
呼叫Object.defineProperty()時傳入target,返回值是target;呼叫Reflect.defineProperty()時傳入target,返回值是true,表示操作成功。由於defineProperty代理陷阱需要返回一個布林值,因此必要時最好用Reflect.defineProperty()來實現預設行為
呼叫Object.getOwnPropertyDescriptor()方法時傳入原始值作為第一個引數,內部將這個值強制轉換為一個物件;另一方面,若呼叫Reflect.getOwnPropertyDescriptor()方法時傳入原始值作為第一個引數,則丟擲一個錯誤
1 2 3 4 |
let descriptor1 = Object.getOwnPropertyDescriptor(2, "name"); console.log(descriptor1); // undefined // 丟擲錯誤 let descriptor2 = Reflect.getOwnPropertyDescriptor(2, "name"); |
由於Object.getOwnPropertyDescriptor()方法將數值2強制轉換為一個不含name屬性的物件,因此它返回undefined,這是當物件中沒有指定的name屬性時的標準行為。然而當呼叫Reflect.getOwnPropertyDescriptor()時立即丟擲一個錯誤,因為該方法不接受原始值作為第一個引數
【ownKeys陷阱】
ownKeys代理陷阱可以攔截內部方法[[OwnPropertyKeys]],我們通過返回個陣列的值可以覆寫其行為。這個陣列被用於Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Object.assign()4個方法,Object.assign()方法用陣列來確定需要複製的屬性
ownKeys陷阱通過Reflect.ownKeys()方法實現預設的行為,返回的陣列中包含所有自有屬性的鍵名,字串型別和Symbol型別的都包含在內。Object.getOwnPropertyNames()方法和Object.keys()方法返回的結果將Symbol型別的屬性名排除在外,Object.getOwnPropertySymbols()方法返回的結果將字串型別的屬性名排除在外。Object.assign()方法支援字串和Symbol兩種型別
ownKeys陷阱唯一接受的引數是操作的目標,返回值必須是一個陣列或類陣列物件,否則就丟擲錯誤。當呼叫Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()或Object.assign()方法時,可以用ownKeys陷阱來過濾掉不想使用的屬性鍵。假設不想引入任何以下劃線字元(在JS中下劃線符號表示欄位是私有的)開頭的屬性名稱,則可以用ownKeys陷阱過濾掉那些鍵
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
let proxy = new Proxy({}, { ownKeys(trapTarget) { return Reflect.ownKeys(trapTarget).filter(key => { return typeof key !== "string" || key[0] !== "_"; }); } }); let nameSymbol = Symbol("name"); proxy.name = "proxy"; proxy._name = "private"; proxy[nameSymbol] = "symbol"; let names = Object.getOwnPropertyNames(proxy), keys = Object.keys(proxy); symbols = Object.getOwnPropertySymbols(proxy); console.log(names.length); // 1 console.log(names[0]); // "name" console.log(keys.length); // 1 console.log(keys[0]); // "name" console.log(symbols.length); // 1 console.log(symbols[0]); // "Symbol(name)" |
這個示例使用了一個ownKeys陷阱,它首先呼叫Reflect.ownKeys()獲取目標的預設鍵列表;接下來,用filter()過濾掉以下劃線字元開始的字串。然後,將3個屬性新增到proxy物件:name、_name和nameSymbol。呼叫Object.getOwnPropertyNames()和Object.Keys()時傳入proxy, 只返回name屬性;同樣,呼叫Object.getOwnPropertySymbols()時傳入proxy,只返回nameSymbol。由於_name屬性被過濾掉了,因此它不出現在這兩次結果中
儘管ownKeys代理陷阱可以修改一小部分操作返回的鍵,但不影響更常用的操作,例如for-of迴圈和Object.keys()方法,這些不能使用代理來更改。ownKeys陷阱也會影響for-in迴圈,當確定迴圈內部使用的鍵時會呼叫陷阱
【函式代理中的apply和construct陷阱】
所有代理陷阱中,只有apply和construct的代理目標是一個函式。函式有兩個內部方法[[Call]]和[[Construct]],apply陷阱和construct陷阱可以覆寫這些內部方法。若使用new操作符呼叫函式,則執行[[Construct]]方法;若不用,則執行[[Construct]方法,此時會執行apply陷阱,它和Reflect.apply()都接受以下引數
1 2 3 |
trapTaqget 被執行的函式(代理的目標) thisArg 函式被呼叫時內部this的值 argumentsList 傳遞給函式的引數陣列 |
當使用new呼叫函式時呼叫的construct陷阱接受以下引數
1 2 |
trapTarget 被執行的函式(代理的目標) argumentsList 傳遞給函式的引數陣列 |
Reflect.construct()方法也接受這兩個引數,其還有一個可選的第三個引數newTarget。若給定這個引數,則該引數用於指定函式內部new.target的值
有了apply和construct陷阱,可以完全控制任何代理目標函式的行為
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
let target = function() { return 42 }, proxy = new Proxy(target, { apply: function(trapTarget, thisArg, argumentList) { return Reflect.apply(trapTarget, thisArg, argumentList); }, construct: function(trapTarget, argumentList) { return Reflect.construct(trapTarget, argumentList); } }); // 使用了函式的代理,其目標物件會被視為函式 console.log(typeof proxy); // "function" console.log(proxy()); // 42 var instance = new proxy(); console.log(instance instanceof proxy); // true console.log(instance instanceof target); // true |
在這裡,有一個返回數字42的函式,該函式的代理分別使用apply陷阱和construct陷阱來將那些行為委託給Reflect.apply()方法和Reflect.construct()方法。最終結果是代理函式與目標函式完全相同,包括在使用typeof時將自己標識為函式。不用new呼叫代理時返回42,用new呼叫時建立一個instance物件,它同時是代理和目標的例項,因為instanceof通過原型鏈來確定此資訊,而原型鏈查詢不受代理影響,這也就是代理和目標好像有相同原型的原因
驗證函式引數
apply陷阱和construct陷阱增加了一些可能改變函式執行方式的可能性,例如,假設驗證所有引數都屬於特定型別,則可以在apply陷阱中檢查引數
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// 將所有引數相加 function sum(...values) { return values.reduce((previous, current) => previous + current, 0); } let sumProxy = new Proxy(sum, { apply: function(trapTarget, thisArg, argumentList) { argumentList.forEach((arg) => { if (typeof arg !== "number") { throw new TypeError("All arguments must be numbers."); } }); return Reflect.apply(trapTarget, thisArg, argumentList); }, construct: function(trapTarget, argumentList) { throw new TypeError("This function can't be called with new."); } }); console.log(sumProxy(1, 2, 3, 4)); // 10 // 丟擲錯誤 console.log(sumProxy(1, "2", 3, 4)); // 同樣丟擲錯誤 let result = new sumProxy(); |
此示例使用apply陷阱來確保所有引數都是數字,sum()函式將所有傳入的引數相加。如果傳入非數字值,函式仍將嘗試操作,可能導致意外結果發生。通過在sumProxy()代理中封裝sum(),這段程式碼攔截了函式呼叫,並確保每個引數在被呼叫前一定是數字。為了安全起見,程式碼還使用construct陷阱來確保函式不會被new呼叫
還可以執行相反的操作,確保必須用new來呼叫函式並驗證其引數為數字
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function Numbers(...values) { this.values = values; } let NumbersProxy = new Proxy(Numbers, { apply: function(trapTarget, thisArg, argumentList) { throw new TypeError("This function must be called with new."); }, construct: function(trapTarget, argumentList) { argumentList.forEach((arg) => { if (typeof arg !== "number") { throw new TypeError("All arguments must be numbers."); } }); return Reflect.construct(trapTarget, argumentList); } }); let instance = new NumbersProxy(1, 2, 3, 4); console.log(instance.values); // [1,2,3,4] // 丟擲錯誤 NumbersProxy(1, 2, 3, 4); |
在這個示例中,apply陷阱丟擲一個錯誤,而construct陷阱使用Reflect.construct()方法來驗證輸入並返回一個新例項。當然,也可以不借助代理而用new.target來完成相同的事情
不用new呼叫建構函式
new.target元屬性是用new呼叫函式時對該函式的引用,所以可以通過檢查new.target的值來確定函式是否是通過new來呼叫的
1 2 3 4 5 6 7 8 9 10 |
function Numbers(...values) { if (typeof new.target === "undefined") { throw new TypeError("This function must be called with new."); } this.values = values; } let instance = new Numbers(1, 2, 3, 4); console.log(instance.values); // [1,2,3,4] // 丟擲錯誤 Numbers(1, 2, 3, 4); |
在這段程式碼中,不用new呼叫Numbers()會丟擲一個錯誤。如果目標是防止用new呼叫函式,則這樣編寫程式碼比使用代理簡單得多。但有時不能控制要修改行為的函式,在這種情況下,使用代理才有意義
假設Numbers()函式定義在無法修改的程式碼中,知道程式碼依賴new.target,希望函式避免檢查卻仍想呼叫函式。在這種情況下,用new呼叫時的行為已被設定,所以只能使用apply陷阱
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function Numbers(...values) { if (typeof new.target === "undefined") { throw new TypeError("This function must be called with new."); } this.values = values; } let NumbersProxy = new Proxy(Numbers, { apply: function(trapTarget, thisArg, argumentsList) { return Reflect.construct(trapTarget, argumentsList); } }); let instance = NumbersProxy(1, 2, 3, 4); console.log(instance.values); // [1,2,3,4] |
apply陷阱用傳入的引數呼叫Reflect.construct(),就可以讓Numbersproxy()函式無須使用new就能實現用new呼叫Numbers()的行為。Numbers()內部的new.target等於Numbers(),所以不會有錯誤丟擲。儘管這個修改new.target的示例非常簡單,但這樣做顯得更加直接
覆寫抽象基類建構函式
進一步修改new.target,可以將第三個引數指定為Reflect.construct()作為賦值給new.target的特定值。這項技術在函式根據已知值檢查new.target時很有用,例如建立抽象基類建構函式。在一個抽象基類建構函式中,new.target理應不同於類的建構函式,就像在這個示例中
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class AbstractNumbers { constructor(...values) { if (new.target === AbstractNumbers) { throw new TypeError("This function must be inherited from."); } this.values = values; } } class Numbers extends AbstractNumbers {} let instance = new Numbers(1, 2, 3, 4); console.log(instance.values); // [1,2,3,4] // 丟擲錯誤 new AbstractNumbers(1, 2, 3, 4); |
當呼叫new AbstractNumbers()時,new.Target等於AbstractNumbers並丟擲一個錯誤。呼叫new Numbers()仍然有效,因為new.target等於Numbers。可以手動用代理給new.target賦值來繞過建構函式限制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class AbstractNumbers { constructor(...values) { if (new.target === AbstractNumbers) { throw new TypeError("This function must be inherited from."); } this.values = values; } } let AbstractNumbersProxy = new Proxy(AbstractNumbers, { construct: function(trapTarget, argumentList) { return Reflect.construct(trapTarget, argumentList, function() {}); } }); let instance = new AbstractNumbersProxy(1, 2, 3, 4); console.log(instance.values); // [1,2,3,4] |
AbstractNumbersProxy使用construct陷阱來攔截對new AbstractNumbersProxy()方法的呼叫。然後傳入陷阱的引數來呼叫Reflect.construct()方法,並新增一個空函式作為第三個引數。這個空函式被用作建構函式內部new.target的值。由於new.target不等於AbstractNumbers,因此不會丟擲錯誤,建構函式可以完全執行
可呼叫的類建構函式
必須用new來呼叫類建構函式,因為類建構函式的內部方法[[Call]]被指定來丟擲一個錯誤。但是代理可以攔截對[[Call]]方法的呼叫,這意味著可以通過使用代理來有效地建立可呼叫類建構函式。例如,如果希望類建構函式不用new就可以執行,那麼可以使用apply陷阱來建立一個新例項
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Person { constructor(name) { this.name = name; } } let PersonProxy = new Proxy(Person, { apply: function(trapTarget, thisArg, argumentList) { return new trapTarget(...argumentList); } }); let me = PersonProxy("huochai"); console.log(me.name); // "huochai" console.log(me instanceof Person); // true console.log(me instanceof PersonProxy); // true |
PersonProxy物件是Person類建構函式的代理,類建構函式是函式,所以當它們被用於代理時就像函式一樣。apply陷阱覆寫預設行為並返回trapTarget的新例項,該例項與pepson相等。用展開運算子將argumentList傳遞給trapTarget來分別傳遞每個引數。不使用new呼叫PersonProxy()可以返回一個person的例項,如果嘗試不使用new呼叫person(),則建構函式將丟擲一個錯誤。建立可呼叫類建構函式只能通過代理來進行
可撤銷代理
通常,在建立代理後,代理不能脫離其目標。但是可能存在希望撤銷代理的情況,然後代理便失去效力。無論是出於安全目的通過API提供一個物件,還是在任意時間點切斷訪問,撤銷代理都非常有用
可以使用proxy.revocable()方法建立可撤銷的代理,該方法採用與Proxy建構函式相同的引數:目標物件和代理處理程式,返回值是具有以下屬性的物件
1 2 |
proxy 可被撤銷的代理物件 revoke 撤銷代理要呼叫的函式 |
當呼叫revoke()函式時,不能通過proxy執行進一步的操作。任何與代理物件互動的嘗試都會觸發代理陷阱丟擲錯誤
1 2 3 4 5 6 7 8 |
let target = { name: "target" }; let { proxy, revoke } = Proxy.revocable(target, {}); console.log(proxy.name); // "target" revoke(); // 丟擲錯誤 console.log(proxy.name); |
此示例建立一個可撤銷代理,它使用解構功能將proxy和revoke變數賦值給Proxy.revocable()方法返回的物件上的同名屬性。之後,proxy物件可以像不可撤銷代理物件一樣使用。因此proxy.name返回”target”,因為它直接透傳了target.name的值。然而,一旦revoke()函式被呼叫,代理不再是函式,嘗試訪問proxy.name會丟擲一個錯誤,正如任何會觸發代理上陷阱的其他操作一樣
模仿陣列
在ES6出現以前,開發者不能在JS中完全模仿陣列的行為。而ES6中的代理和反射API可以用來建立一個物件,該物件的行為與新增和刪除屬性時內建陣列型別的行為相同
1 2 3 4 5 6 7 8 9 10 |
let colors = ["red", "green", "blue"]; console.log(colors.length); // 3 colors[3] = "black"; console.log(colors.length); // 4 console.log(colors[3]); // "black" colors.length = 2; console.log(colors.length); // 2 console.log(colors[3]); // undefined console.log(colors[2]); // undefined console.log(colors[1]); // "green" |
此示例中有兩個特別重要的行為
1、當給colors[3]賦值時,length屬性的值增加到4
2、當length屬性被設定為2時,陣列中最後兩個元素被刪除
要完全重造內建陣列,只需模擬上述兩種行為。下面=將講解如何建立一個能正確模仿這些行為的物件
【檢測陣列索引】
為整數屬性鍵賦值是陣列才有的特例,因為它們與非整數鍵的處理方式不同。要判斷一個屬性是否是一個陣列索引,可以參考ES6規範提供的以下說明
當且僅當ToString(ToUint32(P))等於P,並且ToUint32(P)不等於232-1時,字串屬性名稱P才是一個陣列索引
此操作可以在JS中實現,如下所示
1 2 3 4 5 6 7 |
function toUint32(value) { return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32); } function isArrayIndex(key) { let numericKey = toUint32(key); return String(numericKey) == key && numericKey ); } |
toUint32()函式通過規範中描述的演算法將給定的值轉換為無符號32位整數;isArrayIndex()函式先將鍵轉換為uint32結構,然後進行一次比較以確定這個鍵是否是陣列索引。有了這兩個實用函式,就可以開始實現一個模擬內建陣列的物件
【新增新元素時增加length的值】
之前描述的陣列行為都依賴屬性賦值,只需用set代理陷阱即可實現之前提到的兩個行為。請看以下這個示例,當操作的陣列索引大於length-1時,length屬性也一同增加,這實現了兩個特性中的前一個
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
function toUint32(value) { return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32); } function isArrayIndex(key) { let numericKey = toUint32(key); return String(numericKey) == key && numericKey ); } function createMyArray(length=0) { return new Proxy({ length }, { set(trapTarget, key, value) { let currentLength = Reflect.get(trapTarget, "length"); // 特殊情況 if (isArrayIndex(key)) { let numericKey = Number(key); if (numericKey >= currentLength) { Reflect.set(trapTarget, "length", numericKey + 1); } } // 無論鍵的型別是什麼,都要執行這行程式碼 return Reflect.set(trapTarget, key, value); } }); } let colors = createMyArray(3); console.log(colors.length); // 3 colors[0] = "red"; colors[1] = "green"; colors[2] = "blue"; console.log(colors.length); // 3 colors[3] = "black"; console.log(colors.length); // 4 console.log(colors[3]); // "black" |
這段程式碼用set代理陷阱來攔截陣列索引的設定過程。如果鍵是陣列索引,則將其轉換為數字,因為鍵始終作為字串傳遞。接下來,如果該數值大於或等於當前長度屬性,則將length屬性更新為比數字鍵多1(設定位置3意味著length必須是4)。然後,由於希望被設定的屬效能夠接收到指定的值,因此呼叫Reflect.set()通過預設行為來設定該屬性
呼叫createMyArray()並傳入3作為length的值來建立最初的自定義陣列,然後立即新增這3個元素的值,在此之前length屬性一直是3,直到把位置3賦值為值”black”時,length才被設定為4
【減少length的值來刪除元素】
僅當陣列索引大於等於length屬性時才需要模擬第一個陣列特性,第二個特性與之相反,即當length屬性被設定為比之前還小的值時會移除陣列元素。這不僅涉及長度屬性的改變,還要刪除原本可能存在的元素。例如有一個長度為4的陣列,如果將length屬性設定為2,則會刪除位置2和3中的元素。同樣可以在set代理陷阱中完成這個操作,這不會影響到第一個特性。以下示例在之前的基礎上更新了createMyArray方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
function toUint32(value) { return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32); } function isArrayIndex(key) { let numericKey = toUint32(key); return String(numericKey) == key && numericKey ); } function createMyArray(length=0) { return new Proxy({ length }, { set(trapTarget, key, value) { let currentLength = Reflect.get(trapTarget, "length"); // 特殊情況 if (isArrayIndex(key)) { let numericKey = Number(key); if (numericKey >= currentLength) { Reflect.set(trapTarget, "length", numericKey + 1); } } else if (key === "length") { if (value currentLength) { for (let index = currentLength - 1; index >= value; index--) { Reflect.deleteProperty(trapTarget, index); } } } // 無論鍵的型別是什麼,都要執行這行程式碼 return Reflect.set(trapTarget, key, value); } }); } let colors = createMyArray(3); console.log(colors.length); // 3 colors[0] = "red"; colors[1] = "green"; colors[2] = "blue"; colors[3] = "black"; console.log(colors.length); // 4 colors.length = 2; console.log(colors.length); // 2 console.log(colors[3]); // undefined console.log(colors[2]); // undefined console.log(colors[1]); // "green" console.log(colors[0]); // "red" |
該程式碼中的set代理陷阱檢查key是否為”length”,以便正確調整物件的其餘部分。當開始檢查時,首先用Reflect.get()獲取當前長度值,然後與新的值進行比較,如果新值比當前長度小,則通過一個for迴圈刪除目標上所有不再可用的屬性,fop迴圈從後往前從當前陣列長度(current Length)處開始刪除每個屬性,直到到達新的陣列長度(value)為止
此示例為colors新增了4種顏色,然後將它的length屬性設定為2,位於位置2和3的元素被移除,因此嘗試訪問它們時返回的是undefined。length屬性被正確設定為2,位置0和1中的元素仍可訪問
實現了這兩個特性,就可以很輕鬆地建立一個模仿內建陣列特性的物件了。但建立一個類來封裝這些特性是更好的選擇,所以下一步用一個類來實現這個功能
【實現MyArray類】
想要建立使用代理的類,最簡單的方法是像往常一樣定義類,然後在建構函式中返回一個代理,那樣的話,當類例項化時返回的物件是代理而不是例項(建構函式中this的值是該例項)。例項成為代理的目標,代理則像原本的例項那樣被返回。例項完全私有化,除了通過代理間接訪問外,無法直接訪問它
下面是從一個類建構函式返回一個代理的簡單示例
1 2 3 4 5 6 7 |
class Thing { constructor() { return new Proxy(this, {}); } } let myThing = new Thing(); console.log(myThing instanceof Thing); // true |
在這個示例中,類Thing從它的建構函式中返回一個代理,代理的目標是this,所以即使myThing是通過呼叫Thing建構函式建立的,但它實際上是一個代理。由於代理會將它們的特性透傳給目標,因此myThing仍然被認為是Thing的一個例項,故對任何使用Thing類的人來說代理是完全透明的
從建構函式中可以返回一個代理,理解這個概念後,用代理建立一個自定義陣列類就相對簡單了。其程式碼與之前”減少length的值來刪除元素”的程式碼大部分是一樣的,可以使用相同的代理程式碼,但這次需要把它放在一個類建構函式中。下面是完整的示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
function toUint32(value) { return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32); } function isArrayIndex(key) { let numericKey = toUint32(key); return String(numericKey) == key && numericKey ); } class MyArray { constructor(length=0) { this.length = length; return new Proxy(this, { set(trapTarget, key, value) { let currentLength = Reflect.get(trapTarget, "length"); // 特殊情況 if (isArrayIndex(key)) { let numericKey = Number(key); if (numericKey >= currentLength) { Reflect.set(trapTarget, "length", numericKey + 1); } } else if (key === "length") { if (value currentLength) { for (let index = currentLength - 1; index >= value; index--) { Reflect.deleteProperty(trapTarget, index); } } } // 無論鍵的型別是什麼,都要執行這行程式碼 return Reflect.set(trapTarget, key, value); } }); } } let colors = new MyArray(3); console.log(colors instanceof MyArray); // true console.log(colors.length); // 3 colors[0] = "red"; colors[1] = "green"; colors[2] = "blue"; colors[3] = "black"; console.log(colors.length); // 4 colors.length = 2; console.log(colors.length); // 2 console.log(colors[3]); // undefined console.log(colors[2]); // undefined console.log(colors[1]); // "green" console.log(colors[0]); // "red" |
這段程式碼建立了一個MyArray類,從它的建構函式返回一個代理。length屬性被新增到建構函式中,初始化為傳入的值或預設值0,然後建立代理並返回。colors變數看起來好像只是MyArray的一個例項,並實現了陣列的兩個關鍵特性
雖然從類建構函式返回代理很容易,但這也意味著每建立一個例項都要建立一個新代理。然而,有一種方法可以讓所有例項共享一個代理:將代理用作原型
將代理用作原型
如果代理是原型,僅當預設操作繼續執行到原型上時才會呼叫代理陷阱,這會限制代理作為原型的能力
1 2 3 4 5 6 7 8 9 10 11 12 13 |
let target = {}; let newTarget = Object.create(new Proxy(target, { // 永遠不會被呼叫 defineProperty(trapTarget, name, descriptor) { // 如果被呼叫就會引發錯誤 return false; } })); Object.defineProperty(newTarget, "name", { value: "newTarget" }); console.log(newTarget.name); // "newTarget" console.log(newTarget.hasOwnProperty("name")); // true |
建立newTarget物件,它的原型是一個代理。由於代理是透明的,用target作為代理的目標實際上讓target成為newTarget的原型。現在,僅當newTarget上的操作被透傳給目標時才會呼叫代理陷阱
呼叫Object.defineProperty()方法並傳入newTarget來建立一個名為name的自有屬性。在物件上定義屬性的操作不需要操作物件原型,所以代理中的defineProperty陷阱永遠不會被呼叫,name作為自有屬性被新增到newTarget上
儘管代理作為原型使用時極其受限,但有幾個陷阱卻仍然有用
【在原型上使用get陷阱】
呼叫內部方法[[Get]]讀取屬性的操作先查詢自有屬性,如果未找到指定名稱的自有屬性,則繼續到原型中查詢,直到沒有更多可以查詢的原型過程結束
如果設定一個get代理陷阱,則每當指定名稱的自有屬性不存在時,又由於存在以上過程,往往會呼叫原型上的陷阱。當訪問我們不能保證存在的屬性時,則可以用get陷阱來預防意外的行為。只需建立一個物件,在嘗試訪問不存在的屬性時丟擲錯誤即可
1 2 3 4 5 6 7 8 9 10 |
let target = {}; let thing = Object.create(new Proxy(target, { get(trapTarget, key, receiver) { throw new ReferenceError(`${key} doesn't exist`); } })); thing.name = "thing"; console.log(thing.name); // "thing" // 丟擲錯誤 let unknown = thing.unknown; |
在這段程式碼中,用一個代理作為原型建立了thing物件,當呼叫它時,如果其上不存在給定的鍵,那麼get陷阱會丟擲錯誤。由於thing.name屬性存在,故讀取它的操作不會呼叫原型上的get陷阱,只有當訪問不存在的thing.unknown屬性時才會呼叫
當執行最後一行時,由於unknown不是thing的自有屬性,因此該操作繼續在原型上查詢,之後get陷阱會丟擲一個錯誤。在JS中,訪問未知屬性通常會靜默返回undefined,這種丟擲錯誤的特性(其他語言中的做法)非常有用
要明白,在這個示例中,理解trapTarget和receiver是不同的物件很重要。當代理被用作原型時,trapTarget是原型物件,receiver是例項物件。在這種情況下,trapTarget與target相等,receiver與thing相等,所以可以訪問代理的原始目標和要操作的目標
【在原型上使用set陷阱】
內部方法[[Set]]同樣會檢查目標物件中是否含有某個自有屬性,如果不存在則繼續查詢原型。當給物件屬性賦值時,如果存在同名自有屬性則賦值給它;如果不存在給定名稱,則繼續在原型上查詢。最棘手的是,無論原型上是否存在同名屬性,給該屬性賦值時都將預設在例項(不是原型)中建立該屬性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let target = {}; let thing = Object.create(new Proxy(target, { set(trapTarget, key, value, receiver) { return Reflect.set(trapTarget, key, value, receiver); } })); console.log(thing.hasOwnProperty("name")); // false // 觸發了 `set` 代理陷阱 thing.name = "thing"; console.log(thing.name); // "thing" console.log(thing.hasOwnProperty("name")); // true // 沒有觸發 `set` 代理陷阱 thing.name = "boo"; console.log(thing.name); // "boo" |
在這個示例中,target一開始沒有自有屬性,物件thing的原型是一個代理,其定義了一個set陷阱來捕獲任何新屬性的建立。當thing.name被賦值為”thing”時,由於name不是thing的自有屬性,故set代理陷阱會被呼叫。在陷阱中,trapTarget等於target,receiver等於thing。最終該操作會在thing上建立一個新屬性,很幸運,如果傳入receiver作為第4個引數,Reflect.set()就可以實現這個預設行為
一旦在thing上建立了name屬性,那麼在thing.name被設定為其他值時不再呼叫set代理陷阱,此時name是一個自有屬性,所以[[Set]操作不會繼續在原型上查詢
【在原型上使用has陷阱】
回想一下has陷阱,它可以攔截物件中的in操作符。in操作符先根據給定名稱搜尋物件的自有屬性,如果不存在,則沿著原型鏈依次搜尋後續物件的自有屬性,直到找到給定的名稱或無更多原型為止
因此,只有在搜尋原型鏈上的代理物件時才會呼叫has陷阱,而用代理作為原型時,只有當指定名稱沒有對應的自有屬性時才會呼叫has陷阱
1 2 3 4 5 6 7 8 9 10 11 |
let target = {}; let thing = Object.create(new Proxy(target, { has(trapTarget, key) { return Reflect.has(trapTarget, key); } })); // 觸發了 `has` 代理陷阱 console.log("name" in thing); // false thing.name = "thing"; // 沒有觸發 `has` 代理陷阱 console.log("name" in thing); // true |
這段程式碼在thing的原型上建立了一個has代理陷阱,由於使用in操作符時會自動搜尋原型,因此這個has陷阱不像get陷阱和set陷阱一樣再傳遞一個receiver物件,它只操作與target相等的trapTarget。在此示例中,第一次使用in操作符時會呼叫has陷阱,因為屬性name不是thing的自有屬性;而給thing.name賦值時會再次使用in操作符,這一次不會呼叫has陷阱,因為name已經是thing的自有屬性了,故不會繼續在原型中查詢
【將代理用作類的原型】
由於類的prototype屬性是不可寫的,因此不能直接修改類來使用代理作為類的原型。然而,可以通過繼承的方法來讓類誤以為自己可以將代理用作自己的原型。首先,需要用建構函式建立一個ES5風格的型別定義
1 2 3 4 5 6 7 8 9 10 11 |
function NoSuchProperty() { // empty } NoSuchProperty.prototype = new Proxy({}, { get(trapTarget, key, receiver) { throw new ReferenceError(`${key} doesn't exist`); } }); let thing = new NoSuchProperty(); // 由於 `get` 代理陷阱而丟擲了錯誤 let result = thing.name; |
NoSuchProperty表示類將繼承的基類,函式的prototype屬性沒有限制,於是可以用代理將它重寫。當屬性不存在時會通過get陷阱來丟擲錯誤,thing物件作為NoSuchProperty的例項被建立,被訪問的屬性name不存在於是丟擲錯誤
下一步是建立一個從NoSuchProperty繼承的類
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function NoSuchProperty() { // empty } NoSuchProperty.prototype = new Proxy({}, { get(trapTarget, key, receiver) { throw new ReferenceError(`${key} doesn't exist`); } }); class Square extends NoSuchProperty { constructor(length, width) { super(); this.length = length; this.width = width; } } let shape = new Square(2, 6); let area1 = shape.length * shape.width; console.log(area1); // 12 // 由於 "wdth" 不存在而丟擲了錯誤 let area2 = shape.length * shape.wdth; |
Square類繼承自NoSuchProperty,所以它的原型鏈中包含代理。之後建立的shape物件是Square的新例項,它有兩個自有屬性length和width。讀取這兩個屬性的值時不會呼叫get代理陷阱,只有當訪問shape物件上不存在的屬性時(例如shape.wdth,很明顯這是一個錯誤拼寫)才會觸發get代理陷阱並丟擲一個錯誤。另一方面這也說明代理確實在shape物件的原型鏈中。但是有一點不太明顯的是,代理不是shape物件的直接原型,實際上它位於shape物件的原型鏈中,需要幾個步驟才能到達
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function NoSuchProperty() { // empty } // 對於將要用作原型的代理,儲存對其的一個引用 let proxy = new Proxy({}, { get(trapTarget, key, receiver) { throw new ReferenceError(`${key} doesn't exist`); } }); NoSuchProperty.prototype = proxy; class Square extends NoSuchProperty { constructor(length, width) { super(); this.length = length; this.width = width; } } let shape = new Square(2, 6); let shapeProto = Object.getPrototypeOf(shape); console.log(shapeProto === proxy); // false let secondLevelProto = Object.getPrototypeOf(shapeProto); console.log(secondLevelProto === proxy); // true |
在這一版程式碼中,為了便於後續識別,代理被儲存在變數proxy中。shape的原型Shape.prototype不是一個代理,但是shape.prototype的原型是繼承自NoSuchProperty的代理
通過繼承在原型鏈中額外增加另一個步驟非常重要,因為需要經過額外的一步才能觸發代理中的get陷阱。如果Shape.prototype有一個屬性,將會阻止get代理陷阱被呼叫
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
function NoSuchProperty() { // empty } NoSuchProperty.prototype = new Proxy({}, { get(trapTarget, key, receiver) { throw new ReferenceError(`${key} doesn't exist`); } }); class Square extends NoSuchProperty { constructor(length, width) { super(); this.length = length; this.width = width; } getArea() { return this.length * this.width; } } let shape = new Square(2, 6); let area1 = shape.length * shape.width; console.log(area1); // 12 let area2 = shape.getArea(); console.log(area2); // 12 // 由於 "wdth" 不存在而丟擲了錯誤 let area3 = shape.length * shape.wdth; |
在這裡,Square類有一個getArea()方法,這個方法被自動地新增到Square.prototype,所以當呼叫shape.getArea()時,會先在shape例項搜尋getArea()方法然後再繼續在它的原型中搜尋。由於getArea()是在原型中找到的,搜尋結束,代理沒有被呼叫