一起聊聊Javascript之Proxy與Reflect

2022-02-09 19:01:16
本篇文章給大家帶來了關於JavaScript中Proxy與Reflect的相關知識,希望對大家有幫助。

ECMAScript 在 ES6 規範中加入了 Proxy 與 Reflect 兩個新特性,這兩個新特性增強了 JavaScript 中物件存取的可控性,使得 JS 模組、類的封裝能夠更加嚴密與簡單,也讓操作物件時的報錯變得更加可控。

Proxy

Proxy,正如其名,代理。這個介面可以給指定的物件建立一個代理物件,對代理物件的任何操作,如:存取屬性、對屬性賦值、函數呼叫,都會被攔截,然後交由我們定義的函數來處理相應的操作,
JavaScript 的特性讓物件有很大的操作空間,同時 JavaScript 也提供了很多方法讓我們去改造物件,可以隨意新增屬性、隨意刪除屬性、隨意更改物件的原型……但是此前 Object 類提供的 API 有許多缺點:

  • 如果要用 Object.defineProperty 定義某個名稱集合內的全部屬性,只能通過列舉的方式為全部屬性設定 getter 和 setter,而且由於只能每個屬性創造一個函數,集合太大會造成效能問題。
  • Object.defineProperty 定義後的屬性,如果仍想擁有正常的存取功能,只能將資料存放在物件的另一個屬性名上或者需要另一個物件來存放資料,對於只想監聽屬性的場合尤為不便。
  • Object.defineProperty 無法修改類中不可重新定義的屬性,例如陣列的 length 屬性。
  • 對於那些尚不存在且名稱不好預測的屬性,Object.defineProperty 愛莫能助。
  • 無法修改或阻止某些行為,如:列舉屬性名、修改物件原型。

Proxy 介面的出現很好地解決了這些問題:

  • Proxy 介面將對物件的所有操作歸類到數個類別中,通過 Proxy 提供的陷阱攔截特定的操作,再在我們定義的處理常式中進行邏輯判斷就可以實現複雜的功能,並且還能控制比以前更多的行為。
  • Proxy 創造的代理物件以中間人形式存在,其本身並不負責存放資料,我們只需要提供代理物件給外部使用者,讓外部使用者在代理物件的控制下存取我們的原物件即可。

Proxy 介面在 JS 環境中是一個建構函式:

ƒ Proxy ( target: Object, handlers: Object ) : Proxy

這個建構函式有兩個引數,第一個是我們要代理的物件,第二個是包含處理各種操作的函數的物件。
下面是呼叫範例:

//需要代理的目標
var target = { msg: "I wish I was a bird!" }; 
//包含處理各種操作的函數的物件
var handler = {
	//處理其中一種操作的函數,此處是存取屬性的操作
	get(target, property) {
		//在控制檯列印存取了哪個屬性
		console.log(`你存取了 ${property} 屬性`);
		//實現操作的功能
		return target[property];
	}
}
//構造代理物件
var proxy = new Proxy( target , handler);
//存取代理物件
proxy.msg
//控制檯: 你存取了 msg 屬性
//← I wish I was a bird!

在上面的例子中,先建立了一個物件,賦值給 target ,然後再以 target 為目標建立了一個代理物件,賦值給 proxy。在作為第二個引數提供給 Proxy 建構函式的物件裡有屬性名為「get」的屬性,是一個函數,「get」是 Proxy 介面一個陷阱的名稱,Proxy 會參照我們作為第二個引數提供的物件裡的屬性,找到那些屬性名與陷阱名相同的屬性,自動設定相應的陷阱並把屬性上的函數作為陷阱的處理常式。陷阱能夠攔截對代理物件的特定操作,把操作的細節轉換成引數傳遞給我們的處理常式,讓處理常式去完成這一操作,這樣我們就可以通過處理常式來控制物件的各種行為。
在上面的例子裡,構造代理物件時提供的 get 函數就是處理存取物件屬性操作的函數,代理物件攔截存取物件屬性的操作並給 get 函數傳遞目標物件請求存取的屬性名兩個引數,並將函數的返回值作為存取的結果。

Proxy 的陷阱一共有13種:

陷阱名與對應的函數引數攔截的操作操作範例
get(target, property)存取物件屬性target.property
target[property]
set(target, property, value, receiver)賦值物件屬性target.property = value
target[property] = value
has(target, property)判斷物件屬性是否存在property in target
isExtensible(target)判斷物件可否新增屬性Object.isExtensible(target)
preventExtensions(target)使物件無法新增新屬性Object.preventExtensions(target)
defineProperty(target, property, descriptor)定義物件的屬性Object.defineProperty(target, property, descriptor)
deleteProperty(target, property)刪除物件的屬性delete target.property
delete target[property]
Object.deleteProperty(target, property)
getOwnPropertyDescriptor(target, property)獲取物件自有屬性的描述符Object.getOwnPropertyDescriptor(target, property)
ownKeys(target)列舉物件全部自有屬性Object.getOwnPropertyNames(target).
concat(Object.getOwnPropertySymbols(target))
getPrototypeOf(target)獲取物件的原型Object.getPrototypeOf(target)
setPrototypeOf(target)設定物件的原型Object.setPrototypeOf(target)
apply(target, thisArg, argumentsList)函數呼叫target(...arguments)
target.apply(target, thisArg, argumentsList)
construct(target, argumentsList, newTarget)建構函式呼叫new target(...arguments)

在上面列出的陷阱裡是有攔截函數呼叫一類操作的,但是隻限代理的物件是函數的情況下有效,Proxy 在真正呼叫我們提供的接管函數前是會進行型別檢查的,所以通過代理讓普通的物件擁有函數一樣的功能這種事就不要想啦。
某一些陷阱對處理常式的返回值有要求,如果不符合要求則會丟擲 TypeError 錯誤。限於篇幅問題,本文不深入闡述,需要了解可自行查詢資料。

除了直接 new Proxy 物件外,Proxy 建構函式上還有一個靜態函數 revocable,可以構造一個能被銷燬的代理物件。

Proxy.revocable( target: Object, handlers: Object ) : Object

Proxy.revocable( target, handlers ) → {
	proxy: Proxy,
	revoke: ƒ ()
}

這個靜態函數接收和建構函式一樣的引數,不過它的返回值和建構函式稍有不同,會返回一個包含代理物件和銷燬函數的物件,銷燬函數不需要任何引數,我們可以隨時呼叫銷燬函數將代理物件和目標物件的代理關係斷開。斷開代理後,再對代理物件執行任何操作都會丟擲 TypeError 錯誤。

//建立代理物件
var temp1 = Proxy.revocable({a:1}, {});
//← {proxy: Proxy, revoke: ƒ}
//存取代理物件
temp1.proxy.a
//← 1
//銷燬代理物件
temp1.revoke();
//再次存取代理物件
temp1.proxy.a
//未捕獲的錯誤: TypeError: Cannot perform 'get' on a proxy that has been revoked

弄清楚了具體的原理後,下面舉例一個應用場景。
假設某個需要對外暴露的物件上有你不希望被別人存取的屬性,就可以找代理物件作替身,在外部存取代理物件的屬性時,針對不想被別人存取的屬性返回空值或者報錯:

//目標物件
var target = {
	msg: "我是鮮嫩的美少女!",
	secret: "其實我是800歲的老太婆!" //不想被別人存取的屬性
};
//建立代理物件
var proxy = new Proxy( target , {
	get(target, property) {
		//如果存取 secret 就報錯
		if (property == "secret") throw new Error("不允許存取屬性 secret!");
		return target[property];
	}
});
//存取 msg 屬性
proxy.msg
//← 我是鮮嫩的美少女!
//存取 secret 屬性
proxy.secret
//未捕獲的錯誤: 不允許存取屬性 secret!

在上面的例子中,我針對對 secret 屬性的存取進行了報錯,守護住了「美少女」的祕密,讓我們歌頌 Proxy 的偉大!
只不過,Proxy 只是在程式邏輯上進行了接管,上帝視角的控制檯依然能列印代理物件完整的內容,真是遺憾……(不不不,這挺好的!)

proxy//控制檯: Proxy {msg: '我是鮮嫩的美少女!', secret: '其實我是800歲的老太婆!'}

以下是關於 Proxy 的一些細節問題:

  • Proxy 在處理屬性名的時候會把除 Symbol 型別外的所有屬性名都轉化成字串,所以處理常式在判斷屬性名時需要尤其注意。
  • 對代理物件的任何操作都會被攔截,一旦代理物件被建立就沒有辦法再修改它本身。
  • Proxy 的代理是非常底層的,在沒有主動暴露原始目標物件的情況下,沒有任何辦法越過代理物件存取目標物件(在控制檯搞騷操作除外)。
  • Proxy 代理的目標只能是物件,不能是 JavaScript 中的原始型別。

Reflect

學過其他語言的人看到 Reflect 這個詞可能會首先聯想到「反射」這個概念,但 JavaScript 由於語言特性是不需要反射的,所以這裡的 Reflect 其實和反射無關,是 JavaScript 給 Proxy 配套的一系列函數。
Reflect 在 JS 環境裡是一個全域性物件,包含了與 Proxy 各種陷阱配套的函數。

Reflect: Object
Reflect → {
	apply: ƒ apply(),
	construct: ƒ construct(),
	defineProperty: ƒ defineProperty(),
	deleteProperty: ƒ deleteProperty(),
	get: ƒ (),
	getOwnPropertyDescriptor: ƒ getOwnPropertyDescriptor(),
	getPrototypeOf: ƒ getPrototypeOf(),
	has: ƒ has(),
	isExtensible: ƒ isExtensible(),
	ownKeys: ƒ ownKeys(),
	preventExtensions: ƒ preventExtensions(),
	set: ƒ (),
	setPrototypeOf: ƒ setPrototypeOf(),
	Symbol(Symbol.toStringTag): "Reflect"
}

可以看到,Reflect 上的所有函數都對應一個 Proxy 的陷阱。這些函數接受的引數,返回值的型別,都和 Proxy 上的別無二致,可以說 Reflect 就是 Proxy 攔截的那些操作的原本實現。

那 Reflect 存在的意義是什麼呢?
上文提到過,Proxy 上某一些陷阱對處理常式的返回值有要求。如果想讓代理物件能正常工作,那就不得不按照 Proxy 的要求去寫處理常式。或許會有人覺得只要用 Object 提供的方法不就好了,然而不能這麼想當然,因為某些陷阱要求的返回值和 Object 提供的方法拿到的返回值是不同的,而且有些陷阱還會有邏輯上的要求,和 Object 提供的方法的細節也有所出入。舉個簡單的例子:Proxy 的 defineProperty 陷阱要求的返回值是布林型別,成功就是 true,失敗就是 false。而 Object.defineProperty 在成功的時候會返回定義的物件,失敗則會報錯。如此應該能夠看出為陷阱編寫實現的難點,如果要求簡單那自然是輕鬆,但是要求一旦複雜起來那真是想想都頭大,大多數時候我們其實只想過濾掉一部分操作而已。Reflect 就是專門為了解決這個問題而提供的,因為 Reflect 裡的函數都和 Proxy 的陷阱配套,返回值的型別也和 Proxy 要求的相同,所以如果我們要實現原本的功能,直接呼叫 Reflect 裡對應的函數就好了。

//需要代理的物件
var target = {
	get me() {return "我是鮮嫩的美少女!"} //定義 me 屬性的 getter
};
//建立代理物件
var proxy = new Proxy( target , {
	//攔截定義屬性的操作
	defineProperty(target, property, descriptor) {
		//如果定義的屬性是 me 就返回 false 阻止
		if (property == "me") return false;
		//使用 Reflect 提供的函數實現原本的功能
		return Reflect.defineProperty(target, property, descriptor);
	}
});
//嘗試重新定義 me 屬性
Object.defineProperty(proxy , "me", {value: "我是800歲的老太婆!"})
//未捕獲的錯誤: TypeError: 'defineProperty' on proxy: trap returned falsish for property 'me'
//嘗試定義 age 屬性
Object.defineProperty(proxy , "age", {value: 17})
//← Proxy {age: 17}

//使用 Reflect 提供的函數來定義屬性
Reflect.defineProperty(proxy , "me", {value: "我是800歲的老太婆!"})
//← false
Reflect.defineProperty(proxy , "age", {value: 17})
//← true

在上面的例子裡,由於我很懶,所以我在接管定義屬性功能的地方「偷工減料」用了 Reflect 提供的 defineProperty 函數。用 Object.defineProperty 在代理物件上定義 me 屬性時報了錯,表示失敗,而定義 age 屬性則成功完成了。可以看到,除了被報錯的 me 屬性,對其他屬性的定義是可以成功完成的。我還使用 Reflect 提供的函數執行了同樣的操作,可以看到 Reflect 也無法越過 Proxy 的代理,同時也顯示出了 Reflect 和傳統方法返回值的區別。

雖然 Reflect 的好處很多,但是它也有一個問題:JS 全域性上的 Reflect 物件是可以被修改的,可以替換掉裡面的方法,甚至還能把 Reflect 刪掉。

//備份原本的 Reflect.get
var originGet = Reflect.get;
//修改 Reflect.get
Reflect.get = function get(target ,property) {
	console.log("哈哈,你的 get 已經是我的形狀了!");
	return originGet(target ,property);
};
//呼叫 Reflect.get
Reflect.get({a:1}, "a")
//控制檯: 哈哈,你的 get 已經是我的形狀了!
//← 1
//刪除 Reflect 變數
delete Reflect
//← true
//存取 Reflect 變數
Reflect
//未捕獲的錯誤: ReferenceError: Reflect is not defined

基於上面的演示,不難想到,可以通過修改 Reflect 以欺騙的方式越過 Proxy 的代理。所以如果你對安全性有要求,建議在使用 Reflect 時,第一時間將全域性上的 Reflect 深度複製到你的閉包作用域並且只使用你的備份,或者將全域性上的 Reflect 凍結並鎖定參照。

相關推薦:

以上就是一起聊聊Javascript之Proxy與Reflect的詳細內容,更多請關注TW511.COM其它相關文章!