深入理解ES6--12.代理與反射介面

weixin_34148340發表於2018-05-05

主要知識點:代理和反射的定義、常用的陷阱函式、可被撤銷的代理、將代理物件作為原型使用、將代理作為類的原型

1. 代理和反射

代理是什麼?

通過呼叫 new Proxy() ,你可以建立一個代理用來替代另一個物件(被稱之為目目標物件) ,這個代理對目標物件進行了虛擬,因此該代理與該目標物件表面上可以被當作同一個物件來對待。

代理允許你攔截目標物件上的底層操作,而這本來是JS引擎的內部能力,攔截行為適用了一個能響應特定操作的函式(被稱之為陷阱);

反射是什麼?

Reflect物件所代表的反射介面,是給底層操作提供預設行為的方法的集合,這些操作是能夠被代理重寫的。每個代理陷阱都有一個對應的反射方法,每個方法都與對應的陷阱函式同名,並且接收的引數也與之一致。

建立一個簡單的代理

使用Proxy構建可以建立一個簡單的代理物件,需要傳遞兩個引數:目標物件以及一個處理器,後者是定義一個或多個陷阱函式的物件。如果不定義陷阱函式,則依然使用目標物件的預設行為。

2. 常用陷阱函式

2.1 基本陷阱函式

1.使用Set陷阱函式驗證屬性值

假如有這樣一個場景,必須要求物件的屬性值必須只能是數值,這就意味著該物件每個新增屬性時都要被驗證,並且在屬性不為數值屬性時就應該丟擲錯誤。因此就需要使用set陷阱函式來重寫set函式的預設行為,set陷阱函式接收四個引數:

  1. trapTarget:代理的目標物件;
  2. key:需要寫入的屬性的鍵;
  3. value:被寫入屬性的值;
  4. 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陷阱函式。該函式接受三個引數:

  1. trapTarget:代理的目標物件;
  2. key:需要讀取的屬性的鍵;
  3. 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陷阱函式會傳入兩個引數:

  1. trapTarget:代理的目標物件;
  2. 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 運算子刪除物件屬性時被呼叫,該方法接收兩個引數:

  1. trapTarget:代理的目標物件;
  2. 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()方法時,可以使用setPrototypeOfgetPrototypeOf陷阱函式來影響Object上相應的兩個方法的效果。setPrototypeOf陷阱函式接收兩個引數:

  1. trapTarget:代理的目標物件;
  2. 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 則通過 preventExtensionsisExtensible 陷阱函式允許代理攔截對於底層物件的方法呼叫。這兩個陷阱函式都接受名為 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()方法來檢索這些特性。代理允許你使用 definePropertygetOwnPropertyDescriptor 陷阱函式,來分別攔截對於Object.defineProperty()Object.getOwnPropertyDescriptor() 的呼叫。 defineProperty 陷阱函式接受下列三個引數:

  1. trapTarget :需要被定義屬性的物件(即代理的目標物件) ;
  2. key :屬性的鍵(字串型別或符號型別) ;
  3. descriptor :為該屬性準備的描述符物件。

defineProperty 陷阱函式要求你在操作成功時返回 true ,否則返回 falsegetOwnPropertyDescriptor 陷阱函式則只接受 trapTargetkey 這兩個引數,並會返回對應的描述符。 Reflect.defineProperty()Reflect.getOwnPropertyDescriptor() 方法作為上述陷阱函式的對應方法,接受與之相同的引數。

defineProperty 陷阱函式要求你返回一個布林值用於表示操作是否已成功。當它返回 true時, Object.defineProperty() 會正常執行;而如果它返回了 false ,則Object.defineProperty()會丟擲錯誤。 你可以使用該功能來限制哪些屬性可以被Object.defineProperty() 方法定義。

etOwnPropertyDescriptor 陷阱函式有一個微小差異,要求返回值必須是 nullundefined ,或者是一個物件。如果返回值是一個物件,則只允許該物件擁有 enumerableconfigurablevaluewritablegetset 這些自有屬性

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陷阱函式

只有 applyconstruct 要求代理目標物件必須是一個函式。函式擁有兩個內部方法:[[Call]][[Construct]] ,前者會在函式被直接呼叫時執行,而後者會在函式被使用 new 運算子呼叫時執行。 applyconstruct陷阱函式對應著這兩個內部方法,並允許你對其進行重寫。apply 陷阱函式會接收到下列三個引數( Reflect.apply() 也會接收這些引數) :

  1. trapTarget :被執行的函式(即代理的目標物件) ;
  2. thisArg :呼叫過程中函式內部的 this 值;
  3. argumentsList :被傳遞給函式的引數陣列。

當使用 new 去執行函式時, construct 陷阱函式會被呼叫並接收到下列兩個引數:

  1. trapTarget :被執行的函式(即代理的目標物件) ;
  2. 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 構造器的相同:一個目標物件、一個代理處理器,而返回值是包含下列屬性的一個物件:

  1. proxy :可被撤銷的代理物件;
  2. 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()方法返回的物件進行了解構 賦值,把同名屬性的值賦給了 proxyrevoke 變數。此時 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 類的一個例項,讓它擁有兩個屬性: lengthwidth 。由於 get 陷阱函式永遠不會被呼叫,因此能夠成功讀取這兩個屬性的值。只有訪問 shape 上不存在的屬性時(例如這裡的 shape.wdth 拼寫錯誤) ,才觸發了 get 陷阱函式並導致錯誤被丟擲。

6. 總結

  1. 在 ES6 之前,特定物件(例如陣列) 會顯示出一些非常規的、無法被開發者複製的行為,而代理的出現改變了這種情況。代理允許你為一些 JS 底層操作自行定義非常規行為,因此你就可以通過代理陷阱來複制 JS 內建物件的所有行為。在各種不同操作發生時(例如對於 in運算子的使用) ,這些代理陷阱會在後臺被呼叫。

  2. 反射介面也是在 ES6 中引入的,允許開發者為每個代理陷阱實現預設的行為。每個代理陷阱在 Reflect 物件(ES6 的另一個新特性) 上都有一個同名的對應方法。將代理陷阱與反射介面方法結合使用,就可以在特定條件下讓一些操作有不同的表現,有別於預設的內建行為。

  3. 可被撤銷的代理是一種特殊的代理,可以使用 revoke() 函式去有效禁用。 revoke() 函式終結了代理的所有功能,因此在它被呼叫之後,所有與代理屬性互動的意圖都會導致丟擲錯誤。

  4. 儘管直接使用代理是最有力的使用方式,但你也可以把代理用作另一個物件的原型。但只有很少的代理陷阱能在作為原型的代理上被有效使用,包括 getsethas 這幾個,這讓這方面的用例變得十分有限

相關文章