溫故而知新:你可能不知道的 Proxy
寫在最前面
我們都知道Vue2的響應式系統是利用Object.defineProperty進行數據劫持實現的,但是其本身語法有如以下幾個缺陷:
- 對普通對象的監聽需要遍歷每一個屬性
- 無法監聽數組的變動
- 無法監聽Map/Set數據結構的變動
- 無法對對象新增/刪除的屬性進行監聽
針對此,Vue3使用了Proxy實現的數據響應式,并將其獨立成@vue/reactivity 模塊。因此,要了解學習 Vue3的響應式系統,對Proxy的掌握尤為重要。閱讀完本文,我們可以學習到:
- Proxy對象的基本用法
- Proxy 能實現對對象的代理的工作原理
Proxy簡介
首先,我們來看下Proxy在MDN上的定義:
Proxy 對象用于創建一個對象的代理,從而實現基本操作的攔截和自定義(如屬性查找、賦值、枚舉、函數調用等)。
其基本語法如下:
const p = new Proxy(target, handler);
參數說明:
target: 即我們要代理的對象。我們都知道在JS里“萬物皆對象”,因此這個target 可以是任何類型的對象,包括原生數組,函數,甚至另一個Proxy對象。同時,請注意到定義里的關鍵詞“用于創建一個對象的代理”,因此Proxy只能代理對象,任何原始值類型都是無法代理的 。如對number, boolean類型的原始值代理都會得到 “Cannot create proxy with a non-object as target or handler”的錯誤:
handler:其是一個屬性全部為函數類型的對象。這些函數類型的屬性 ,也 稱之為捕獲器(trap),其作用就是為了實現定義里說的“基本操作的攔截和自定義(如屬性查找、賦值、枚舉、函數調用等)”,注意,這里的攔截其實是對代理對象p的基本操作攔截,而并不是對被代理的對象target的攔截(至于為什么,會在接下來的工作原理章節 進行解釋)。handler對象總共有以下截圖共計13個屬性方法(trap):
基本用法如下 :
const obj = {
foo: 'bar',
fn () {
console.log('fn調用了');
}
};
const handler = {
get (target, key) {
console.log(`我被讀取了${key}屬性`);
return target[key];
},
set (target, key, val) {
console.log(`我被設置了${key}屬性, val: ${val}`);
target[key] = val;
},
apply (target, thisArg, argumentsList) {
console.log('fn調用被攔截');
return target.call(thisArg, ...argumentsList);
}
};
const p = new Proxy(obj, handler);
p.foo; // 輸出:我被讀取了foo屬性
p.foo = 'bar1'; // 輸出:我被設置了foo屬性, val: bar1
p.fn(); // 輸出:我被讀取了fn屬性 fn調用了
在上述 代碼中,我們只是實現了13個方法其中的get/set/apply,這3個trap的含義分別是:屬性讀取操作的捕捉器、屬性設置操作的捕捉器、函數調用操作的捕捉器。關于其他10個方法(捕捉器 )的含義 在這里就不一一贅述了,感興趣的同學可以去MDN了解。
值得注意的是,在上述代碼中,并沒有攔截到obj.fn()函數調用操作,而卻是只是輸出了“我被讀取了fn屬性”。究其原因,我們可以再次從Proxy的定義里的關鍵詞“基本操作”找到答案 。那么何為基本操作呢?在上述代碼中就表明了對象屬性的讀取(p.foo) 、設置(p.foo='xxx')就是基本操作,與之對應的就是非基本操作,我們可以稱之為復合操作。而obj.fn()就是一個典型的復合操作,它是由兩個基本操作組成的分別是讀取操作(obj.fn), 和函數調用操作(取到obj.fn的值再進行調用),而我們代理的對象是obj,并不是obj.fn。因此,我們只能攔截到fn屬性的讀取操作。這也說明了Proxy只能對對象的基本操作進行代理,這點尤為重要。
下面的代碼表明函數的調用也是基本操作,是可以被apply攔截到的:
const handler = {
apply (target, thisArg, argumentsList) {
console.log('函數調用被攔截');
return target.call(thisArg, ...argumentsList);
}
};
new Proxy(() => {}, handler)(); // 輸出:函數調用被攔截
Reflex和 Proxy
首先還是要來看下Reflex在MDN里的定義:
Reflect 是一個內置的對象,它提供攔截 JavaScript 操作的方法。這些方法與proxy handlers 的方法相同.
不難發現,Reflex對象的方法和proxy的攔截器(第二個入參handler)的方法完全一致,同樣有著13個方法:
那么,Reflect對象的作用是 什么呢,拿Reflect.get舉例簡單來說其作用之一就是提供了訪問一個對象屬性的默認行為,如以下代碼:
const obj = {foo: 'foo'};
obj.foo;
// 等同于
Reflect.get(obj, 'foo');
既然 作用一致 ,那么使用Reflect.get有何意義呢,在回答這個問題之前,我們先看下以下代碼:
const obj = {
foo: 'foo',
get bar () {
return this.foo;
}
};
const handler = {
get (target, key, receiver) {
console.log(`我被讀取了${key}屬性`);
return target[key];
},
set (target, key, val, receiver) {
console.log(`我被設置了${key}屬性, val: ${val}`);
target[key] = val;
}
};
const p = new Proxy(obj, handler);
p.bar; // 輸出:我被讀取了bar屬性
// Q: 為什么讀取foo屬性沒有被攔截
在上述代碼中我們定義了一個foo屬性和bar屬性,其中bar屬性是一個訪問器屬性,通過get函數 return this.foo獲取得到 的,因此按理來說我們在讀取bar屬性時候會觸發讀取foo屬性,也同樣會被get的trap所攔截到,但實際代碼運行結果并沒有攔截到foo屬性。這是為什么呢,答案的關鍵在于bar訪問器里的this指向。梳理下代碼運行過程:p.bar 實際上會被handler的get捕獲 返回 target['bar'],而這里的target實際上就是obj,所以這時候bar訪問器里的this指向obj,this.foo,實際就是obj.foo。而obj并不是proxy對象p,所以訪問其foo屬性并不會被攔截到。
那么如何也能觸發到foo屬性的攔截呢,這時候Reflect就派上用場了,有以下代碼:
const obj = {
foo: 'foo',
get bar () {
return this.foo;
}
};
const handler = {
get (target, key, receiver) {
console.log(`我被讀取了${key}屬性`);
return Reflect.get(target, key, receiver);
},
set (target, key, val, receiver) {
console.log(`我被設置了${key}屬性, val: ${val}`);
return Reflect.set(target, key, val, receiver);
}
};
const p = new Proxy(obj, handler)
p.bar; // 輸出:我被讀取了bar屬性 我被讀取了foo屬性
如上面代碼所示,我們能正確地觸發了foo屬性的攔截,其實現的關鍵在于Reflect.get的第三個參數receiver ,其作用就是改變this指向,在MDN里有以下描述:
如果target對象中指定了getter,receiver則為getter調用時的this值。
而我們這里的receiver就是p對象,this.foo 等同于 p.foo,因此訪問bar屬性的 時候同樣可以攔截得到。也正是因為this指向的問題,所以建議在proxy對象攔截器里的屬性方法都通過Reflex.*去操作。
Proxy的工作原理
內部方法和內部槽
在Proxy簡介章節里我們曾提到:“Proxy只能代理對象”。那么不知道你有沒有想過這樣的一個問題,在JS里對象的定義又是什么?關于這個問題的答案,我們需要從ECMAScript規范里找到答案 :
在ecma262規范6.1.7.2章節開頭給出這樣的定義:
The actual semantics of objects, in ECMAScript, are specified via algorithms called internal methods. Each object in an ECMAScript engine is associated with a set of internal methods that defines its runtime behaviour. These internal methods are not part of the ECMAScript language. They are defined by this specification purely for expository purposes. However, each object within an implementation of ECMAScript must behave as specified by the internal methods associated with it. The exact manner in which this is accomplished is determined by the implementation.
也就是說:對象的實際語義是通過稱為內部方法(internal methods)的算法指定的。
那么 ,什么又是內部方法呢。閱讀完本章節,我們不難發現,其實對象 不僅有內部 方法(internal methods)還有內部槽(Internal Slots),在ECMAScript規范里使用[[ xxx ]]來表示內部方法或者內部槽:
Internal methods and internal slots are identified within this specification using names enclosed in double square brackets [[ ]].
內部方法對JavaScript開發者來說是不可見的,但當我們 對一個對象進行操作時,JS引擎則會 調用其內部方法。舉個例子來說:當我們訪問一個對象的屬性時:
const obj = { foo: 'foo'};
obj.foo;
引擎內部則會調用obj內部方法[[ Get ]] 來獲取foo屬性值;
以下是 作為一個對象,其必要的11個基本內部方法,也就是說凡是對象,其必然部署了以下11個內部方法:
當然,不同的對象,可能部署了不同的內部方法。比如說函數也是對象,那如何區分函數和普通對象呢,或者說對象怎么能像函數一樣被調用呢,答案是只要部署了[[ Call ]]這個內部方法,那么這個對象就是函數對象,同時如果這個函數對象也部署了[[ Construct ]]內部方法,那么這個函數對象也是構造函數對象也就意味著其可以使用new操作符:
同時內部方法又是具有多態性的,也就是說不同的對象在對相同的內部方法的實現可能有所差異:
Internal method names are polymorphic. This means that different object values may perform different algorithms when a common internal method name is invoked upon them. That actual object upon which an internal method is invoked is the “target” of the invocation. If, at runtime, the implementation of an algorithm attempts to use an internal method of an object that the object does not support, a TypeError exception is thrown.
舉個例子來說:Proxy對象和普通對象其都有內部方法[[ Get ]] , 但是他們的 [[ Get ]]實現 邏輯卻是不同的,Proxy對象 的[[ Get ]]實現邏輯是由ecma262規范 10.5.8章節里定義的,而普通對象的[[ Get ]]實現邏輯是由ecma262規范 10.1.8章節里定義的.
普通對象和異質對象
在上 一節我們了解到了對象都有內部方法和內部槽,不同的對象可能有不同的內部方法或者內部槽,而即便 有相同的內部 方法,但是其內部方法的內部實現邏輯可能也有所不同。
實際上,通過閱讀ECMAScript規范,我們可以將JS的對象分為兩大類:普通對象(ordinary object)和異質對象(exotic object),而區分一個對象是普通對象還是異質對象的標準就是:內部方法或者內部槽的不同。那么什么是普通對象呢,根據定義滿足以下要求即是:
也就是說,一個普通對象需要滿足以下3點:
其內部方法的定義是符合ECMAScript規范10.1.x章節定義的,如下圖所示10個內部方法:
如果這個對象有內部方法[[ Call ]] 那么其應該是由ECMAScript規范10.2.1章節定義的
如果這個對象有內部方法[[ Construct ]] 那么其應該是由ECMAScript規范10.2.2章節定義的
綜上,就是 一個普通對象的定義。而異質對象的定義就較為簡單了,只要一個對象不是普通對象,那它就是異質對象。
An exotic object is an object that is not an ordinary object.
再聊Proxy
通過上兩個小節我們了解到了普通對象和異質對象的定義,當我們再閱讀規范時就不難發現其實Proxy對象就是一個異質對象,因為Proxy對象的內部方法是在10.5.x章節進行定義的,并不滿足普通對象的定義:
Proxy是如何實現代理對象的,其實是和它的內部方法實現邏輯息息相關的。還是拿代碼舉例來說明:
const obj = {
foo: 'foo',
};
const handler = {
};
const p = new Proxy(obj, {});
p.foo; // 輸出:foo
在上述代碼中,我們的handler是一個空對象,但是它具體是如何實現代理的,但是Proxy對象p仍能實現對對象obj的代理,具體點來講p.foo 的值為什么和obj.foo的值等同。
通過上兩節學習,我們知道對象屬性的讀取操作會觸發引擎內部對這個對象的內部方法[[ Get ]]的調用,那就讓我們看下Proxy的[[ Get ]]內部方法:
這里我們重點看第5-7步,結合我們的代碼,簡而言之,當我們讀取p.foo時,首選會檢查p對象有無get的trap 如果沒有,則會調用被代理的對象obj(target)的[[ Get ]]內部方法,如果有則會調用handler的get 方法并將其調用結果 返回。
因此,我們可以得出一個結論:創建代理對象p時指定的攔截器handler,實際上是用來自定義這個代理對象p本身的操作行為,并不是攔截自定義被代理對象obj的操作行為的。這正是體現了代理透明性質,也解釋了我們在 Proxy簡介里提到的問題:攔截其實是對代理對象p的基本操作攔截,而并不是對被代理的對象target的攔截。
總結
本文主要是介紹Proxy以及配合Reflect的簡單使用,再從ECMAScript規范講起內部方法、內部槽以及普通對象、異質對象的定義,進而了解了Proxy能實現代理的內部實現邏輯。
參考文獻
Proxy - JavaScript | MDN (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)
Reflect - JavaScript | MDN (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect)
ECMAScript? 2023 Language Specification (https://tc39.es/ecma262/)
Vue.js設計與實現 (https://www.ituring.com.cn/book/2953)
作者:張宇航,微醫前端技術部,一個不文藝的處女座程序員。