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
。
- 寫入操作
proxy.test=
會在target
上設定值。 - 讀取操作
proxy.test
會傳回來自target
的值。 - 對
proxy
的反覆運算會傳回來自target
的值。
正如我們所看到的,沒有任何陷阱,proxy
是 target
周圍的透明包裝。
Proxy
是一個特殊的「異國物件」。它沒有自己的屬性。使用空的 handler
,它會透明地將操作轉發到 target
。
為了啟用更多功能,我們來新增陷阱。
我們可以用它們攔截什麼?
對於物件上的大多數操作,JavaScript 規範中有一個所謂的「內部方法」,描述它在最低層級如何運作。例如 [[Get]]
,讀取屬性的內部方法,[[Set]]
,寫入屬性的內部方法,依此類推。這些方法只用於規範中,我們無法直接按名稱呼叫它們。
Proxy 陷阱攔截這些方法的呼叫。它們列在 Proxy 規範 和下表中。
對於每個內部方法,此表中都有陷阱:我們可以新增到 new Proxy
的 handler
參數的方法名稱,以攔截操作
內部方法 | 處理方法 | 在…時觸發 |
---|---|---|
[[取得]] |
get |
讀取屬性 |
[[設定]] |
set |
寫入屬性 |
[[具有屬性]] |
has |
in 運算子 |
[[刪除]] |
deleteProperty |
delete 運算子 |
[[呼叫]] |
apply |
函式呼叫 |
[[建構]] |
construct |
new 運算子 |
[[取得原型]] |
getPrototypeOf |
Object.getPrototypeOf |
[[設定原型]] |
setPrototypeOf |
Object.setPrototypeOf |
[[可擴充]] |
isExtensible |
Object.isExtensible |
[[防止擴充]] |
preventExtensions |
Object.preventExtensions |
[[定義自有屬性]] |
defineProperty |
Object.defineProperty、Object.defineProperties |
[[取得自有屬性]] |
getOwnPropertyDescriptor |
Object.getOwnPropertyDescriptor、for..in 、Object.keys/values/entries |
[[自有屬性鍵]] |
ownKeys |
Object.getOwnPropertyNames、Object.getOwnPropertySymbols、for..in 、Object.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
屬性會自動增加。我們的代理不會破壞任何東西。
我們不必覆寫值新增陣列方法(例如 push
和 unshift
等),並在其中新增檢查,因為它們在內部會使用代理攔截的 [[Set]]
操作。
因此,程式碼簡潔明瞭。
true
如上所述,有一些不變式必須成立。
對於 set
,它必須傳回 true
表示寫入成功。
如果我們忘記執行此操作或傳回任何假值,該操作會觸發 TypeError
。
使用「ownKeys」和「getOwnPropertyDescriptor」進行反覆運算
Object.keys
、for..in
迴圈和大多數其他反覆運算物件屬性的方法會使用 [[OwnPropertyKeys]]
內部方法(由 ownKeys
陷阱攔截)來取得屬性清單。
此類方法在細節上有所不同
Object.getOwnPropertyNames(obj)
傳回非符號鍵。Object.getOwnPropertySymbols(obj)
傳回符號鍵。Object.keys/values()
傳回具有enumerable
旗標的非符號鍵/值(屬性旗標已在文章 屬性旗標和描述符 中說明)。for..in
迴圈反覆運算具有enumerable
旗標的非符號鍵,以及原型鍵。
…但它們都從該清單開始。
在以下範例中,我們使用 ownKeys
陷阱讓 for..in
迴圈反覆運算 user
,以及 Object.keys
和 Object.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..in
和Object.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 中是一個物件),thisArg
是this
的值。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)
正如我們已經看到的,這大多數時候都能正常運作。包裝函數 (*)
在逾時後執行呼叫。
但是包裝函數不會轉發屬性讀寫操作或其他任何操作。包裝後,將無法存取原始函數的屬性,例如 name
、length
等
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
允許我們將運算子(new
、delete
…)作為函數(Reflect.construct
、Reflect.deleteProperty
…)呼叫。這是一個有趣的機能,但這裡還有另一件事很重要。
對於每個內部方法,Proxy
可進行攔截,在 Reflect
中都有對應的方法,其名稱和參數與 Proxy
攔截相同。
因此,我們可以使用 Reflect
將操作轉發到原始物件。
在此範例中,get
和 set
攔截都會透明地(彷彿不存在)將讀取/寫入操作轉發到物件,並顯示訊息
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"
!
問題出在哪裡?也許我們在繼承時做錯了什麼事?
但是,如果我們移除代理,那麼一切都將按預期運作。
問題實際上出在代理的 (*)
行。
-
當我們讀取
admin.name
時,由於admin
物件沒有此自有屬性,因此搜尋會轉移到其原型。 -
原型是
userProxy
。 -
從代理讀取
name
屬性時,其get
攔截會觸發,並在(*)
行中從原始物件傳回target[prop]
。當
prop
是取得器時,呼叫target[prop]
會在this=target
的內容中執行其程式碼。因此,結果是原始物件target
的this._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 提供了一種獨特的方式來改變或調整現有物件在最低層級的行為。不過,它並非完美無缺。它有一些限制。
內建物件:內部插槽
許多內建物件,例如 Map
、Set
、Date
、Promise
等,會使用所謂的「內部插槽」。
這些插槽類似於屬性,但保留給內部、僅限規格的目的。例如,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)
呼叫會傳回一個物件,其中包含 proxy
和 revoke
函式,用於停用它。
以下是一個範例
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()
會從代理中移除所有指向目標物件的內部參照,因此它們不再連線。
最初,revoke
與 proxy
是分開的,因此我們可以在保留 revoke
在目前範圍內的情況下,傳遞 proxy
。
我們也可以透過設定 proxy.revoke = revoke
將 revoke
方法繫結到代理。
另一個選項是建立一個 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
才能存取它們。 - 物件相等性測試
===
無法攔截。 - 效能:基準測試取決於引擎,但一般來說,使用最簡單的代理存取屬性會花費數倍的時間。不過,在實務上,這只會影響某些「瓶頸」物件。
註解
<code>
標籤,若要插入多行程式碼,請將它們包覆在<pre>
標籤中,若要插入 10 行以上的程式碼 – 請使用沙盒 (plnkr、jsbin、codepen…)