代理(Proxy)可以攔截並改變 JS 引擎的底層操作,如資料讀取、屬性定義、函式構造等一系列操作。ES6 通過對這些底層內建物件的代理陷阱和反射函式,讓開發者能進一步接近 JS 引擎的能力。
一、代理與反射的基本概念
什麼是代理和反射呢?
代理是用來替代另一個物件(target),JS 通過new Proxy()
建立一個目標物件的代理,該代理與該目標物件表面上可以被當作同一個物件來對待。
當目標物件上的進行一些特定的底層操作時,代理允許你攔截這些操作並且覆寫它,而這原本只是 JS 引擎的內部能力。
如果你對些代理&反射的概念比較困惑的話,可以直接看後面的應用示例,最後再重新看這些定義就會更清晰!
攔截行為使用了一個能夠響應特定操作的函式( 被稱為陷阱),每個代理陷阱對應一個反射(Reflect)方法。
ES6 的反射 API 以 Reflect
物件的形式出現,物件每個方法都與對應的陷阱函式同名,並且接收的引數也與之一致。以下是 Reflect
物件的一些方法:
代理陷阱 | 覆寫的特性 | 方法 | |
---|---|---|---|
get | 讀取一個屬性的值 | Reflect.get() | |
set | 寫入一個屬性 | Reflect.set() | |
has | in 運算子 | Reflect.has() | |
deleteProperty | delete 運算子 | Reflect.deleteProperty() | |
getPrototypeOf | Object.getPrototypeOf() | Reflect.getPrototypeOf() | |
isExtensible | Object.isExtensible() | Reflect.isExtensible() | |
defineProperty | Object.defineProperty() | Reflect.defineProperty | |
apply | 呼叫一個函式 | Reflect.apply() | |
construct | 使用 new 呼叫一個函式 | Reflect.construct() |
每個陷阱函式都可以重寫 JS 物件的一個特定內建行為,允許你攔截並修改它。
綜合來說,想要控制或改變JS的一些底層操作,可以先建立一個代理物件,在這個代理物件上掛載一些陷阱函式,陷阱函式裡面有反射方法。通過接下來的應用示例可以更清晰的明白代理的過程。
二、開始一個簡單的代理
當你使用 Proxy 構造器來建立一個代理時,需要傳遞兩個引數:目標物件(target)以及一個處理器( handler),
先建立一個僅進行傳遞的代理如下:
// 目標物件
let target = {};
// 代理物件
let proxy = new Proxy(target, {});
proxy.name = "hello";
console.log(proxy.name); // "hello"
console.log(target.name); // "hello"
target.name = "world";
console.log(proxy.name); // "world"
console.log(target.name); // "world
上例中的 proxy 代理物件將所有操作直接傳遞給 target 目標物件,代理物件 proxy 自身並沒有儲存該屬性,它只是簡單將值傳遞給 target 物件,proxy.name 與 target.name 的屬性值總是相等,因為它們都指向 target.name。
此時代理陷阱的處理器為空物件,當然處理器可以定義了一個或多個陷阱函式。
2.1 set 驗證物件屬性的儲存
假設你想要建立一個物件,並要求其屬性值只能是數值,這就意味著該物件的每個新增屬性
都要被驗證,並且在屬性值不為數值型別時應當丟擲錯誤。
這時需要使用 set 陷阱函式來攔截傳入的 value,該陷阱函式能接受四個引數:
- trapTarget :將接收屬性的物件( 即代理的目標物件)
- key :需要寫入的屬性的鍵( 字串型別或符號型別)
- value :將被寫入屬性的值;
- receiver :操作發生的物件( 通常是代理物件)
set 陷阱對應的反射方法和預設特性是Reflect.set()
,和陷阱函式一樣接受這四個引數,並會基於操作是否成功而返回相應的結果:
let targetObj = {};
let proxyObj = new Proxy(targetObj, {
set: set
});
/* 定義 set 陷阱函式 */
function set (trapTarget, key, value, receiver) {
if (isNaN(value)) {
throw new TypeError("Property " + key + " must be a number.");
}
return Reflect.set(trapTarget, key, value, receiver);
}
/* 測試 */
proxyObj.count = 123;
console.log(proxyObj.count); // 123
console.log(targetObj.count); // 123
proxyObj.anotherName = "proxy" // TypeError: Property anotherName must be a number.
示例中set 陷阱函式成功攔截傳入的 value 值,你可以嘗試一下,如果註釋或不return Reflect.set()
會發生什麼?,答案是攔截陷阱就不會有反射響應。
需要注意的是,直接給 targetObj 目標物件賦值時是不會觸發 set 代理陷阱的,需要通過給代理物件賦值才會觸發 set 代理陷阱與反射。
2.2 get 驗證物件屬性的讀取
JS 非常有趣的特性之一,是讀取不存在的屬性時並不會丟擲錯誤,而會把undefined
當作該屬性的值。
對於大型的程式碼庫,當屬性名稱存在書寫錯誤時(不會拋錯)會導致嚴重的問題。這時使用 get 代理陷阱驗證物件結構(Object Shape),訪問不存在的屬性時就丟擲錯誤,使物件結構驗證變得簡單。
get 陷阱函式會在讀取屬性時被呼叫,即使該屬性在物件中並不存在,它能接受三個引數:
- trapTarget :將會被讀取屬性的物件( 即代理的目標物件)
- key :需要讀取的屬性的鍵( 字串型別或符號型別)
- receiver :操作發生的物件( 通常是代理物件)
Reflect.get()
方法接受與之相同的引數,並返回預設屬性的預設值。
let proxyObj = new Proxy(targetObj, {
set: set,
get: get
});
/* 定義 get 陷阱函式 */
function get(trapTarget, key, receiver) {
if (!(key in receiver)) {
throw new TypeError("Property " + key + " doesn`t exist.");
}
return Reflect.get(trapTarget, key, receiver);
}
console.log(proxyObj.count); // 123
console.log(proxyObj.newcount) // TypeError: Property newcount doesn`t exist.
這段程式碼允許新增新的屬性,並且此後可以正常讀取該屬性的值,但當讀取的屬性並
不存在時,程式丟擲了一個錯誤,而不是將其預設為undefined
。
還可以使用 has 陷阱驗證in
運算子,使用 deleteProperty 陷阱函式避免屬性被delete
刪除。
注:in
運算子用於判斷物件中是否存在某個屬性,如果自有屬性或原型屬性匹配這個名稱字串或Symbol,那麼in
運算子返回 true。
targetObj = {
name: `targetObject`
};
console.log("name" in targetObj); // true
console.log("toString" in targetObj); // true
其中 name 是物件自身的屬性,而 toString 則是原型屬性( 從 Object 物件上繼承而來),所以檢測結果都為 true。
has 陷阱函式會在使用in
運算子時被呼叫,並且會傳入兩個引數(同名反射Reflect.has()
方法也一樣):
- trapTarget :需要讀取屬性的物件( 代理的目標物件)
- key :需要檢查的屬性的鍵( 字串型別或
Symbol
符號型別)
deleteProperty 陷阱函式會在使用delete
運算子去刪除物件屬性時下被呼叫,並且也會被傳入兩個引數(Reflect.deleteProperty()
方法也接受這兩個引數):
- trapTarget :需要刪除屬性的物件( 即代理的目標物件) ;
- key :需要刪除的屬性的鍵( 字串型別或符號型別) 。
一些思考:分析過 Vue 原始碼的都瞭解過,給一個 Vue 例項中掛載的 data,是通過
Object.defineProperty
代理 vm._data 中的物件屬性,實現雙向繫結…… 同理可以考慮使用 ES6 的 Proxy 的 get 和 set 陷阱實現這個代理。
三、物件屬性陷阱
3.1 資料屬性與訪問器屬性
ES5 最重要的特徵之一就是引入了 Object.defineProperty()
方法定義屬性的特性。屬性的特性是為了實現javascript引擎用的,屬於內部值,因此不能直接訪問他們。
屬性分為資料屬性和訪問器屬性。使用Object.defineProperty()
方法修改資料屬性的特性值的示例如下:
let obj1 = {
name: `myobj`,
}
/* 資料屬性*/
Object.defineProperty(obj1,`name`,{
configurable: false, // default true
writable: false, // default true
enumerable: true, // default true
value: `jenny` // default undefined
})
console.log(obj1.name) // `jenny`
其中[[Configurable]]
表示能否通過 delete 刪除屬性從而重新定義為訪問器屬性;[[Enumerable]]
表示能否通過for-in
迴圈返回屬性;[[Writable]]
表示能否修改屬性的值; [[Value]]
包含這個屬性的資料值。
對於訪問器屬性,該屬性不包含資料值,包含一對getter和setter函式,定義訪問器屬性必須使用Object.defineProperty()
方法:
let obj2 = {
age: 18
}
/* 訪問器屬性 */
Object.defineProperty(obj2,`_age`,{
configurable: false, // default true
enumerable: false, // default true
get () { // default undefined
return this.age
},
set (num) { // default undefined
this.age = num
}
})
/* 修改訪問器屬性呼叫 getter */
obj2._age = 20
console.log(obj2.age) // 20
/* 輸出訪問器屬性 */
console.log(Object.getOwnPropertyDescriptor(obj2,`_age`))
// { get: [Function: get],
// set: [Function: set],
// enumerable: false,
// configurable: false }
[[Get]]
在讀取屬性時呼叫的函式, [[Set]]
再寫入屬性時呼叫的函式。使用訪問器屬性的常用方式,是設定一個屬性的值導致其他屬性發生變化。
3.2 檢查屬性的修改
代理允許你使用 defineProperty 同名函式陷阱函式攔截Object.defineProperty()
的呼叫,defineProperty 陷阱函式接受下列三個引數:
- trapTarget :需要被定義屬性的物件( 即代理的目標物件);
- key :屬性的鍵( 字串型別或符號型別);
- descriptor :為該屬性準備的描述符物件。
defineProperty 陷阱函式要求在操作後返回一個布林值用於判斷操作是否成功,如果返回了 false 則丟擲錯誤,故可以使用該功能來限制哪些屬性可以被Object.defineProperty() 方法定義。
例如,如果想阻止定義Symbol
符號型別的屬性,你可以檢查傳入的屬性值,若是則返回 false:
/* 定義代理 */
let proxy = new Proxy({}, {
defineProperty(trapTarget, key, descriptor) {
if (typeof key === "symbol") {
return false;
}
return Reflect.defineProperty(trapTarget, key, descriptor);
}
});
Object.defineProperty(proxy, "name", {
value: "proxy"
});
console.log(proxy.name); // "proxy"
let nameSymbol = Symbol("name");
// 丟擲錯誤
Object.defineProperty(proxy, nameSymbol, {
value: "proxy"
})
四、函式代理
4.1 建構函式 & 立即執行
函式的兩個內部方法:[[Call]]
與[[Construct]]
會在函式被呼叫時呼叫,通過代理函式來為這兩個內部方法設定陷阱,從而控制函式的行為。
[[Construct]]
會在函式被使用new
運算子呼叫時執行,代理觸發construct()
陷阱函式,並和Reflect.construct()
一樣接收到下列兩個引數:
- trapTarget :被執行的函式( 即代理的目標物件) ;
- argumentsList :被傳遞給函式的引數陣列。
[[Call]]
會在函式被直接呼叫時執行,代理觸發apply()
陷阱函式,它和Reflect.apply()
都接收三個引數:
- trapTarget :被執行的函式( 代理的目標函式) ;
- thisArg :呼叫過程中函式內部的 this 值;
- argumentsList :被傳遞給函式的引數陣列。
每個函式都包含
call()
和apply()
方法,用於重置函式執行的作用域即 this 指向,區別只是接收引數的方式不同:call()
的引數需要逐個列舉、apply()
是引數陣列。
顯然,apply 與 construct 要求代理目標物件必須是一個函式,這兩個代理陷阱在函式的執行方式上開啟了很多的可能性,結合使用就可以完全控制任意的代理目標函式的行為。
4.2 驗證函式的引數
看到apply()
和construct()
陷阱的引數都有被傳遞給函式的引數陣列argumentsList
,所以可以用來驗證函式的引數。
例如需要保證所有引數都是某個特定型別的,並且不能通過 new 構造使用,示例如下:
/* 定義 sum 目標函式 */
function sum(...values) {
return values.reduce((previous, current) => previous + current, 0);
}
/* 定義 apply 陷阱函式 */
function applyRef (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 constructRef () {
throw new TypeError("This function can`t be called with new.");
}
/* 定義 sumProxy 代理函式 */
let sumProxy = new Proxy(sum, {
apply: applyRef,
construct: constructRef
});
console.log(sumProxy(1, 2, 3, 4)); // 10
// console.log(sumProxy(1, "2", 3, 4)); // TypeError: All arguments must be numbers.
// let result = new sumProxy() // TypeError: This function can`t be called with new.
sum() 函式會將所有傳遞進來的引數值相加,此程式碼通過將 sum() 函式封裝在 sumProxy() 代理中,如果傳入引數的值不是數值型別,該函式仍然會嘗試加法操作,但在函式執行之前攔截了函式呼叫,觸發apply
陷阱函式以保證每個引數都是數值。
出於安全的考慮,這段程式碼使用 construct
陷阱丟擲錯誤,以確保該函式不會被使用 new 運算子呼叫
例項物件 instance 物件會被同時判定為 proxy 與 target 物件的例項,是因為 instanceof 運算子使用了原型鏈來進行推斷,而原型鏈查詢並沒有受到這個代理的影響,因此 proxy 物件與 target 物件對於 JS 引擎來說就有同一個原型。
4.3 呼叫類的建構函式
ES6 中新引入了class
類的概念,類使用constructor
建構函式封裝資料,並規定必須始終使用 new 來呼叫,原因是類構造器的內部方法 [[Call]] 被明
確要求丟擲錯誤。
代理可以攔截對於 [[Call]] 方法的呼叫,你可以藉助代理呼叫的類構造器。例如在缺少 new 的情況下建立一個新例項,就使用 apply 陷阱函式實現:
class Person {
constructor(name) {
this.name = name;
}
}
let PersonProxy = new Proxy(Person, {
apply: function(trapTarget, thisArg, argumentList) {
return new trapTarget(...argumentList);
}
});
let me = PersonProxy("Jenny");
console.log(me.name); // "Jenny"
console.log(me instanceof Person); // true
console.log(me instanceof PersonProxy); // true
類構造器即類的建構函式,使用代理時它的行為就像函式一樣,apply
陷阱函式重寫了預設的構造行為。
關於類的更多有趣的用法,可參考 【ES6】更易於繼承的類語法
總結來說,代理的用途非常廣泛,因為它提供了修改 JS 內建物件的所有行為的入口。上述例子只是簡單的一些應用入門,還有更多複雜的示例,推薦閱讀《深入理解ES6》。