在ES6以後,Reflect與Proxy API是實作meta-programming的利器,但開發者多半的誤解是「Reflect為Proxy而生,Reflect API不過是Object的進階版」;實際上兩者同等重要,獨立思考存在的意義,試著結合兩者時,才有更大助益。

ESLint與Reflect

先前〈ES6與meta程式設計〉提過,符號(Symbol)、Proxy與Reflect,是ES6以後提供的meta-programming利器。多數開發者應該最熟悉「符號」,它主要用來定義獨一無二的行為象徵,不同符號對物件來說,代表著不同metadata,具體而言,就是透過符號實作物件間的協定掛勾,有些符號實作甚至能影響語言內建語法、運算子的行為,如Symbol.hasInstance可以影響instanceof的結果等。

相對來說,Reflect的角色看似尷尬,不少文件都會細談符號與Proxy,然而,對Reflect都是約略帶過,這不禁令人產生疑問:不搭配Proxy,Reflect就無用武之地了嗎?另外,Reflect上的API為何與Object有所重疊?

解答這兩個疑問的來源之一,是ESLint的〈prefer-reflect〉(https://bit.ly/2ZxcuCY),列出一些用Reflect API取代函式apply或Object API的建議。

開發者多半知道控制函式的this時,可以使用函式繼承而來的apply與call方法;然而,對於函式func.apply(self, args),實際是在隱含地假設函式實例本身沒有定義apply方法,若想避免這層假設,方式之一,是使用Function.prototype.apply.apply(o.test, [o, [o, o]]),就程式碼的閱讀上,這麼做顯然難以理解;Reflect.apply沒有這層顧慮,同樣的需求改用Reflect.apply(func, obj, args)就簡潔多了。

另一方面,若仔細觀察Reflect與Object重疊的API,我們可以發現,Reflect列出的API,能夠用來存取JavaScript引擎的內部方法(Internal method),而且,這些內部方法,實現了CMAScript中、、等演算規範(可參考https://bit.ly/2MDPjDj)。

為了便於辨識,這類與底層審視(Introspection)相關的API,未來基本上會以Reflect作為名稱空間,因此,ESLint才建議,使用Reflect上的getOwnPropertyDescriptor、getPrototypeOf等,來取代Object上既有的對應函式;將來與物件處理相關,然而不涉及引擎底層的API,才是新增到Object,例如,ES10新增的Object.fromEntries等。

Reflect、物件與運算子

除了提供審視引擎底層的API之外,Reflect上有些函式,可以對物件做進階控制。例如,物件可以定義設值函式(setter)與取值函式(getter),然而無法直接取得這類函式實例,這意謂著,此類函式實作中,若有this關鍵字,無法透過函式apply、call,或Reflect.apply來調整對象。然而,Reflect提供了set與get函式,它們都提供一個可選的receiver參數,可用來指定設值函式、取值函式的this對象。

Reflect也將原本只以運算子形式呈現的行為,以函式方式提供。例如,in運算子的對應是Reflect.has,這比較沒有問題,傳回值是布林值,若被測試的不是物件,也是拋回TypeError;new運算子的行為對應是Reflect.construct,對於建構式C來說,new C(1, 2, 3)這個動作,可以使用Reflect.construct(C, [1, 2, 3]),但是,不可以使用C.apply({}, [1, 2, 3]),因為這只是等同於C(1, 2, 3)呼叫罷了。

不少文件都會談到,Reflect.construct可以令new建構式這個動作接受陣列作為引數。實際上,這需求只要藉由Spread運算子也可達到,例如new C(...[1, 2, 3])。比較重要的功能其實是,ES6以後新增new.target,在使用new建構實例時,new.target代表了建構式或類別本身,否則就會是undefined,而Reflect.construct第三個可選參數,就可用來控制new.target的對象,也就是說,相對於使用new建構物件,Reflect.construct能控制的更多。

delete運算子的對應是Reflect.deleteProperty,不過,在行為上有些修正。在嚴格模式下,若用delete刪除不可組態的特性,會引發TypeError;然而,Reflect.deleteProperty刪除不可組態特性時只會傳回false,看似回歸到delete在非嚴格模式下的行為。

因此,對於ESLint建議使用Reflect.deleteProperty來取代delete,開發者持有各自不同的觀點(https://bit.ly/31mVKi9)。有的人從簡潔性、速錯觀點支持delete;有人從可讀性、Reflect API的一致性支持Reflect.deleteProperty。我個人是覺得,在嚴格模式下,刪除不可組態特性既然有兩個選擇,就看需求而定,不見得要按照ESLint的建議。

實現代理模式的Proxy

代理模式簡單來說,是提供一個代理物件給客戶端操作,代理物件具有與目標物件相同的介面,在客戶端對實作毫不知情下,不會意識到正在操作的是代理物件,而代理物件可以單純地將任務轉發給目標物件,或者是在這前後附加額外的邏輯。

在ES6以後,可以透過建構Proxy來實現代理模式,建構時指定目標物件以及處理器,處理器可以代理的行為,與Reflect一對一對應(因此才會有「Reflect為Proxy而生」的這種說法吧!)也就是說,可以代理的介面不單是物件特性,也包括了、、等底層行為,某些程度來說,就是讓開發者可以干涉JavaScript引擎的行為。

要活用Proxy,深入認識Reflect是必要的,因為Proxy提供干涉內部方法的時機,Reflect提供存取內部方法的管道,兩者結合就提供meta-programming的重量級支援,只是簡單地干涉get、set,就能實現日誌、驗證、效能量測等常見需求,透過has、construct、deleteProperty,開發者還能神不知鬼不覺地干涉、改變in、new、delete運算子的行為。

雖說如此,還是要有分寸,實現代理模式基本原則,是「在客戶端對實作毫不知情下,不會意識到正在操作的是代理物件」,引擎為此提供了一定程度的檢測,若明顯地被違反物件協定,試圖實作代理會拋出TypeError。

例如,target的foo特性configurable與writable,若都是false,特性值為10,底下程式碼會拋出錯誤:

const proxy = new Proxy(target, {
get() { return 20; }
});
console.log(proxy.foo);

如果從違反協定的角度來理解,原因就很簡單,由於configurable與writable都是false,這表示該特性是個不能刪的常數10。在上例中,代理物件的實作卻試圖傳回20,顯然違反協定;類似地,若target特性的configurable為false,代理物件的處理器deleteProperty方法傳回true,就會引發TypeError。

獨立地思考Reflect、Proxy

Reflect不是Object的進階版,而是與Proxy更是同等重要的存在,開發者應獨立地思考Reflect API存在之意義,有興趣的話,你可以進一步參考〈What does the Reflect object do in JavaScript?〉(https://bit.ly/2Yva8I8)的討論。

事實上,Proxy肩負的職責是實現代理模式,應該思考代理模式的意義,而不是同時思考Proxy與Reflect可以做到的部分,否則遇到無法代理的情況時,也許會覺得不明就理;若能思考這兩個API各自存在的意義,自然能理解何時兩者應該結合,也才能發揮meta-programming的最大可能性。

專欄作者

熱門新聞

Advertisement