Об’єкт Proxy
обгортає інший об’єкт і перехоплює операції, такі як читання/запис властивостей та інші, за бажанням обробляючи їх самостійно, або прозоро дозволяючи об’єкту обробляти їх.
Проксі використовуються в багатьох бібліотеках і деяких фреймворках браузера. У цьому розділі ми побачимо багато випадків вирішення реальних задач.
Proxy
Синтаксис:
let proxy = new Proxy(target, handler)
target
– об’єкт для обгортання, може бути будь-чим, включаючи функції.handler
– конфігурація проксі: об’єкт з “пастками” (“traps”), методами, які перехоплюють операції, наприклад, пасткаget
– для зчитування властивостіtarget
, пасткаset
– для запису властивості вtarget
і так далі.
Для операцій над proxy
, якщо в handler
є відповідна пастка, то вона спрацьовує, і проксі має шанс обробити її, інакше операція виконується над target
.
Як початковий приклад, створімо проксі без пасток:
let target = {};
let proxy = new Proxy(target, {}); // порожній handler
proxy.test = 5; // записуємо в проксі (1)
alert(target.test); // 5, властивість з’явилася у target!
alert(proxy.test); // 5, ми також можемо зчитати її з проксі (2)
for(let key in proxy) alert(key); // test, ітерація працює (3)
Оскільки пасток немає, усі операції на proxy
перенаправляються до target
.
- Операція запису
proxy.test=
встановлює значення дляtarget
. - Операція зчитування
proxy.test
повертає значення зtarget
. - Ітерація по
proxy
повертає значення зtarget
.
Як бачимо, без пасток proxy
є прозорою обгорткою навколо target
.
Proxy
– це особливий “екзотичний об’єкт”. Він не має своїх властивостей. З порожнім handler
він прозоро перенаправляє операції до target
.
Щоб активувати більше можливостей, додаймо пастки.
Що саме ми можемо ними перехопити?
Для більшості операцій над об’єктами в специфікації JavaScript є так званий “внутрішній метод”, який на найнижчому рівні описує, як його виконувати. Наприклад, [[Get]]
, внутрішній метод для зчитування властивості, [[Set]]
, внутрішній метод для запису властивості тощо. Ці методи використовуються лише в специфікації, ми не можемо називати їх безпосередньо по імені.
Пастки проксі перехоплюють виклики цих методів. Вони перераховані в специфікації Proxy і в таблиці нижче.
Для кожного внутрішнього методу в цій таблиці є пастка: ім’я методу, яке ми можемо додати до параметра handler
нового проксі, щоб перехопити операцію:
Внутрішній Метод | Метод Пастки | Викликається, коли… |
---|---|---|
[[Get]] |
get |
зчитування значення |
[[Set]] |
set |
запис значення |
[[HasProperty]] |
has |
оператор in |
[[Delete]] |
deleteProperty |
оператор delete |
[[Call]] |
apply |
виклик функції |
[[Construct]] |
construct |
оператор new |
[[GetPrototypeOf]] |
getPrototypeOf |
Object.getPrototypeOf |
[[SetPrototypeOf]] |
setPrototypeOf |
Object.setPrototypeOf |
[[IsExtensible]] |
isExtensible |
Object.isExtensible |
[[PreventExtensions]] |
preventExtensions |
Object.preventExtensions |
[[DefineOwnProperty]] |
defineProperty |
Object.defineProperty, Object.defineProperties |
[[GetOwnProperty]] |
getOwnPropertyDescriptor |
Object.getOwnPropertyDescriptor, for..in , Object.keys/values/entries |
[[OwnPropertyKeys]] |
ownKeys |
Object.getOwnPropertyNames, Object.getOwnPropertySymbols, for..in , Object.keys/values/entries |
JavaScript встановлює деякі інваріанти – умови, які повинні виконуватися внутрішніми методами та пастками.
Більшість із них стосуються значень, що повертаються:
[[Set]]
має повертатиtrue
, якщо значення було записано успішно, інакшеfalse
.[[Delete]]
має повертатиtrue
, якщо значення було успішно видалено, інакшеfalse
.- …і так далі, ми побачимо більше у прикладах нижче.
Є й інші інваріанти, наприклад:
[[GetPrototypeOf]]
, застосований до об’єкта проксі, має повертати те саме значення, що й[[GetPrototypeOf]]
, застосоване до цільового об’єкта проксі. Іншими словами, зчитування прототипу проксі завжди має повертати прототип цільового об’єкта.
Пастки можуть перехопити ці операції, але вони повинні дотримуватися цих правил.
Інваріанти забезпечують правильну та послідовну поведінку функцій мови. Повний список інваріантів знаходиться в специфікації. Ви, мабуть, не порушите їх, якщо не робитимете щось дивне.
Подивімося, як це працює на практичних прикладах.
Типове значення із пасткою “get”
Найпоширеніші пастки призначені для зчитування/запису властивостей.
Щоб перехопити зчитування, handler
повинен мати метод get(target, property, receiver)
.
Він запускається, коли властивість зчитується, з такими аргументами:
target
– це цільовий об’єкт, який передається як перший аргумент доnew Proxy
,property
– назва властивості,receiver
– якщо цільова властивість є геттером, тодіreceiver
є об’єктом, який буде використовуватися якthis
у його виклику. Зазвичай це сам об’єктproxy
(або об’єкт, який успадковується від нього, якщо ми успадковуємо від проксі). Наразі цей аргумент нам не потрібен, тому детальніше роз’яснимо його пізніше.
Використаймо get
для реалізації значень за замовчуванням для об’єкта.
Ми створимо числовий масив, який повертає 0
для неіснуючих значень.
Зазвичай, коли хтось намагається отримати неіснуючий елемент масиву, він отримує значення undefined
, але ми обернемо звичайний масив у проксі, який перехоплює зчитування і повертає 0
, якщо такої властивості немає:
let numbers = [0, 1, 2];
numbers = new Proxy(numbers, {
get(target, prop) {
if (prop in target) {
return target[prop];
} else {
return 0; // типове значення
}
}
});
alert( numbers[1] ); // 1
alert( numbers[123] ); // 0 (немає такого елемента)
Як ми бачимо, це досить легко зробити за допомогою пастки get
.
Ми можемо використовувати Proxy
для реалізації будь-якої логіки для “типових” значень.
Уявіть, що у нас є словник із фразами та їх перекладами:
let dictionary = {
'Hello': 'Hola',
'Bye': 'Adiós'
};
alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome'] ); // undefined
Прямо зараз, якщо фрази немає, зчитування з dictionary
повертає значення undefined
. Але на практиці, як правило, краще залишити фразу неперекладеною, ніж undefined
. Тож давайте змусимо його повертати неперекладену фразу в цьому випадку замість undefined
.
Щоб досягти цього, ми обгорнемо dictionary
у проксі, який перехоплює операції зчитування:
let dictionary = {
'Hello': 'Hola',
'Bye': 'Adiós'
};
dictionary = new Proxy(dictionary, {
get(target, phrase) { // перехоплює зчитування властивості з dictionary
if (phrase in target) { // якщо ми маємо таку в словнику
return target[phrase]; // повертаємо переклад
} else {
// інакше повертаємо неперекладену фразу
return phrase;
}
}
});
// Знайдіть у словнику довільні фрази!
// У гіршому випадку вони будуть не перекладені.
alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome to Proxy']); // Welcome to Proxy (немає перекладу)
Зверніть увагу, як проксі перезаписує змінну:
dictionary = new Proxy(dictionary, ...);
Проксі повинен повністю замінити цільовий об’єкт скрізь. Ніхто ніколи не повинен посилатися на цільовий об’єкт після того, як він був проксійований. Інакше легко заплутатися.
Валідація з пасткою “set”.
Скажімо, нам потрібен масив виключно для чисел. Якщо додано значення іншого типу, має бути помилка.
Пастка set
запускається, коли властивість записується.
set(target, property, value, receiver)
:
target
– це цільовий об’єкт, який передається як перший аргумент доnew Proxy
,property
– назва властивості,value
– значення властивості,receiver
– аналогічно пастціget
, має значення лише для властивостей сеттера.
Пастка set
повинна повертати true
, якщо налаштування є успішними, і false
в іншому випадку (викликає TypeError
).
Використаймо його для перевірки нових значень:
let numbers = [];
numbers = new Proxy(numbers, { // (*)
set(target, prop, val) { // для перехоплення запису властивості
if (typeof val == 'number') {
target[prop] = val;
return true;
} else {
return false;
}
}
});
numbers.push(1); // додано успішно
numbers.push(2); // додано успішно
alert("Довжина: " + numbers.length); // 2
numbers.push("test"); // TypeError ('set' на проксі повернула false)
alert("Цей рядок ніколи не буде досягнуто (помилка в рядку вище)");
Зверніть увагу: вбудований функціонал масивів все ще працює! Значення додаються за допомогою 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: "Іван",
age: 30,
_password: "***"
};
user = new Proxy(user, {
ownKeys(target) {
return Object.keys(target).filter(key => !key.startsWith('_'));
}
});
// "ownKeys" виключив _password
for(let key in user) alert(key); // name, потім: age
// аналогічний ефект для цих методів:
alert( Object.keys(user) ); // name,age
alert( Object.values(user) ); // Іван,30
Поки що це працює.
Хоча, якщо ми повернемо ключ, якого не існує в об’єкті, Object.keys
не виведе його в списку:
let user = { };
user = new Proxy(user, {
ownKeys(target) {
return ['a', 'b', 'c'];
}
});
alert( Object.keys(user) ); // <пусто>
Чому? Причина проста: Object.keys
повертає лише властивості з прапором enumerable
. Щоб перевірити це, він викликає внутрішній метод [[GetOwnProperty]]
для кожної властивості, щоб отримати її дескриптор. І тут, оскільки властивості немає, її дескриптор порожній, немає прапора enumerable
, тому вона пропускається.
Щоб Object.keys
повертав властивість, нам потрібно, щоб вона існувала в об’єкті з прапором enumerable
, або ми можемо перехоплювати виклики [[GetOwnProperty]]
(це робить пастка getOwnPropertyDescriptor
) , і повертати дескриптор із enumerable: true
.
Ось приклад цього:
let user = { };
user = new Proxy(user, {
ownKeys(target) { // викликається один раз, щоб отримати список властивостей
return ['a', 'b', 'c'];
},
getOwnPropertyDescriptor(target, prop) { // викликається для кожного значення
return {
enumerable: true,
configurable: true
/* ...інші прапори, ймовірне "значення:..." */
};
}
});
alert( Object.keys(user) ); // a, b, c
Зауважимо ще раз: нам потрібно перехоплювати [[GetOwnProperty]]
лише тоді, коли властивість відсутня в об’єкті.
Захищені властивості з “deleteProperty” та іншими пастками
Існує поширена домовленість, що властивості та методи з префіксом підкреслення _
є внутрішніми. До них не слід звертатися ззовні об’єкта.
Але технічно це можливо:
let user = {
name: "Іван",
_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("Доступ заборонено");
}
let value = target[prop];
return (typeof value === 'function') ? value.bind(target) : value; // (*)
},
set(target, prop, val) { // для перехоплення запису властивості
if (prop.startsWith('_')) {
throw new Error("Доступ заборонено");
} else {
target[prop] = val;
return true;
}
},
deleteProperty(target, prop) { // для перехоплення видалення властивості
if (prop.startsWith('_')) {
throw new Error("Доступ заборонено");
} else {
delete target[prop];
return true;
}
},
ownKeys(target) { // для перехоплення перебору властивостей
return Object.keys(target).filter(key => !key.startsWith('_'));
}
});
// "get" не дозволяє прочитати _password
try {
alert(user._password); // Error: Доступ заборонено
} catch(e) { alert(e.message); }
// "set" не дозволяє записати _password
try {
user._password = "test"; // Error: Доступ заборонено
} catch(e) { alert(e.message); }
// "deleteProperty" не дозволяє видалити _password
try {
delete user._password; // Error: Доступ заборонено
} catch(e) { alert(e.message); }
// "ownKeys" виключає _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) {
// метод об’єкта повинен мати можливість зчитати _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
– це список аргументів.
Наприклад, згадаймо декоратор delay(f, ms)
, який ми робили у розділі Декоратори та переадресація виклику, call/apply.
У цьому розділі ми зробили це без проксі. Виклик до delay(f, ms)
повернув функцію, яка перенаправляє всі виклики до f
через ms
мілісекунд.
Ось попередня реалізація на основі функцій:
function delay(f, ms) {
// повертає обгортку, яка передає виклик до f після тайм-ауту
return function() { // (*)
setTimeout(() => f.apply(this, arguments), ms);
};
}
function sayHi(user) {
alert(`Привіт, ${user}!`);
}
// після цього обгортання виклики sayHi будуть відкладені на 3 секунди
sayHi = delay(sayHi, 3000);
sayHi("Іван"); // Привіт, Іван! (через 3 секунди)
Як ми вже бачили, це переважно працює. Функція-обгортка (*)
виконує виклик після тайм-ауту.
Але функція-обгортка не перенаправляє операції зчитування/запису властивостей або щось інше. Після обгортання втрачається доступ до властивостей оригінальних функцій, таких як name
, length
та інших:
function delay(f, ms) {
return function() {
setTimeout(() => f.apply(this, arguments), ms);
};
}
function sayHi(user) {
alert(`Привіт, ${user}!`);
}
alert(sayHi.length); // 1 (length функції — це кількість аргументів у її оголошенні)
sayHi = delay(sayHi, 3000);
alert(sayHi.length); // 0 (в оголошенні обгортки нуль аргументів)
Proxy
набагато потужніші, оскільки вони перенаправляють все до цільового об’єкта.
Використаймо Proxy
замість функції-обгортки:
function delay(f, ms) {
return new Proxy(f, {
apply(target, thisArg, args) {
setTimeout(() => target.apply(thisArg, args), ms);
}
});
}
function sayHi(user) {
alert(`Привіт, ${user}!`);
}
sayHi = delay(sayHi, 3000);
alert(sayHi.length); // 1 (*) проксі перенаправляє операцію "get length" до цілі
sayHi("John"); // Привіт, Іван! (через 3 секунди)
Результат той самий, але тепер не тільки виклики, а й усі операції на проксі пересилаються до оригінальної функцію. Отже, sayHi.length
повертає правильне значення після обгортання в рядку (*)
.
У нас є “багатша” обгортка.
Існують й інші пастки: повний список на початку цієї статті. Схема їх використання схожа на описану вище.
Reflect
Reflect
– це вбудований об’єкт, який спрощує створення Proxy
.
Раніше було сказано, що внутрішні методи, такі як [[Get]]
, [[Set]]
та інші, призначені лише для специфікації, вони не можуть бути викликані безпосередньо.
Об’єкт Reflect
робить це певним чином можливим. Його методи – мінімальні обгортки навколо внутрішніх методів.
Ось приклади операцій і викликів Reflect
, які роблять те саме:
Операція | Виклик Reflect |
Внутрішній метод |
---|---|---|
obj[prop] |
Reflect.get(obj, prop) |
[[Get]] |
obj[prop] = value |
Reflect.set(obj, prop, value) |
[[Set]] |
delete obj[prop] |
Reflect.deleteProperty(obj, prop) |
[[Delete]] |
new F(value) |
Reflect.construct(F, value) |
[[Construct]] |
… | … | … |
Наприклад:
let user = {};
Reflect.set(user, 'name', 'Іван');
alert(user.name); // Іван
Зокрема, Reflect
дозволяє нам викликати оператори (new
, delete
…) як функції (Reflect.construct
, Reflect.deleteProperty
, …). Це цікава здатність, але тут важливо інше.
Для кожного внутрішнього методу, перехопленого Proxy
, є відповідний метод у Reflect
з тими ж іменами та аргументами, що й пастка Proxy
.
Таким чином, ми можемо використовувати Reflect
для пересилання операції до оригінального об’єкта.
У цьому прикладі обидві пастки get
і set
прозоро (наче їх не існує) перенаправляють операції зчитування/запису до об’єкта, показуючи повідомлення:
let user = {
name: "Іван",
};
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; // показує "GET name"
user.name = "Петро"; // показує "SET name=Петро"
Тут:
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: "Гість",
get name() {
return this._name;
}
};
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
return target[prop];
}
});
alert(userProxy.name); // Гість
Пастка get
тут є “прозорою”, вона повертає оригінальну властивість і більше нічого не робить. Цього достатньо для нашого прикладу.
Начебто все гаразд. Але зробімо приклад трохи складнішим.
Після успадкування іншого об’єкта admin
від user
ми можемо спостерігати неправильну поведінку:
let user = {
_name: "Гість",
get name() {
return this._name;
}
};
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
return target[prop]; // (*) target = user
}
});
let admin = {
__proto__: userProxy,
_name: "Адмін"
};
// Очікується: Адмін
alert(admin.name); // виводиться: Гість (?!?)
Зчитування admin.name
має повертати "Адмін"
, а не "Гість"
!
Що трапилось? Можливо ми зробили щось не так з успадкуванням?
Але якщо ми видалимо проксі, то все буде працювати, як очікувалося.
Проблема насправді в проксі, у рядку (*)
.
-
Коли ми читаємо
admin.name
, оскільки об’єктadmin
не має такої своєї властивості, пошук переходить до його прототипу. -
Прототипом є
userProxy
. -
Під час зчитування властивості
name
з проксі спрацьовує пасткаget
і повертає її з вихідного об’єкта якtarget[prop]
у рядку(*)
.Виклик
target[prop]
, колиprop
є геттером, запускає його код у контекстіthis=target
. Таким чином, результатом єthis._name
з оригінального об’єктаtarget
, тобто: відuser
.
Щоб виправити такі ситуації, нам потрібен receiver
, третій аргумент пастки get
. У ньому зберігається правильний this
для передачі гетеру. У нашому випадку це admin
.
Як передати контекст для геттера? Для звичайної функції ми можемо використовувати call/apply
, але це геттер, він не “викликається”, а лише доступний.
Reflect.get
може це зробити. Все буде працювати правильно, якщо ми цим скористаємося.
Ось виправлений варіант:
let user = {
_name: "Гість",
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: "Адмін"
};
alert(admin.name); // Адмін
Тепер receiver
, який зберігає посилання на правильний this
(тобто admin
), передається до геттера за допомогою Reflect.get
у рядку (*)
.
Ми можемо переписати пастку ще коротше:
get(target, prop, receiver) {
return Reflect.get(...arguments);
}
Виклики Reflect
називаються точно так само, як і пастки, і приймають ті ж самі аргументи. Вони були спеціально розроблені таким чином.
Тож, return Reflect...
забезпечує безпечну та просту переадресацію операції та оберігає від того, що ми забудемо щось, що пов’язане з цим.
Обмеження проксі
Проксі надають унікальний спосіб змінити або налаштувати поведінку існуючих об’єктів на найнижчому рівні. Все-таки це не ідеально. Існують обмеження.
Вбудовані об’єкти: внутрішні слоти
Багато вбудованих об’єктів, наприклад Map
, Set
, Date
, Promise
та інші, використовують так звані “внутрішні слоти”.
Це подібні властивості, але зарезервовані для внутрішніх цілей, призначених лише для специфікації. Наприклад, Map
зберігає елементи у внутрішньому слоті [[MapData]]
. Вбудовані методи отримують доступ до них безпосередньо, а не через внутрішні методи [[Get]]/[[Set]]
. Тому Proxy
не може перехопити це.
Чому це має значення? Вони ж все одно внутрішні!
Ну, є одна проблема. Після того, як такий вбудований об’єкт проксіюється, проксі не має цих внутрішніх слотів, тому виклики вбудованих методів призведуть до помилок.
Наприклад:
let map = new Map();
let proxy = new Proxy(map, {});
proxy.set('test', 1); // Помилка
Всередині Map
зберігає всі дані у своєму внутрішньому слоті [[MapData]]
. Проксі не має такого слота. Вбудований метод 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 (працює!)
Тепер все працює нормально, тому що пастка get
пов’язує властивості функції, такі як map.set
, із самим цільовим об’єктом (map
).
На відміну від попереднього прикладу, значення this
всередині proxy.set(...)
буде не proxy
, а оригінальним map
. Тому, коли внутрішня реалізація set
намагатиметься отримати доступ до this.[[MapData]]
внутрішнього слота, операція завершиться успішно.
Array
не має внутрішніх слотівПомітний виняток: вбудований Array
не використовує внутрішні слоти. Так склалося з історичних причин, оскільки масиви з’явилися дуже давно.
Саме тому вищевказана проблема не виникає при проксіюванні масиву.
Приватні поля
Подібне відбувається з полями приватного класу.
Наприклад, метод getName()
отримує доступ до приватної властивості #name
і перестає працювати після проксіювання:
class User {
#name = "Гість";
getName() {
return this.#name;
}
}
let user = new User();
user = new Proxy(user, {});
alert(user.getName()); // Помилка
Причина в тому, що приватні поля реалізуються за допомогою внутрішніх слотів. JavaScript не використовує [[Get]]/[[Set]]
під час доступу до них.
У виклику getName()
значенням this
є проксійований user
, і він не має слота з приватними полями.
Знову ж таки, рішення з прив’язкою методу змушує його працювати:
class User {
#name = "Гість";
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()); // Гість
Проте це рішення має недоліки, як пояснювалося раніше: воно відкриває вихідний об’єкт методу, потенційно дозволяючи його передавати далі та порушуючи інші функціональні можливості проксі.
Проксі != цільовий об’єкт
Проксі та оригінальний об’єкт – це різні об’єкти. Це природно, правда?
Отже, якщо ми використовуємо оригінальний об’єкт як ключ, а потім проксіюємо його, то проксі не буде знайдено:
let allUsers = new Set();
class User {
constructor(name) {
this.name = name;
allUsers.add(this);
}
}
let user = new User("Іван");
alert(allUsers.has(user)); // true
user = new Proxy(user, {});
alert(allUsers.has(user)); // false
Як бачимо, після проксіювання ми не можемо знайти user
у наборі allUsers
, оскільки проксі є іншим об’єктом.
===
Проксі можуть перехоплювати багато операторів, таких як new
(з construct
), in
(з has
), delete
(з deleteProperty
) тощо.
Але немає способу перехопити перевірку на сувору рівність для об’єктів. Об’єкт суворо рівний тільки самому собі, і жодному іншому значенню.
Таким чином, усі операції та вбудовані класи, які порівнюють об’єкти на рівність, відрізнятимуть об’єкт від проксі. Тут немає прозорої заміни.
Проксі, що відкликаються
Проксі, що відкликаються (revocable) — це проксі, що можна вимкнути.
Скажімо, у нас є ресурс, і ми хочемо закрити доступ до нього в будь-який момент.
Що ми можемо зробити, так це обгорнути його у проксі, що відкликається, без будь-яких пасток. Такий проксі буде пересилати операції на об’єкт, і ми можемо вимкнути його в будь-який момент.
Синтаксис такий:
let {proxy, revoke} = Proxy.revocable(target, handler)
Виклик повертає об’єкт із функціями proxy
та revoke
, щоб вимкнути його.
Ось приклад:
let object = {
data: "Важливі дані"
};
let {proxy, revoke} = Proxy.revocable(object, {});
// передати проксі десь замість об’єкта...
alert(proxy.data); // Важливі дані
// пізніше в нашому коді
revoke();
// проксі більше не працює (відкликано)
alert(proxy.data); // Помилка
Виклик revoke()
видаляє всі внутрішні посилання на цільовий об’єкт із проксі, тому вони більше не пов’язані.
Спочатку revoke
існує окремо від proxy
, тому ми можемо передавати proxy
скрізь, залишаючи revoke
у поточній області.
Ми також можемо прив’язати метод revoke
до проксі, встановивши proxy.revoke = revoke
.
Інший варіант – створити WeakMap
, який має proxy
як ключ і відповідне значення revoke
як значення, що дозволяє легко знайти revoke
для проксі:
let revokes = new WeakMap();
let object = {
data: "Важливі дані"
};
let {proxy, revoke} = Proxy.revocable(object, {});
revokes.set(proxy, revoke);
// ..ще десь у нашому коді..
revoke = revokes.get(proxy);
revoke();
alert(proxy.data); // Помилка (відкликано)
Тут ми використовуємо WeakMap
замість Map
, оскільки він не блокує збір сміття. Якщо об’єкт проксі стає “недоступним” (наприклад, жодна змінна більше не посилається на нього), WeakMap
дозволяє стерти його з пам’яті разом із його revoke
, що нам більше не знадобиться.
Посилання
– Специфікація: Proxy.
- MDN: Proxy.
Підсумки
Проксі
– це обгортка навколо об’єкта, яка перенаправляє операції над нею до об’єкта, маючи можливість перехоплювати деякі з них.
Проксіювати можна будь-який тип об’єкта, включаючи класи та функції.
Синтаксис такий:
let proxy = new Proxy(target, {
/* пастки */
});
…Тоді ми повинні використовувати proxy
скрізь замість target
. Проксі не має своїх властивостей чи методів. Він перехоплює операцію, якщо пастка передбачена, в іншому випадку пересилає її до цільового об’єкта.
Ми можемо захопити:
- Зчитування (
get
), запис (set
), видалення (deleteProperty
) властивості (навіть неіснуючої). - Виклик функції (пастка
apply
). - Оператор
new
(пасткаconstruct
). - Багато інших операцій (повний список на початку статті та в документації).
Це дозволяє нам створювати “віртуальні” властивості та методи, реалізовувати значення за замовчуванням, спостережувані об’єкти, декоратори функцій та багато іншого.
Ми також можемо обгорнути об’єкт кілька разів у різні проксі, прикрашаючи його різними аспектами функціональності.
Reflect API розроблено для доповнення Proxy. Для будь-якої пастки Proxy
є виклик Reflect
з тими самими аргументами. Ми повинні використовувати їх для переадресації викликів цільовим об’єктам.
Проксі мають деякі обмеження:
- Вбудовані об’єкти мають “внутрішні слоти”, доступ до них не може бути проксійованим. Перегляньте обхідний шлях вище.
- Те ж саме стосується полів приватного класу, оскільки вони внутрішньо реалізовані за допомогою слотів. Тому виклики методів через проксі повинні мати цільовий об’єкт як
this
для доступу до них. - Перевірки об’єктів на сувору рівність
===
не можуть бути перехоплені. - Продуктивність: контрольні показники залежать від інтерпретатора, але зазвичай доступ до властивості за допомогою найпростішого проксі займає в кілька разів більше часу. На практиці це має значення лише для деяких “особливо навантажених” об’єктів.