反射和代理的具體應用

異次元的廢D發表於2018-08-20

原文釋出於 github.com/ta7sudan/no…, 如需轉載請保留原作者 @ta7sudan.

ES6 為我們提供了許多新的 API, 其中個人覺得最有用的(之一)便是代理了. 代理和反射都被歸為反射 API, 那什麼是反射? 根據 wiki 上的解釋.

反射是指計算機程式在執行時(Run time)可以訪問、檢測和修改它本身狀態或行為的一種能力。

所以廣義上來說, 並非只有使用了 Proxy Reflect 相關的 API 才叫反射, 而是隻要在執行時訪問, 檢測和修改自身狀態和行為的都可以認為是用到了反射. 拿比較常見的 new 無關的建構函式來說, 我們常常會這樣實現.

function Person() {
	var self = this instanceof Person ? this : Object.create(Person.prototype);
	return self;
}
複製程式碼

像上面這樣, 我們在執行時通過檢測 this 進而檢測是否是通過 new 呼叫的函式, 從而決定返回值, 這也算是反射.

很多語言都提供了反射機制, 即使是彙編, 也能夠在執行時修改自身的程式碼(誰讓指令和資料是在一起呢...不過即便不在一起也是可以的). 那反射和代理到底有什麼用?

有人認為反射破壞了封裝, 但是它也帶來了更多的靈活性, 使得原本無法實現或難以實現的事情變得很容易實現, 儘管有缺點, 但缺點是我們可以避免的(如果有人使用了反射來破壞封裝, 那說明他在使用的時候已經清楚這樣做的結果, 產生的後果也應當自己承擔, 而如果不是必要, 則大部分時候也不會用到反射, 不存在破壞封裝), 而帶來的好處相較缺點則是明顯划算的.

就 API 而言, 反射和代理用起來是很簡單的, 所以這裡就不提了. 下面以比較常用的 get trap 來說明代理的實際應用場景. 後文中的反射泛指反射 API, 即包含了 ProxyReflect, 並不再區分.

考慮我們有一個物件 obj, 物件具有一個 sayHello 方法, 我們可能會這麼寫.

var obj = {};
obj.sayHello = function () {
	console.log('hello');
};
複製程式碼

在初始化的時候便定義了 sayHello 方法, 但可能有時候我們覺得這沒必要, 畢竟一個函式表示式也是有開銷的. 當然你可以說我們直接在字面量裡寫好 sayHello 不就行了, 為什麼一定要用函式表示式? 這裡只是演示, 不用在意細節, 總之我們希望在執行時某一時刻再例項化這個 sayHello 方法, 而不是一開始就例項化它, 原因可能是應用啟動速度比較重要. 那我們可能會這麼寫.

var obj = {};

setTimeout(() => {
	obj.sayHello = function () {
		console.log('hello');
	};
}, 3000);
複製程式碼

現在我們過了 3 秒才例項化了 sayHello 方法, 的確滿足了我們前面說的, 在執行時某一時刻例項化的需求, 至少我們的啟動速度提升了那麼一點. 那假如現在我們希望這個某一時刻不是 3 秒, 而是我們呼叫 sayHello 的時候呢? 換句話說, 如果呼叫 sayHello 時它還沒有例項化, 則我們先例項化它, 再呼叫它.

Proxy 我們可以這樣寫.

var obj = {};

var pobj = new Proxy(obj, {
	get(target, key) {
		if (key === 'sayHello') {
			if (!target[key]) {
				target[key] = function () {
					console.log('hello');
				};
			}
			return target[key];
		}
	}
});

pobj.sayHello();
複製程式碼

很好, 這樣我們就實現了在呼叫時例項化, 並且只例項化一次 sayHello 之後不會重複例項化. 但是也有人會說, 這不就是一個 getter 嗎? 這種事情用 Object.defineProperty() 也能做到. 比如.

var obj = {};

Object.defineProperty(obj, 'sayHello', {
	get() {
		if (!this._sayHello) {
			this._sayHello = function () {
				console.log('hello');
			};
		}
		return this._sayHello;
	}
});

obj.sayHello();
複製程式碼

的確, 從這個角度來看, 使用 Proxy 和使用 Object.defineProperty() 幾乎沒什麼區別. 而另一方面是, 儘管這兩種方法是沒有在初始化的時候例項化 sayHello 而是把這一過程推遲到呼叫 sayHello 的時刻了, 但是使用 Proxy 要建立一個代理物件, 使用 Object.defineProperty() 也要執行一次函式呼叫, 它們的開銷可能比初始化時候使用一個函式表示式來得更大, 這有什麼意義?

get trap 不僅僅是 getter

前面的例子中我們遇到了兩個問題, 一個是 Object.defineProperty() 某種意義上也能完成 Proxy 一樣的功能, 那 Proxy 有什麼意義? get trap 有什麼意義? 另一個是建立一個 Proxy 物件的開銷並不一定比使用一個函式表示式來得小, 這又有什麼意義?

為了回答這兩個問題, 現在我們考慮 obj 不僅僅有一個 sayHello 方法, 它有成百上千個方法, 每個方法列印了方法名. 使用 Proxy 的話, 我們可以這樣寫.

var obj = {};

var pobj = new Proxy(obj, {
	get(target, key) {
		if (!target[key]) {
			target[key] = function () {
				console.log(key);
			};
		}
		return target[key];
	}
});

pobj.sayHello();
pobj.sayGoodBye();
// sayHello
// sayGoodBye
複製程式碼

依舊簡短. 那用 Object.defineProperty() 呢? 不可能實現, 而即便是確定只有 100 個方法, 並且它們名字確定, 也需要呼叫 100 次 Object.defineProperty(), 對於函式表示式來說, 也是一樣的. 而從開銷的角度來看呢? 這時候 Proxy 依然只建立了一個代理物件, 而即便是可以使用 Object.defineProperty() 或函式表示式, 它們也要呼叫成百上千次.

當我們使用 obj.xxx() 去呼叫一個 xxx() 方法時, obj 物件本身並不知道自己是否具有 xxx() 方法, 而反射就像是一面鏡子, 讓 obj 能夠知道自己是否具有 xxx() 方法, 並且根據情況做出對應的處理.

儘管我們可以在執行時通過 Object.defineProperty() 或函式表示式動態地為 obj 物件新增方法, 但這是因為我們知道 obj 在那個時候是否存在對應方法, 而不是 obj 本身知道自己當時是否存在對應方法. 換句話說, 我們在使用物件的方法時, 總是要先知道方法名, 哪怕能夠在執行時知道, 但是[知道]這個動作也必須發生在[方法呼叫]這個動作之前. 這就導致了一些現實問題難以被優雅地解決.

比如前面的 obj 物件是我們暴露的 API, 給使用者使用, 它的方法都是按需例項化的. 如果沒有 Proxy, 則使用者什麼時候呼叫 obj 的方法我們是不知道的, 所以[知道]這一動作是不可能在[方法呼叫]之前, 我們也就沒辦法按需例項化. 當然使用者是能夠在[方法呼叫]之前[知道]什麼時候會有方法呼叫的, 但我們不可能讓使用者自己來例項化方法.

從編譯器角度來看, Proxy 是攔截了物件所有屬性的右值查詢, 而 Object.defineProperty() 則只是攔截了特定屬性的右值查詢, 這意味著 Object.defineProperty() 必須知道屬性名這一資訊, 而 Proxy 則不需要知道.

前置代理和後置代理

大部分時候我們使用的都是前置代理, 即我們把直接和代理物件進行互動(所有操作都發生在代理物件身上)的方式叫做前置代理. 那什麼是後置代理? 看程式碼.

var pobj = new Proxy({}, {
	get(target, key) {
		if (!target[key]) {
			target[key] = function () {
				console.log(key);
			};
		}
		return target[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayGoodBye();
複製程式碼

藉助原型鏈機制, 我們直接和 obj 進行互動而不是和代理物件進行互動, 只有當 obj 不存在對應方法時才會通過原型鏈去查詢代理物件.

可以看出來的是, 對於原本存在於目標物件(target)上的屬性, 使用代理前置開銷更大, 因為明明已經具有對應屬性了卻還要經過一次代理物件, 而使用代理後置開銷更小. 對於那些不存在的屬性, 使用後置代理開銷更大, 因為不僅要經過原型鏈查詢還要經過一次代理物件, 而使用前置代理只需要經過一次代理物件. 當然也可能引擎有特殊的優化技巧使得這種效能差異並不明顯, 所以也看個人喜歡採用哪種方式吧.

Reflect

講了這麼多都是在講 Proxy, 那 Reflect 呢? 它和以前的一些方法只有一些細微差別, 所以它的意義是什麼? 有什麼用?

Reflect 的方法和 Proxy 的方法是成對出現的, 和以前的一些方法相比, Reflect 的方法對引數的處理不同或返回值不同, 儘管很細微的差別, 但是當和 Proxy 配合使用的時候, 使用以前的方法可能導致 Proxy 物件和普通物件的一些行為不一致, 而使用 Reflect 則不會有這樣的問題, 所以建議在 Proxy 中都使用 Reflect 的對應方法.

另一方面是 Reflect 暴露的 API 相對更加底層, 效能會好一些.

最後是有些事情只能通過 Reflect 實現, 具體參考這個例子. 但是個人感覺這個例子並不是很好, 畢竟這個場景太少見了.

讓我們先來回顧一下前面後置代理的例子.

var pobj = new Proxy({}, {
	get(target, key) {
		if (!target[key]) {
			target[key] = function () {
				console.log(key);
			};
		}
		return target[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayGoodBye();
複製程式碼

在這個例子中, 呼叫 obj 上一開始不存在的方法最終都會通過原型鏈找到代理物件, 進而找到 target 也即空物件, 然後對空物件例項化對應的方法. 這裡的原型鏈查詢總是讓人感覺不太爽, 明明進入到 get trap 就肯定說明 obj 一開始不存在對應方法, 那我們理應可以在這時候給 obj 設定對應方法, 這樣下次呼叫的時候就不會進行原型鏈的查詢了, 為什麼非要給那個毫無卵用的空物件設定方法, 導致每次對 obj 進行方法呼叫還是要進行原型鏈查詢?

於是我們想起 get trap 還有個 receiver 引數, 大多數地方都寫著 receiver 就是代理物件, 也即我們這裡的 pobj, 其實不是, 準確說它是實際發生屬性查詢的物件, 也即我們這裡的 obj, 有點像 DOM 事件中 event.target 的意思.

於是我們馬上將原有的寫法改成這樣.

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (!receiver[key]) {
			receiver[key] = function () {
				console.log(key);
			};
		}
		return receiver[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
// RangeError: Maximum call stack size exceeded
複製程式碼

看上去沒什麼毛病, 然後我們立馬得到一個堆疊溢位的錯誤. 仔細看看我們發現關鍵問題就出在這個 receiver[key], 它對 obj.sayHello 進行了查詢, 但此時 obj.sayHello 還未例項化, 於是無限對 obj.sayHello 進行查詢, 最終導致堆疊溢位.

這裡出現問題的根本原因是 a[b] 這樣的取值操作妥妥地會觸發 Proxy 的 get trap 的, 因為 Proxy 是更為底層的存在, 但是仔細想想我們的需求其實不是為了取值, 而是為了知道 obj 自身是否存在 sayHello 屬性, 從這一點來說, 我們沒必要使用 a[b] 這樣的方式來判斷, 我們可以用 hasOwnProperty(). 於是繼續改造.

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (!receiver.hasOwnProperty(key)) {
			receiver[key] = function () {
				console.log(key);
			};
		}
		return receiver[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
// RangeError: Maximum call stack size exceeded
複製程式碼

還是堆疊溢位, 因為 hasOwnProperty() 其實是 Object.prototype.hasOwnProperty(), 意味著在原型鏈的盡頭, 而 pobj 在原型鏈上更近的位置, 於是相當於 receiver/obj 並不存在 hasOwnProperty(), 於是變成了對 obj.hasOwnProperty() 無限查詢導致堆疊溢位.

那繼續吧, 我們直接用 Object.prototype.hasOwnProperty() 總行了吧.

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (!Object.prototype.hasOwnProperty.call(receiver, key)) {
			receiver[key] = function () {
				console.log(key);
			};
		}
		return receiver[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayHello();
// sayHello
// sayHello
複製程式碼

到這裡其實問題已經解決了, 我們的後置代理只會在第一次未例項化方法時進行原型鏈查詢, 之後呼叫 obj.sayHello() 都是直接和 obj 進行互動, 既沒有原型鏈查詢也沒有代理. 那這和 Reflect 有什麼關係?

其實這裡用 Reflect 會更好一點, 一方面相對於長長的 Object.prototype.hasOwnProperty.call 來說會更短更直觀, 一方面效能也好一點(反正 Node 原始碼中是把 call 換成了 Reflect).

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (!Reflect.has(receiver, key)) {
			Reflect.set(receiver, key, function () {
				console.log(key);
			});
			return Reflect.get(receiver, key);
		} else {
			return Reflect.get(target, key);
		}
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayHello();
console.log(obj.hasOwnProperty('sayHello'));
複製程式碼

最終我們改成了這樣子, 和前面又稍稍有一些不一樣, 有個 else 把非 obj 自身的屬性查詢轉發給了 target, 因為後面有個 hasOwnProperty() 呼叫, 如果不轉發給 target 的話, 則導致繼承自 Object 的屬性和方法全都會產生堆疊溢位.

後續補充: 這裡我犯了兩個錯誤, 為了說明這個錯誤所以前面的內容不再修改, 當作標本.

先讓我們來看看最終版本的 if (!Reflect.has(receiver, key)) 這段邏輯和之前的 if (!receiver[key]), 我們說, 最終我們希望的是檢測對應屬性是否存在, 這話嚴格來說也不算錯. 但每個人對存在的定義可能都不同, 有人認為 receiver[key] === undefined 就算不存在, 而如果有人覺得 Reflect.has(receiver, key)false 算不存在, 但其實它們是很不一樣的. 這裡我們準確定義應該是, receiver[key] === undefined 是做的可用性檢測, 而 Reflect.has(receiver, key) 是做的存在性檢測. 所以這裡用 Reflect.has(receiver, key) 嚴格來說也不能算錯, 但是很容易被人忽視的一點就是, 在後置代理中, receiver 物件的任何存在但不可用的屬性, 都會導致無法委託到原型鏈上的代理物件. 這也算是使用後置代理的一點限制吧.

而第二個錯誤, 則是實實在在的錯誤了. 前面說過, 一旦進入到 get trap 就肯定說明 obj 一開始不存在對應方法, 既然我們已經知道不存在對應方法了, 那為什麼還要用 if (!Reflect.has(receiver, key)) 做存在性檢測? 所以這步邏輯是多餘的. 但是另一方面是, 很多 Object.prototype 上的方法, 其實 receiver 也是不存在的, 所以當呼叫這些方法的時候也是會進入到 get trap 的, 我們依舊需要把它們轉發到 target 上去. 於是我們應當寫成這樣.

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (Reflect.has(target, key)) {
			return Reflect.get(target, key);
		}
		Reflect.set(receiver, key, function () {
			console.log(key);
		});
		return Reflect.get(receiver, key);
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayHello();
console.log(obj.hasOwnProperty('sayHello'));
複製程式碼

其實也沒有省太多事就是了, 雖然我們去掉了一個判斷, 但是為了保證繼承自 Object 的方法正常使用, 又引入了一個新的判斷, 看上去只是把 if-else 中的邏輯調換了位置而已, 不過邏輯上講, 這樣更合理一些吧.

其他細節

對於陣列使用代理的話, get trap 和 set trap 也可以攔截到陣列方法, 比如 forEach push 等, 因為實際上這些方法也會對陣列使用如 arr[index] 這樣的形式去獲取和設定值.

另外 Proxy 的各個 trap 中的 this 均是指向 handler 物件, 而不是代理物件, 也不是目標物件, 而 trap 中返回函式(如果可以返回一個函式的話)的 this 指向的是代理物件而不是目標物件. 即

var obj = {}, handler = {
	get(target, key, receiver) {
		console.log(this === target);
		console.log(this === receiver);
		console.log(this === handler);
	}
};

var pobj = new Proxy(obj, handler);
pobj.name;
// false
// false
// true


var obj = {}, handler = {
	get(target, key, receiver) {
		return function () {
			console.log(this === target);
			console.log(this === receiver);
			console.log(this === handler);
		};
	}
};

var pobj = new Proxy(obj, handler);
pobj.test();
// false
// true
// false
複製程式碼

這裡也順便提下 Object.defineProperty()this 的處理. Object.defineProperty() 的 getter/setter 中的 this 指向的是目標物件而非屬性描述符物件, 如果 getter 中返回函式, 則函式的 this 也是指向目標物件.

var obj = {
	name: 'aaa'
};

Object.defineProperty(obj, 'test', {
	get() {
		console.log(this.name);
	}
});

obj.test;
// aaa


var obj = {
	name: 'aaa'
};

Object.defineProperty(obj, 'test', {
	get() {
		return function () {
			console.log(this.name);
		};
	}
});

obj.test();
// aaa
複製程式碼

參考資料

相關文章