主要知識點:代理和反射的定義、常用的陷阱函式、可被撤銷的代理、將代理物件作為原型使用、將代理作為類的原型
1. 代理和反射
代理是什麼?
通過呼叫 new Proxy()
,你可以建立一個代理用來替代另一個物件(被稱之為目目標物件) ,這個代理對目標物件進行了虛擬,因此該代理與該目標物件表面上可以被當作同一個物件來對待。
代理允許你攔截目標物件上的底層操作,而這本來是JS引擎的內部能力,攔截行為適用了一個能響應特定操作的函式(被稱之為陷阱);
反射是什麼?
被Reflect
物件所代表的反射介面,是給底層操作提供預設行為的方法的集合,這些操作是能夠被代理重寫的。每個代理陷阱都有一個對應的反射方法,每個方法都與對應的陷阱函式同名,並且接收的引數也與之一致。
建立一個簡單的代理
使用Proxy構建可以建立一個簡單的代理物件,需要傳遞兩個引數:目標物件以及一個處理器,後者是定義一個或多個陷阱函式的物件。如果不定義陷阱函式,則依然使用目標物件的預設行為。
2. 常用陷阱函式
2.1 基本陷阱函式
1.使用Set陷阱函式驗證屬性值
假如有這樣一個場景,必須要求物件的屬性值必須只能是數值,這就意味著該物件每個新增屬性時都要被驗證,並且在屬性不為數值屬性時就應該丟擲錯誤。因此就需要使用set
陷阱函式來重寫set
函式的預設行為,set
陷阱函式接收四個引數:
- trapTarget:代理的目標物件;
- key:需要寫入的屬性的鍵;
- value:被寫入屬性的值;
- receiver:操作發生的物件(通常是代理物件)
Reflect.set()
是set
陷阱函式對應的反射方法,同時也是set
操作的預設行為,Reflect.set()
方法與set
陷阱函式一樣,能夠接受四個引數。
針對上述場景,示例程式碼:
//set陷阱函式
let target = {
name:'target'
}
let proxy = new Proxy(target,{
set(tarpTarget,key,value,receiver){
if(!tarpTarget.hasOwnProperty(key)){
if(isNaN(value)){
throw new Error('property must be number');
}
}
return Reflect.set(tarpTarget,key,value,receiver);
}
});
proxy.msg='hello proxy'; //Uncaught Error: property must be number
複製程式碼
通過set陷阱函式就可以檢測設定屬性時屬性值的型別,當屬性值不是數字時,就會丟擲錯誤。
2.使用get陷阱函式進行物件外形驗證
**物件外形(Object Shape)指的是物件已有的屬性與方法的集合。**能夠使用代理很方便進行物件外形驗證。由於使用屬性驗證只需要在讀取屬性時被觸發,因此只需要使用get陷阱函式
。該函式接受三個引數:
- trapTarget:代理的目標物件;
- key:需要讀取的屬性的鍵;
- receiver:操作發生的物件(通常是代理物件);
相應的Reflect.get()
方法同樣擁有這三個引數。進行物件外形驗證的示例程式碼:
//get陷阱函式
let target={
name:'hello world'
}
let proxy = new Proxy(target,{
get(tarpTarget,key,receiver){
if(!(key in tarpTarget)){
throw new Error('不存在該物件');
}
return Reflect.get(tarpTarget,key,receiver);
}
});
console.log(proxy.name); //hello world
console.log(proxy.age); // Uncaught Error: 不存在該物件
複製程式碼
使用get陷阱函式
進行物件外形驗證,由於target
物件存在name
屬性,所以可以正常返回,當獲取age
屬性時,由於該屬性並不存在,所以會丟擲錯誤。
3.使用has陷阱函式隱藏屬性
in
運算子用於判斷指定物件中是否存在某個屬性,如果物件的屬性名與指定的字串或符號值相匹配,那麼in
運算子就會返回true
。無論該屬性是物件自身的屬性還是其原型的屬性。
has陷阱函式
會在使用in
運算子的情況下被呼叫,控制in運算子返回不同的結果,has陷阱函式
會傳入兩個引數:
- trapTarget:代理的目標物件;
- key:屬性鍵;
Reflect.has()
方法接收相同的引數,並向in
運算子返回預設的響應結果,用於返回預設響應結果。
例如想要隱藏value屬性:
//has陷阱函式
let target = {
value:'hello world'
}
let proxy = new Proxy(target,{
has(tarpTarget,key){
if(Object.is(key,'value')){
return false;
}
Reflect.has(tarpTarget,key);
}
})
console.log('value' in proxy); //false
複製程式碼
使用has陷阱函式
,能夠控制in
運算子的結果,value
屬性在target物件
中存在,通過代理的has陷阱函式
使得在檢查value
屬性時返回false
,達到隱藏屬性的效果。
4.使用deleteProperty陷阱函式避免屬性被刪除
deleteProperty
陷阱函式會在使用delete
運算子刪除物件屬性時被呼叫,該方法接收兩個引數:
- trapTarget:代理的目標物件;
- key:需要刪除的鍵;
Reflect.deleteProperty()
方法也接受這兩個引數,並提供了 deleteProperty
陷阱函式的預設實現。你可以結合 Reflect.deleteProperty()
方法以及 deleteProperty
陷阱函式,來修改 delete
運算子的行為。例如,能確保 value 屬性不被刪除:
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
複製程式碼
2.2 原型代理上的陷阱函式
在呼叫Object.setPrototypeOf()和getPrototypeOf()方法時,可以使用setPrototypeOf
和getPrototypeOf
陷阱函式來影響Object上相應的兩個方法的效果。setPrototypeOf陷阱函式接收兩個引數:
- trapTarget:代理的目標物件;
- proto:需要被用作原型的物件;
setPrototypeOf()
方法與Reflect.setPrototypeOf()
傳入相同的引數。另外,getPrototypeOf陷阱函式只接收trapTarget
引數,Reflect.getPrototype
也只接收一個引數。
例如,通過返回 null 隱藏了代理物件的原型,並且使得該原型不可被修改:
//原型代理上的陷阱函式
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
物件作為引數呼叫Object.getPrototypeOf()
會返回一個物件值;而使用 proxy 物件呼叫該方法則會返回null
,因為 getPrototypeOf
陷阱函式被呼叫了。類似的,使用 target 去呼叫Object.setPrototypeOf()
會成功;而由於 setPrototypeOf
陷阱函式的存在,使用 proxy
則會引發錯誤。
2.3 物件可擴充套件性的陷阱函式
ES5 通過Object.preventExtensions()
與 Object.isExtensible()
方法給物件增加了可擴充套件性。而 ES6 則通過 preventExtensions
與 isExtensible
陷阱函式允許代理攔截對於底層物件的方法呼叫。這兩個陷阱函式都接受名為 trapTarget
的單個引數,此引數代表代理的目標物件。 isExtensible
陷阱函式必須返回一個布林值用於表明目標物件是否可被擴充套件,而 preventExtensions
陷阱函式也需要返回一個布林值,用於表明操作是否已成功。同時也存在Reflect.preventExtensions()
與Reflect.isExtensible()
方法,用於實現預設的行為。這兩個方法都返回布林值,因此它們可以在對應的陷阱函式內直接使用。
例如,不想讓代理物件的Object.preventExtensios()
操作成功,可以強制preventExtensions
陷阱函式返回false
:
//物件可擴充套件性的陷阱函式
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
複製程式碼
2.4 屬性描述符的陷阱函式
ES5 最重要的特徵之一就是引入了Object.defineProperty()
方法用於定義屬性的特性。在JS 之前的版本中,沒有方法可以定義一個訪問器屬性,也不能讓屬性變成只讀或是不可列舉。而這些特性都能夠利用 Object.defineProperty()
方法來實現,並且你還可以利用Object.getOwnPropertyDescriptor()
方法來檢索這些特性。代理允許你使用 defineProperty
與 getOwnPropertyDescriptor
陷阱函式,來分別攔截對於Object.defineProperty()
與 Object.getOwnPropertyDescriptor()
的呼叫。 defineProperty
陷阱函式接受下列三個引數:
- trapTarget :需要被定義屬性的物件(即代理的目標物件) ;
- key :屬性的鍵(字串型別或符號型別) ;
- descriptor :為該屬性準備的描述符物件。
defineProperty
陷阱函式要求你在操作成功時返回 true
,否則返回 false
。getOwnPropertyDescriptor
陷阱函式則只接受 trapTarget
與 key
這兩個引數,並會返回對應的描述符。 Reflect.defineProperty()
與 Reflect.getOwnPropertyDescriptor()
方法作為上述陷阱函式的對應方法,接受與之相同的引數。
defineProperty
陷阱函式要求你返回一個布林值用於表示操作是否已成功。當它返回 true
時, Object.defineProperty()
會正常執行;而如果它返回了 false
,則Object.defineProperty()
會丟擲錯誤。 你可以使用該功能來限制哪些屬性可以被Object.defineProperty()
方法定義。
etOwnPropertyDescriptor
陷阱函式有一個微小差異,要求返回值必須是 null
、
undefined
,或者是一個物件。如果返回值是一個物件,則只允許該物件擁有 enumerable
、 configurable
、 value
、 writable
、 get
或 set
這些自有屬性
2.5 ownKeys陷阱函式
ownKeys
代理陷阱攔截了內部方法 [[OwnPropertyKeys]]
,並允許你返回一個陣列用於重寫該行為。返回的這個陣列會被用於四個方法: Object.keys()
方法、Object.getOwnPropertyNames()
方法、Object.getOwnPropertySymbols()
方法與Object.assign()
方法,其中 Object.assign()
方法會使用該陣列來決定哪些屬性會被複制。
ownKeys
陷阱函式接受單個引數,即目標物件,同時必須返回一個陣列或者一個類陣列物件。你可以使用 ownKeys
陷阱函式去過濾特定的屬性,以避免這些屬性被Object.keys()
方法、Object.getOwnPropertyNames()
方法、Object.getOwnPropertySymbols()
方法或 Object.assign()
方法使用。
2.6 apply與construct陷阱函式
只有 apply
與 construct
要求代理目標物件必須是一個函式。函式擁有兩個內部方法:[[Call]]
與 [[Construct]]
,前者會在函式被直接呼叫時執行,而後者會在函式被使用 new
運算子呼叫時執行。 apply
與 construct
陷阱函式對應著這兩個內部方法,並允許你對其進行重寫。apply
陷阱函式會接收到下列三個引數( Reflect.apply()
也會接收這些引數) :
- trapTarget :被執行的函式(即代理的目標物件) ;
- thisArg :呼叫過程中函式內部的 this 值;
- argumentsList :被傳遞給函式的引數陣列。
當使用 new
去執行函式時, construct
陷阱函式會被呼叫並接收到下列兩個引數:
- trapTarget :被執行的函式(即代理的目標物件) ;
- argumentsList :被傳遞給函式的引數陣列。
Reflect.construct()
方法同樣會接收到這兩個引數,還會收到可選的第三引數 newTarget
,如果提供了此引數,則它就指定了函式內部的 new.target
值。
使用apply和construct陷阱函式有這樣一些應用場景:
驗證函式的引數
假如需要保證所有引數都是某個特定型別,可使用 apply
陷阱函式進行驗證:
//apply和construct陷阱函式
let sum = function (arr=[]) {
return arr.reduce((previous,current)=>previous+current);
}
let proxy = new Proxy(sum,{
apply(trapTarget,thisArg,argumentList){
argumentList[0].forEach((item)=>{
if(typeof item != 'number'){
throw new Error('不是數字型別');
}
})
return Reflect.apply(trapTarget,thisArg,argumentList);
},
construct(trapTarget,argumentList){
throw new Error('不能使用new');
}
});
console.log(proxy([1,2,3,4])); // 10
console.log(proxy([1, "2", 3, 4]));//Uncaught Error: 不是數字型別Uncaught Error: 不是數字型別
let result = new proxy(); //Uncaught Error: 不能使用new
複製程式碼
3. 可被撤銷的代理
在被建立之後,代理通常就不能再從目標物件上被解綁。有的情況下你可能想撤銷一個代理以便讓它不能再被使用。當你想通過公共介面向外提供一個安全的物件,並且要求要隨時都能切斷對某些功能的訪問,這種情況下可被撤銷的代理就會非常有用。
你可以使用Proxy.revocable()
方法來建立一個可被撤銷的代理,該方法接受的引數與Proxy
構造器的相同:一個目標物件、一個代理處理器,而返回值是包含下列屬性的一個物件:
- proxy :可被撤銷的代理物件;
- revoke :用於撤銷代理的函式;
當 revoke()
函式被呼叫後,就不能再對該 proxy
物件進行更多操作。例如:
let target = {
name: "target"
};
let { proxy, revoke } = Proxy.revocable(target, {});
console.log(proxy.name); // "target"
revoke();
// 丟擲錯誤
console.log(proxy.name);
複製程式碼
這個例子建立了一個可被撤銷的代理,它對Proxy.revocable()
方法返回的物件進行了解構
賦值,把同名屬性的值賦給了 proxy
與 revoke
變數。此時 proxy
物件和代理的目標物件一樣,於是 proxy.name
屬性的值就是 "target"
,因為它直接傳遞了
target.name
的值。然而一旦revoke()
函式被呼叫, 代理物件就和所代理的目標物件就解除“繫結”關係,之後試圖訪問 proxy.name
會丟擲錯誤。
4. 將代理物件作為原型使用
**代理物件可以被作為原型使用,在把代理物件作為原型時,僅當操作的預設行為會按慣例追蹤原型時,代理陷阱才會被呼叫。**因此,將代理物件作為原型時,常見的應用場景有:
1.在原型上使用get陷阱函式
當內部方法 [[Get]]
被呼叫以讀取屬性時,該操作首先會查詢物件的自有屬性;如果指定名稱的屬性沒有找到,則會繼續在物件的原型上進行屬性查詢;這個流程會一直持續到沒有原型可供查詢為止。得益於這個流程,若你設定了一個 get
代理陷阱,則只有在物件不存在指定名稱的自有屬性時,該陷阱函式才會在物件的原型上被呼叫。當所訪問的屬性無法保證存在時,你可以使用 get
陷阱函式來阻止預期外的行為。例如,建立了一個物件,當你嘗試去訪問一個不存在的屬性時,它會丟擲錯誤:
//原型上使用get陷阱函式
let target = {};
let newTarget = Object.create(new Proxy(
target,{
get(trapTarget,key,receiver){
throw new Error('不存在該屬性');
}
}
));
newTarget.name = 'hello world';
console.log(newTarget.name); //hello world
console.log(newTarget.age); //Uncaught Error: 不存在該屬性
複製程式碼
由於 name
屬性存在,所以不會呼叫 get
陷阱函式,而 age
屬性在物件上並不存在,所以,會從原型上去找該屬性,因此,會觸發 get
陷阱函式,從而丟擲錯誤。
2.在原型上使用 set
陷阱函式
內部方法 [[Set]]
同樣會查詢物件的自有屬性,並在必要時繼續對該物件的原型進行查詢。當你對一個物件屬性進行賦值時,如果指定名稱的自有屬性存在,值就會被賦在該屬性上;而若該自有屬性不存在,則會繼續檢查物件的原型,但預設情況下它會在物件例項(而非原型) 上建立一個新的屬性用於賦值,無論同名屬性是否存在於原型上。
3.在原型上使用has陷阱函式
has
陷阱函式會攔截物件上 in
運算子的使用。 in
運算子首先查詢物件上指定名稱的自有屬性;如果不存在同名自有屬性,則會繼續查詢物件的原型;如果原型上也不存在同名自有屬性,那麼就會沿著原型鏈一直查詢下去,直到找到該屬性、或者沒有更
多原型可供查詢時為止。has
陷阱函式只在原型鏈查詢觸及原型物件的時候才會被呼叫。
當使用代理作為原型時,這隻會在指定名稱的自有屬性不存在時,才會觸發 has
陷阱函式。
5.將代理作為類的原型
代理物件不能直接作為類的原型,因為類的 prototype
屬性是不可寫入的。但是,可以使用繼承來實現:
//代理物件作為類的原型
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
類,因此該代理就被加入了 Square
類的原型鏈。隨後shape
物件被建立為 Square
類的一個例項,讓它擁有兩個屬性: length
與 width
。由於 get
陷阱函式永遠不會被呼叫,因此能夠成功讀取這兩個屬性的值。只有訪問 shape
上不存在的屬性時(例如這裡的 shape.wdth
拼寫錯誤) ,才觸發了 get
陷阱函式並導致錯誤被丟擲。
6. 總結
-
在 ES6 之前,特定物件(例如陣列) 會顯示出一些非常規的、無法被開發者複製的行為,而代理的出現改變了這種情況。代理允許你為一些 JS 底層操作自行定義非常規行為,因此你就可以通過代理陷阱來複制 JS 內建物件的所有行為。在各種不同操作發生時(例如對於
in
運算子的使用) ,這些代理陷阱會在後臺被呼叫。 -
反射介面也是在 ES6 中引入的,允許開發者為每個代理陷阱實現預設的行為。每個代理陷阱在
Reflect
物件(ES6 的另一個新特性) 上都有一個同名的對應方法。將代理陷阱與反射介面方法結合使用,就可以在特定條件下讓一些操作有不同的表現,有別於預設的內建行為。 -
可被撤銷的代理是一種特殊的代理,可以使用
revoke()
函式去有效禁用。revoke()
函式終結了代理的所有功能,因此在它被呼叫之後,所有與代理屬性互動的意圖都會導致丟擲錯誤。 -
儘管直接使用代理是最有力的使用方式,但你也可以把代理用作另一個物件的原型。但只有很少的代理陷阱能在作為原型的代理上被有效使用,包括
get
、set
與has
這幾個,這讓這方面的用例變得十分有限