溫故而知新:你可能不知道的 Proxy

語言: CN / TW / HK

寫在最前面

我們都知道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)

作者:張宇航,微醫前端技術部,一個不文藝的處女座程式設計師。