2022 年 6 月 26 日

Proxy 和 Reflect

Proxy 物件會包裝另一個物件並攔截操作,例如讀取/寫入屬性等,可選擇自行處理這些操作,或透明地允許物件處理這些操作。

代理在許多函式庫和一些瀏覽器架構中使用。我們將在本文中看到許多實際應用。

代理

語法

let proxy = new Proxy(target, handler)
  • target – 是要包裝的物件,可以是任何東西,包括函式。
  • handler – 代理設定:一個包含「陷阱」的物件,攔截操作的方法。– 例如,get 陷阱用於讀取 target 的屬性,set 陷阱用於將屬性寫入 target,依此類推。

對於 proxy 上的操作,如果 handler 中有對應的陷阱,則它會執行,並且代理有機會處理它,否則操作會在 target 上執行。

作為一個入門範例,我們來建立一個沒有任何陷阱的代理

let target = {};
let proxy = new Proxy(target, {}); // empty handler

proxy.test = 5; // writing to proxy (1)
alert(target.test); // 5, the property appeared in target!

alert(proxy.test); // 5, we can read it from proxy too (2)

for(let key in proxy) alert(key); // test, iteration works (3)

由於沒有陷阱,proxy 上的所有操作都會轉發到 target

  1. 寫入操作 proxy.test= 會在 target 上設定值。
  2. 讀取操作 proxy.test 會傳回來自 target 的值。
  3. proxy 的反覆運算會傳回來自 target 的值。

正如我們所看到的,沒有任何陷阱,proxytarget 周圍的透明包裝。

Proxy 是一個特殊的「異國物件」。它沒有自己的屬性。使用空的 handler,它會透明地將操作轉發到 target

為了啟用更多功能,我們來新增陷阱。

我們可以用它們攔截什麼?

對於物件上的大多數操作,JavaScript 規範中有一個所謂的「內部方法」,描述它在最低層級如何運作。例如 [[Get]],讀取屬性的內部方法,[[Set]],寫入屬性的內部方法,依此類推。這些方法只用於規範中,我們無法直接按名稱呼叫它們。

Proxy 陷阱攔截這些方法的呼叫。它們列在 Proxy 規範 和下表中。

對於每個內部方法,此表中都有陷阱:我們可以新增到 new Proxyhandler 參數的方法名稱,以攔截操作

內部方法 處理方法 在…時觸發
[[取得]] get 讀取屬性
[[設定]] set 寫入屬性
[[具有屬性]] has in 運算子
[[刪除]] deleteProperty delete 運算子
[[呼叫]] apply 函式呼叫
[[建構]] construct new 運算子
[[取得原型]] getPrototypeOf Object.getPrototypeOf
[[設定原型]] setPrototypeOf Object.setPrototypeOf
[[可擴充]] isExtensible Object.isExtensible
[[防止擴充]] preventExtensions Object.preventExtensions
[[定義自有屬性]] defineProperty Object.definePropertyObject.defineProperties
[[取得自有屬性]] getOwnPropertyDescriptor Object.getOwnPropertyDescriptorfor..inObject.keys/values/entries
[[自有屬性鍵]] ownKeys Object.getOwnPropertyNamesObject.getOwnPropertySymbolsfor..inObject.keys/values/entries
不變式

JavaScript 執行某些不變式,這些條件必須由內部方法和陷阱來滿足。

它們大多數是針對回傳值

  • 如果成功寫入值,[[設定]] 必須回傳 true,否則回傳 false
  • 如果成功刪除值,[[刪除]] 必須回傳 true,否則回傳 false
  • …以此類推,我們將在以下範例中看到更多內容。

還有一些其他不變式,例如

  • 套用至代理物件的 [[取得原型]] 必須回傳與套用至代理物件目標物件的 [[取得原型]] 相同的值。換句話說,讀取代理物件的原型時,永遠都必須回傳目標物件的原型。

陷阱可以攔截這些操作,但它們必須遵循這些規則。

不變式確保語言特性的正確且一致的行為。完整的變式清單在 規範 中。如果您沒有做任何奇怪的事情,您可能不會違反它們。

讓我們看看這如何在實際範例中運作。

使用「get」陷阱的預設值

最常見的陷阱是讀取/寫入屬性。

若要攔截讀取,handler 應具有方法 get(target, property, receiver)

當屬性被讀取時,它會觸發,並帶有以下參數

  • target – 是目標物件,傳遞給 new Proxy 作為第一個參數的那個物件,
  • property – 屬性名稱,
  • receiver – 如果目標屬性是 getter,則 receiver 是將在呼叫中用作 this 的物件。通常那是 proxy 物件本身(或從它繼承的物件,如果我們從 proxy 繼承)。現在我們不需要這個參數,因此稍後將更詳細地說明。

讓我們使用 get 來實作物件的預設值。

我們將建立一個數字陣列,它會傳回不存在值的 0

通常,當有人嘗試取得不存在的陣列項目時,他們會取得 undefined,但我們會將常規陣列包裝到攔截讀取並在沒有此類屬性的情況下傳回 0 的 proxy 中

let numbers = [0, 1, 2];

numbers = new Proxy(numbers, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; // default value
    }
  }
});

alert( numbers[1] ); // 1
alert( numbers[123] ); // 0 (no such item)

正如我們所見,使用 get 陷阱可以很輕鬆地做到這一點。

我們可以使用 Proxy 來實作「預設」值的任何邏輯。

想像我們有一個字典,其中包含短語及其翻譯

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome'] ); // undefined

現在,如果沒有短語,從 dictionary 讀取會傳回 undefined。但在實務上,讓短語保持未翻譯通常比 undefined 更好。因此,讓我們在這種情況下讓它傳回未翻譯的短語,而不是 undefined

為達成此目的,我們將 dictionary 包裝在一個攔截讀取作業的 proxy 中

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

dictionary = new Proxy(dictionary, {
  get(target, phrase) { // intercept reading a property from dictionary
    if (phrase in target) { // if we have it in the dictionary
      return target[phrase]; // return the translation
    } else {
      // otherwise, return the non-translated phrase
      return phrase;
    }
  }
});

// Look up arbitrary phrases in the dictionary!
// At worst, they're not translated.
alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome to Proxy']); // Welcome to Proxy (no translation)
請注意

請注意 proxy 如何覆寫變數

dictionary = new Proxy(dictionary, ...);

proxy 應完全取代目標物件。在目標物件被代理後,不應再有人參照目標物件。否則很容易搞砸。

使用「set」陷阱進行驗證

假設我們想要一個專門用於數字的陣列。如果新增了其他類型的值,則應出現錯誤。

當寫入屬性時,set 陷阱會觸發。

set(target, property, value, receiver):

  • target – 是目標物件,傳遞給 new Proxy 作為第一個參數的那個物件,
  • property – 屬性名稱,
  • value – 屬性值,
  • receiver – 類似於 get 陷阱,僅適用於 setter 屬性。

如果設定成功,set 陷阱應傳回 true,否則傳回 false(觸發 TypeError)。

讓我們使用它來驗證新值

let numbers = [];

numbers = new Proxy(numbers, { // (*)
  set(target, prop, val) { // to intercept property writing
    if (typeof val == 'number') {
      target[prop] = val;
      return true;
    } else {
      return false;
    }
  }
});

numbers.push(1); // added successfully
numbers.push(2); // added successfully
alert("Length is: " + numbers.length); // 2

numbers.push("test"); // TypeError ('set' on proxy returned false)

alert("This line is never reached (error in the line above)");

請注意:陣列的內建功能仍然有效!值會透過 push 新增。新增值時,length 屬性會自動增加。我們的代理不會破壞任何東西。

我們不必覆寫值新增陣列方法(例如 pushunshift 等),並在其中新增檢查,因為它們在內部會使用代理攔截的 [[Set]] 操作。

因此,程式碼簡潔明瞭。

別忘了傳回 true

如上所述,有一些不變式必須成立。

對於 set,它必須傳回 true 表示寫入成功。

如果我們忘記執行此操作或傳回任何假值,該操作會觸發 TypeError

使用「ownKeys」和「getOwnPropertyDescriptor」進行反覆運算

Object.keysfor..in 迴圈和大多數其他反覆運算物件屬性的方法會使用 [[OwnPropertyKeys]] 內部方法(由 ownKeys 陷阱攔截)來取得屬性清單。

此類方法在細節上有所不同

  • Object.getOwnPropertyNames(obj) 傳回非符號鍵。
  • Object.getOwnPropertySymbols(obj) 傳回符號鍵。
  • Object.keys/values() 傳回具有 enumerable 旗標的非符號鍵/值(屬性旗標已在文章 屬性旗標和描述符 中說明)。
  • for..in 迴圈反覆運算具有 enumerable 旗標的非符號鍵,以及原型鍵。

…但它們都從該清單開始。

在以下範例中,我們使用 ownKeys 陷阱讓 for..in 迴圈反覆運算 user,以及 Object.keysObject.values,以略過以底線 _ 開頭的屬性

let user = {
  name: "John",
  age: 30,
  _password: "***"
};

user = new Proxy(user, {
  ownKeys(target) {
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "ownKeys" filters out _password
for(let key in user) alert(key); // name, then: age

// same effect on these methods:
alert( Object.keys(user) ); // name,age
alert( Object.values(user) ); // John,30

到目前為止,它都能正常運作。

不過,如果我們傳回物件中不存在的鍵,Object.keys 就不會列出它

let user = { };

user = new Proxy(user, {
  ownKeys(target) {
    return ['a', 'b', 'c'];
  }
});

alert( Object.keys(user) ); // <empty>

為什麼?原因很簡單:Object.keys 僅傳回具有 enumerable 旗標的屬性。為了檢查它,它會針對每個屬性呼叫內部方法 [[GetOwnProperty]] 以取得 其描述符。在此,由於沒有屬性,因此其描述符為空,沒有 enumerable 旗標,所以會略過它。

要讓 Object.keys 傳回屬性,我們需要它存在於物件中並具有 enumerable 旗標,或者我們可以攔截對 [[GetOwnProperty]] 的呼叫(陷阱 getOwnPropertyDescriptor 會執行此操作),並傳回具有 enumerable: true 的描述符。

以下是一個範例

let user = { };

user = new Proxy(user, {
  ownKeys(target) { // called once to get a list of properties
    return ['a', 'b', 'c'];
  },

  getOwnPropertyDescriptor(target, prop) { // called for every property
    return {
      enumerable: true,
      configurable: true
      /* ...other flags, probable "value:..." */
    };
  }

});

alert( Object.keys(user) ); // a, b, c

讓我們再次注意:我們只需要在物件中沒有屬性的情況下攔截 [[GetOwnProperty]]

使用「deleteProperty」和其他陷阱保護屬性

有一個廣泛的慣例,以底線 _ 作為開頭的屬性和方法是內部的。不應從物件外部存取它們。

技術上來說,這是有可能的

let user = {
  name: "John",
  _password: "secret"
};

alert(user._password); // secret

讓我們使用代理來防止存取任何以 _ 開頭的屬性。

我們需要陷阱

  • get 在讀取此類屬性時擲回錯誤,
  • set 在寫入時擲回錯誤,
  • deleteProperty 在刪除時擲回錯誤,
  • ownKeys 排除以 _ 開頭的屬性,不讓 for..inObject.keys 等方法存取。

以下是程式碼

let user = {
  name: "John",
  _password: "***"
};

user = new Proxy(user, {
  get(target, prop) {
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    }
    let value = target[prop];
    return (typeof value === 'function') ? value.bind(target) : value; // (*)
  },
  set(target, prop, val) { // to intercept property writing
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      target[prop] = val;
      return true;
    }
  },
  deleteProperty(target, prop) { // to intercept property deletion
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      delete target[prop];
      return true;
    }
  },
  ownKeys(target) { // to intercept property list
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "get" doesn't allow to read _password
try {
  alert(user._password); // Error: Access denied
} catch(e) { alert(e.message); }

// "set" doesn't allow to write _password
try {
  user._password = "test"; // Error: Access denied
} catch(e) { alert(e.message); }

// "deleteProperty" doesn't allow to delete _password
try {
  delete user._password; // Error: Access denied
} catch(e) { alert(e.message); }

// "ownKeys" filters out _password
for(let key in user) alert(key); // name

請注意 get 陷阱中 (*) 行中的重要細節

get(target, prop) {
  // ...
  let value = target[prop];
  return (typeof value === 'function') ? value.bind(target) : value; // (*)
}

為什麼我們需要一個函式來呼叫 value.bind(target)

原因是物件方法,例如 user.checkPassword(),必須能夠存取 _password

user = {
  // ...
  checkPassword(value) {
    // object method must be able to read _password
    return value === this._password;
  }
}

呼叫 user.checkPassword() 會將代理的 user 作為 this(點之前的物件會變成 this),因此當它嘗試存取 this._password 時,get 陷阱會啟動(它會在任何屬性讀取時觸發)並擲回錯誤。

因此,我們在 (*) 行中將物件方法的內容繫結到原始物件 target。然後,它們未來的呼叫會將 target 用作 this,而不會有任何陷阱。

這種解決方案通常有效,但並非理想,因為方法可能會將未代理的物件傳遞到其他地方,然後我們就會搞亂:原始物件在哪裡,代理的物件在哪裡?

此外,一個物件可能會被代理多次(多個代理可能會對物件新增不同的「調整」),如果我們將一個未封裝的物件傳遞給一個方法,可能會產生意外的後果。

因此,這樣的代理不應在所有地方使用。

類別的私有屬性

現代 JavaScript 引擎本機支援類別中的私有屬性,以 # 作為開頭。它們在文章 私有和受保護的屬性和方法 中有說明。不需要代理。

不過,此類屬性有其自身的問題。特別是,它們不會被繼承。

使用「has」陷阱的「範圍」

讓我們看更多範例。

我們有一個範圍物件

let range = {
  start: 1,
  end: 10
};

我們希望使用 in 算子來檢查一個數字是否在 range 中。

has 陷阱會攔截 in 呼叫。

has(target, property)

  • target – 是目標物件,傳遞給 new Proxy 作為第一個參數,
  • property – 屬性名稱

以下是示範

let range = {
  start: 1,
  end: 10
};

range = new Proxy(range, {
  has(target, prop) {
    return prop >= target.start && prop <= target.end;
  }
});

alert(5 in range); // true
alert(50 in range); // false

不錯的語法糖,不是嗎?而且實作起來非常簡單。

包裝函數:「apply」

我們也可以在函數周圍包裝一個代理。

apply(target, thisArg, args) 陷阱處理將代理作為函數呼叫

  • target 是目標物件(函數在 JavaScript 中是一個物件),
  • thisArgthis 的值。
  • args 是引數清單。

例如,讓我們回想一下我們在文章 裝飾器和轉發,call/apply 中所做的 delay(f, ms) 裝飾器。

在該文章中,我們沒有使用代理來執行此操作。呼叫 delay(f, ms) 會傳回一個函數,該函數在 ms 毫秒後將所有呼叫轉發到 f

以下是先前的基於函數的實作

function delay(f, ms) {
  // return a wrapper that passes the call to f after the timeout
  return function() { // (*)
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

// after this wrapping, calls to sayHi will be delayed for 3 seconds
sayHi = delay(sayHi, 3000);

sayHi("John"); // Hello, John! (after 3 seconds)

正如我們已經看到的,這大多數時候都能正常運作。包裝函數 (*) 在逾時後執行呼叫。

但是包裝函數不會轉發屬性讀寫操作或其他任何操作。包裝後,將無法存取原始函數的屬性,例如 namelength

function delay(f, ms) {
  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

alert(sayHi.length); // 1 (function length is the arguments count in its declaration)

sayHi = delay(sayHi, 3000);

alert(sayHi.length); // 0 (in the wrapper declaration, there are zero arguments)

Proxy 強大得多,因為它會將所有內容轉發到目標物件。

讓我們使用 Proxy 取代包裝函數

function delay(f, ms) {
  return new Proxy(f, {
    apply(target, thisArg, args) {
      setTimeout(() => target.apply(thisArg, args), ms);
    }
  });
}

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

sayHi = delay(sayHi, 3000);

alert(sayHi.length); // 1 (*) proxy forwards "get length" operation to the target

sayHi("John"); // Hello, John! (after 3 seconds)

結果相同,但現在不僅呼叫,代理上的所有操作都會轉發到原始函數。因此,在第 (*) 行包裝後,會正確傳回 sayHi.length

我們得到一個「更豐富」的包裝器。

還有其他陷阱:完整清單在本篇文章的開頭。它們的使用模式類似於上述模式。

Reflect

Reflect 是內建物件,可簡化 Proxy 的建立。

先前曾說過,內部方法(例如 [[Get]][[Set]] 等)僅限於規格,無法直接呼叫。

Reflect 物件讓這件事變得有點可能。它的方法是內部方法的最小包裝器。

以下是執行相同操作的 Reflect 呼叫和操作範例

操作 Reflect 呼叫 內部方法
obj[prop] Reflect.get(obj, prop) [[取得]]
obj[prop] = value Reflect.set(obj, prop, value) [[設定]]
delete obj[prop] Reflect.deleteProperty(obj, prop) [[刪除]]
new F(value) Reflect.construct(F, value) [[建構]]

例如

let user = {};

Reflect.set(user, 'name', 'John');

alert(user.name); // John

特別是,Reflect 允許我們將運算子(newdelete…)作為函數(Reflect.constructReflect.deleteProperty…)呼叫。這是一個有趣的機能,但這裡還有另一件事很重要。

對於每個內部方法,Proxy 可進行攔截,在 Reflect 中都有對應的方法,其名稱和參數與 Proxy 攔截相同。

因此,我們可以使用 Reflect 將操作轉發到原始物件。

在此範例中,getset 攔截都會透明地(彷彿不存在)將讀取/寫入操作轉發到物件,並顯示訊息

let user = {
  name: "John",
};

user = new Proxy(user, {
  get(target, prop, receiver) {
    alert(`GET ${prop}`);
    return Reflect.get(target, prop, receiver); // (1)
  },
  set(target, prop, val, receiver) {
    alert(`SET ${prop}=${val}`);
    return Reflect.set(target, prop, val, receiver); // (2)
  }
});

let name = user.name; // shows "GET name"
user.name = "Pete"; // shows "SET name=Pete"

如下所示

  • Reflect.get 會讀取物件屬性。
  • Reflect.set 會寫入物件屬性,並在成功時傳回 true,否則傳回 false

也就是說,一切都很簡單:如果攔截想要將呼叫轉發到物件,只要呼叫 Reflect.<method> 並傳入相同的參數即可。

在多數情況下,我們可以在不使用 Reflect 的情況下執行相同的動作,例如,讀取屬性 Reflect.get(target, prop, receiver) 可以改用 target[prop]。不過,其中有一些重要的差異。

代理取得器

讓我們來看一個範例,說明為什麼 Reflect.get 較佳。我們也會看到為什麼 get/set 有第三個參數 receiver,而我們之前沒有使用過。

我們有一個物件 user,其中有 _name 屬性和一個取得器。

以下是其周圍的代理

let user = {
  _name: "Guest",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop];
  }
});

alert(userProxy.name); // Guest

get 攔截在此處是「透明的」,它會傳回原始屬性,而且不會執行其他任何動作。這對於我們的範例來說已經足夠了。

一切看起來都很好。不過,讓我們讓範例再複雜一點。

user 繼承另一個物件 admin 之後,我們可以觀察到不正確的行為

let user = {
  _name: "Guest",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop]; // (*) target = user
  }
});

let admin = {
  __proto__: userProxy,
  _name: "Admin"
};

// Expected: Admin
alert(admin.name); // outputs: Guest (?!?)

讀取 admin.name 應該傳回 "Admin",而不是 "Guest"

問題出在哪裡?也許我們在繼承時做錯了什麼事?

但是,如果我們移除代理,那麼一切都將按預期運作。

問題實際上出在代理的 (*) 行。

  1. 當我們讀取 admin.name 時,由於 admin 物件沒有此自有屬性,因此搜尋會轉移到其原型。

  2. 原型是 userProxy

  3. 從代理讀取 name 屬性時,其 get 攔截會觸發,並在 (*) 行中從原始物件傳回 target[prop]

    prop 是取得器時,呼叫 target[prop] 會在 this=target 的內容中執行其程式碼。因此,結果是原始物件 targetthis._name,也就是來自 user

要修正這種情況,我們需要 receiver,也就是 get 陷阱的第三個參數。它會保留正確的 this 以傳遞給 getter。在我們的案例中,就是 admin

如何傳遞 getter 的內容?對於一般函式,我們可以使用 call/apply,但 getter 不是「被呼叫」,而是「被存取」。

Reflect.get 可以做到這件事。如果我們使用它,一切都將正常運作。

以下是修正後的變體

let user = {
  _name: "Guest",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) { // receiver = admin
    return Reflect.get(target, prop, receiver); // (*)
  }
});


let admin = {
  __proto__: userProxy,
  _name: "Admin"
};

alert(admin.name); // Admin

現在,會使用 Reflect.get(*) 行中將保留對正確 this(也就是 admin)的參考的 receiver 傳遞給 getter。

我們可以將陷阱改寫得更簡短

get(target, prop, receiver) {
  return Reflect.get(...arguments);
}

Reflect 呼叫的名稱與陷阱完全相同,而且會接受相同的參數。它們特別設計成這樣。

因此,return Reflect... 提供了一個安全的無腦方式來轉發操作,並確保我們不會忘記任何相關事項。

Proxy 的限制

Proxy 提供了一種獨特的方式來改變或調整現有物件在最低層級的行為。不過,它並非完美無缺。它有一些限制。

內建物件:內部插槽

許多內建物件,例如 MapSetDatePromise 等,會使用所謂的「內部插槽」。

這些插槽類似於屬性,但保留給內部、僅限規格的目的。例如,Map 會將項目儲存在內部插槽 [[MapData]] 中。內建方法會直接存取它們,而不是透過 [[Get]]/[[Set]] 內部方法。因此,Proxy 無法攔截它們。

為什麼要關心?它們本來就是內部的!

問題在於此。在像這樣的內建物件被代理之後,Proxy 沒有這些內部插槽,因此內建方法會失敗。

例如

let map = new Map();

let proxy = new Proxy(map, {});

proxy.set('test', 1); // Error

在內部,Map 會將所有資料儲存在其 [[MapData]] 內部插槽中。Proxy 沒有這樣的插槽。內建方法 Map.prototype.set 會嘗試存取內部屬性 this.[[MapData]],但由於 this=proxy,因此無法在 proxy 中找到它,最後失敗。

幸運的是,有一個方法可以修正它

let map = new Map();

let proxy = new Proxy(map, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

proxy.set('test', 1);
alert(proxy.get('test')); // 1 (works!)

現在它運作正常,因為 get 陷阱會將函式屬性(例如 map.set)繫結到目標物件(map)本身。

與前一個範例不同,proxy.set(...) 內部的 this 值不會是 proxy,而是原始的 map。因此,當 set 的內部實作嘗試存取 this.[[MapData]] 內部插槽時,會成功。

Array 沒有內部插槽

一個值得注意的例外:內建的 Array 不使用內部插槽。這是因為歷史原因,它出現的時間太早了。

因此,在代理陣列時不會有此問題。

私有欄位

類似的問題也會發生在私有類別欄位上。

例如,getName() 方法存取私有的 #name 屬性,並在代理後中斷

class User {
  #name = "Guest";

  getName() {
    return this.#name;
  }
}

let user = new User();

user = new Proxy(user, {});

alert(user.getName()); // Error

原因是私有欄位是使用內部插槽實作的。JavaScript 在存取它們時不會使用 [[Get]]/[[Set]]

在呼叫 getName() 時,this 的值是代理的 user,它沒有包含私有欄位的插槽。

再一次,使用繫結方法的解決方案可以讓它運作

class User {
  #name = "Guest";

  getName() {
    return this.#name;
  }
}

let user = new User();

user = new Proxy(user, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

alert(user.getName()); // Guest

話雖如此,此解決方案有缺點,如前所述:它會將原始物件公開給方法,可能會讓它被進一步傳遞並中斷其他代理功能。

Proxy != target

代理和原始物件是不同的物件。這很自然,對吧?

因此,如果我們使用原始物件作為鍵,然後代理它,那麼就找不到代理

let allUsers = new Set();

class User {
  constructor(name) {
    this.name = name;
    allUsers.add(this);
  }
}

let user = new User("John");

alert(allUsers.has(user)); // true

user = new Proxy(user, {});

alert(allUsers.has(user)); // false

正如我們所見,在代理後,我們無法在集合 allUsers 中找到 user,因為代理是一個不同的物件。

代理無法攔截嚴格相等性測試 ===

代理可以攔截許多運算子,例如 new(使用 construct)、in(使用 has)、delete(使用 deleteProperty)等等。

但是,沒有辦法攔截物件的嚴格相等性測試。一個物件只與它自己嚴格相等,沒有其他值。

因此,所有比較物件相等性的運算和內建類別都會區分物件和代理。這裡沒有透明替換。

可撤銷代理

可撤銷代理是一種可以停用的代理。

假設我們有一個資源,並希望隨時關閉對它的存取。

我們可以做的是將它包裝到一個可撤銷代理中,而不使用任何陷阱。這樣的代理會將運算轉發到物件,我們可以在任何時候停用它。

語法為

let {proxy, revoke} = Proxy.revocable(target, handler)

呼叫會傳回一個物件,其中包含 proxyrevoke 函式,用於停用它。

以下是一個範例

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});

// pass the proxy somewhere instead of object...
alert(proxy.data); // Valuable data

// later in our code
revoke();

// the proxy isn't working any more (revoked)
alert(proxy.data); // Error

呼叫 revoke() 會從代理中移除所有指向目標物件的內部參照,因此它們不再連線。

最初,revokeproxy 是分開的,因此我們可以在保留 revoke 在目前範圍內的情況下,傳遞 proxy

我們也可以透過設定 proxy.revoke = revokerevoke 方法繫結到代理。

另一個選項是建立一個 WeakMap,其中 proxy 為鍵,對應的 revoke 為值,這允許輕鬆地為代理尋找 revoke

let revokes = new WeakMap();

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});

revokes.set(proxy, revoke);

// ..somewhere else in our code..
revoke = revokes.get(proxy);
revoke();

alert(proxy.data); // Error (revoked)

我們在此使用 WeakMap 而不是 Map,因為它不會阻擋垃圾回收。如果代理物件變成「無法觸及」(例如,沒有變數再參照它),WeakMap 允許它與我們不再需要的 revoke 一起從記憶體中清除。

參考資料

摘要

Proxy 是物件的包裝器,它會將其上的操作轉發到物件,並選擇性地攔截其中一些操作。

它可以包裝任何類型的物件,包括類別和函式。

語法為

let proxy = new Proxy(target, {
  /* traps */
});

…然後我們應該在所有地方使用 proxy,而不是 target。代理沒有自己的屬性或方法。如果提供攔截器,它會攔截操作,否則會將操作轉發到 target 物件。

我們可以攔截

  • 讀取 (get)、寫入 (set)、刪除 (deleteProperty) 屬性(甚至是尚不存在的屬性)。
  • 呼叫函式 (apply 攔截器)。
  • new 營運子 (construct 攔截器)。
  • 許多其他操作(完整清單在本文開頭和 文件 中)。

這允許我們建立「虛擬」屬性和方法,實作預設值、可觀察物件、函式裝飾器等等。

我們也可以在不同的代理中多次包裝一個物件,並使用各種功能面向來裝飾它。

Reflect API 旨在補充 Proxy。對於任何 Proxy 攔截器,都有具有相同引數的 Reflect 呼叫。我們應該使用它們將呼叫轉發到目標物件。

代理有一些限制

  • 內建物件有「內部插槽」,無法存取這些插槽。請參閱上述解決方法。
  • 私有類別欄位也是如此,因為它們在內部是使用插槽實作的。因此,代理方法呼叫必須將目標物件當作 this 才能存取它們。
  • 物件相等性測試 === 無法攔截。
  • 效能:基準測試取決於引擎,但一般來說,使用最簡單的代理存取屬性會花費數倍的時間。不過,在實務上,這只會影響某些「瓶頸」物件。

作業

通常,嘗試讀取不存在的屬性會傳回 undefined

建立一個代理,當嘗試讀取不存在的屬性時,會擲回錯誤。

這有助於及早偵測程式錯誤。

撰寫一個函式 wrap(target),它會接收一個物件 target,並傳回一個代理,新增此功能面向。

它的運作方式如下

let user = {
  name: "John"
};

function wrap(target) {
  return new Proxy(target, {
      /* your code */
  });
}

user = wrap(user);

alert(user.name); // John
alert(user.age); // ReferenceError: Property doesn't exist: "age"
let user = {
  name: "John"
};

function wrap(target) {
  return new Proxy(target, {
    get(target, prop, receiver) {
      if (prop in target) {
        return Reflect.get(target, prop, receiver);
      } else {
        throw new ReferenceError(`Property doesn't exist: "${prop}"`)
      }
    }
  });
}

user = wrap(user);

alert(user.name); // John
alert(user.age); // ReferenceError: Property doesn't exist: "age"

在某些程式語言中,我們可以使用負數索引存取陣列元素,從尾端開始計算。

如下所示

let array = [1, 2, 3];

array[-1]; // 3, the last element
array[-2]; // 2, one step from the end
array[-3]; // 1, two steps from the end

換句話說,array[-N] 等於 array[array.length - N]

建立一個代理來實作此行為。

它的運作方式如下

let array = [1, 2, 3];

array = new Proxy(array, {
  /* your code */
});

alert( array[-1] ); // 3
alert( array[-2] ); // 2

// Other array functionality should be kept "as is"
let array = [1, 2, 3];

array = new Proxy(array, {
  get(target, prop, receiver) {
    if (prop < 0) {
      // even if we access it like arr[1]
      // prop is a string, so need to convert it to number
      prop = +prop + target.length;
    }
    return Reflect.get(target, prop, receiver);
  }
});


alert(array[-1]); // 3
alert(array[-2]); // 2

建立一個函式 makeObservable(target),透過傳回一個代理來「讓物件可觀察」。

它的運作方式如下

function makeObservable(target) {
  /* your code */
}

let user = {};
user = makeObservable(user);

user.observe((key, value) => {
  alert(`SET ${key}=${value}`);
});

user.name = "John"; // alerts: SET name=John

換句話說,makeObservable 傳回的物件就像原始物件,但它還有一個方法 observe(handler),用於設定 handler 函式,以便在任何屬性變更時呼叫它。

每當屬性變更時,就會呼叫 handler(key, value),並傳入屬性的名稱和值。

附註:在此作業中,請只注意寫入屬性。其他作業可以以類似的方式實作。

解決方案包含兩部分

  1. 每當呼叫 .observe(handler) 時,我們需要記住某處的處理常式,才能在稍後呼叫它。我們可以使用我們的符號作為屬性金鑰,將處理常式儲存在物件中。
  2. 我們需要一個具有 set 攔截器的代理,以便在任何變更時呼叫處理常式。
let handlers = Symbol('handlers');

function makeObservable(target) {
  // 1. Initialize handlers store
  target[handlers] = [];

  // Store the handler function in array for future calls
  target.observe = function(handler) {
    this[handlers].push(handler);
  };

  // 2. Create a proxy to handle changes
  return new Proxy(target, {
    set(target, property, value, receiver) {
      let success = Reflect.set(...arguments); // forward the operation to object
      if (success) { // if there were no error while setting the property
        // call all handlers
        target[handlers].forEach(handler => handler(property, value));
      }
      return success;
    }
  });
}

let user = {};

user = makeObservable(user);

user.observe((key, value) => {
  alert(`SET ${key}=${value}`);
});

user.name = "John";
教學地圖

註解

在評論之前先閱讀這段…
  • 如果您有建議要改進 - 請提交 GitHub 問題或提交拉取請求,而不是評論。
  • 如果您無法理解文章中的內容 – 請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行程式碼,請將它們包覆在 <pre> 標籤中,若要插入 10 行以上的程式碼 – 請使用沙盒 (plnkrjsbincodepen…)