Вичерпний посібник з JavaScript Proxy API. Навчіться перехоплювати та налаштовувати операції з об'єктами за допомогою практичних прикладів, сценаріїв використання та порад щодо продуктивності.
JavaScript Proxy API: Глибоке занурення в модифікацію поведінки об'єктів
У динамічному світі сучасного JavaScript розробники постійно шукають більш потужні та елегантні способи керування даними та взаємодії з ними. Хоча такі функції, як класи, модулі та async/await, революціонізували наш спосіб написання коду, існує потужна функція метапрограмування, представлена в ECMAScript 2015 (ES6), яка часто залишається недооціненою: Proxy API.
Метапрограмування може звучати лячно, але це просто концепція написання коду, який оперує іншим кодом. Proxy API є основним інструментом JavaScript для цього, дозволяючи створювати "проксі" для іншого об'єкта, який може перехоплювати та перевизначати фундаментальні операції для цього об'єкта. Це схоже на встановлення настроюваного "охоронця" перед об'єктом, що дає вам повний контроль над доступом до нього та його зміною.
Цей вичерпний посібник розвіє міфи навколо Proxy API. Ми розглянемо його основні концепції, розберемо різноманітні можливості на практичних прикладах, а також обговоримо просунуті сценарії використання та аспекти продуктивності. До кінця ви зрозумієте, чому проксі є наріжним каменем сучасних фреймворків і як ви можете використовувати їх для написання чистішого, потужнішого та легшого в обслуговуванні коду.
Розуміння основних концепцій: Ціль, Обробник та Пастки
Proxy API побудований на трьох фундаментальних компонентах. Розуміння їхніх ролей є ключем до опанування проксі.
- Ціль (Target): Це оригінальний об'єкт, який ви хочете обгорнути. Це може бути будь-який об'єкт, включаючи масиви, функції або навіть інший проксі. Проксі віртуалізує цю ціль, і всі операції зрештою (хоча й не обов'язково) перенаправляються до неї.
- Обробник (Handler): Це об'єкт, який містить логіку для проксі. Це об'єкт-заповнювач, властивостями якого є функції, відомі як "пастки". Коли над проксі виконується операція, він шукає відповідну пастку в обробнику.
- Пастки (Traps): Це методи в обробнику, які забезпечують доступ до властивостей. Кожна пастка відповідає фундаментальній операції над об'єктом. Наприклад, пастка
get
перехоплює читання властивості, а пасткаset
— запис властивості. Якщо пастка не визначена в обробнику, операція просто перенаправляється до цілі, ніби проксі не існувало.
Синтаксис для створення проксі простий:
const proxy = new Proxy(target, handler);
Розглянемо дуже простий приклад. Ми створимо проксі, який просто передає всі операції до цільового об'єкта, використовуючи порожній обробник.
// Оригінальний об'єкт
const target = {
message: "Hello, World!"
};
// Порожній обробник. Усі операції будуть перенаправлені до цілі.
const handler = {};
// Об'єкт проксі
const proxy = new Proxy(target, handler);
// Доступ до властивості через проксі
console.log(proxy.message); // Вивід: Hello, World!
// Операцію було перенаправлено до цілі
console.log(target.message); // Вивід: Hello, World!
// Зміна властивості через проксі
proxy.anotherMessage = "Hello, Proxy!";
console.log(proxy.anotherMessage); // Вивід: Hello, Proxy!
console.log(target.anotherMessage); // Вивід: Hello, Proxy!
У цьому прикладі проксі поводиться точно так само, як і оригінальний об'єкт. Справжня сила з'являється, коли ми починаємо визначати пастки в обробнику.
Анатомія проксі: вивчення поширених пасток
Об'єкт-обробник може містити до 13 різних пасток, кожна з яких відповідає фундаментальному внутрішньому методу об'єктів JavaScript. Розглянемо найпоширеніші та найкорисніші з них.
Пастки доступу до властивостей
1. `get(target, property, receiver)`
Це, мабуть, найчастіше використовувана пастка. Вона спрацьовує, коли відбувається читання властивості проксі.
target
: Оригінальний об'єкт.property
: Ім'я властивості, до якої відбувається доступ.receiver
: Сам проксі або об'єкт, який від нього успадковується.
Приклад: Значення за замовчуванням для неіснуючих властивостей.
const user = {
firstName: 'John',
lastName: 'Doe',
age: 30
};
const userHandler = {
get(target, property) {
// Якщо властивість існує в цілі, повернути її.
// Інакше повернути повідомлення за замовчуванням.
return property in target ? target[property] : `Властивість '${property}' не існує.`;
}
};
const userProxy = new Proxy(user, userHandler);
console.log(userProxy.firstName); // Вивід: John
console.log(userProxy.age); // Вивід: 30
console.log(userProxy.country); // Вивід: Властивість 'country' не існує.
2. `set(target, property, value, receiver)`
Пастка set
викликається, коли властивості проксі присвоюється значення. Вона ідеально підходить для валідації, логування або створення об'єктів, доступних лише для читання.
value
: Нове значення, що присвоюється властивості.- Пастка повинна повертати логічне значення:
true
, якщо присвоєння було успішним, іfalse
в іншому випадку (що призведе доTypeError
у суворому режимі).
Приклад: Валідація даних.
const person = {
name: 'Jane Doe',
age: 25
};
const validationHandler = {
set(target, property, value) {
if (property === 'age') {
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new TypeError('Вік повинен бути цілим числом.');
}
if (value <= 0) {
throw new RangeError('Вік повинен бути додатним числом.');
}
}
// Якщо валідація пройдена, встановлюємо значення для цільового об'єкта.
target[property] = value;
// Позначаємо успіх.
return true;
}
};
const personProxy = new Proxy(person, validationHandler);
personProxy.age = 30; // Це коректно
console.log(personProxy.age); // Вивід: 30
try {
personProxy.age = 'thirty'; // Генерує TypeError
} catch (e) {
console.error(e.message); // Вивід: Вік повинен бути цілим числом.
}
try {
personProxy.age = -5; // Генерує RangeError
} catch (e) {
console.error(e.message); // Вивід: Вік повинен бути додатним числом.
}
3. `has(target, property)`
Ця пастка перехоплює оператор in
. Вона дозволяє контролювати, які властивості вважаються існуючими в об'єкті.
Приклад: Приховування "приватних" властивостей.
У JavaScript поширеною практикою є префікс для приватних властивостей у вигляді підкреслення (_). Ми можемо використовувати пастку has
, щоб приховати їх від оператора in
.
const secretData = {
_apiKey: 'xyz123abc',
publicKey: 'pub456def',
id: 1
};
const hidingHandler = {
has(target, property) {
if (property.startsWith('_')) {
return false; // Вдаємо, що її не існує
}
return property in target;
}
};
const dataProxy = new Proxy(secretData, hidingHandler);
console.log('publicKey' in dataProxy); // Вивід: true
console.log('_apiKey' in dataProxy); // Вивід: false (хоча вона є в цілі)
console.log('id' in dataProxy); // Вивід: true
Примітка: Це впливає лише на оператор in
. Прямий доступ, як-от dataProxy._apiKey
, все одно працюватиме, якщо ви також не реалізуєте відповідну пастку get
.
4. `deleteProperty(target, property)`
Ця пастка виконується, коли властивість видаляється за допомогою оператора delete
. Вона корисна для запобігання видаленню важливих властивостей.
Пастка повинна повертати true
для успішного видалення або false
для невдалого.
Приклад: Запобігання видаленню властивостей.
const immutableConfig = {
databaseUrl: 'prod.db.server',
port: 8080
};
const deletionGuardHandler = {
deleteProperty(target, property) {
if (property in target) {
console.warn(`Спроба видалити захищену властивість: '${property}'. Операцію відхилено.`);
return false;
}
return true; // Властивість і так не існувала
}
};
const configProxy = new Proxy(immutableConfig, deletionGuardHandler);
delete configProxy.port;
// Вивід у консоль: Спроба видалити захищену властивість: 'port'. Операцію відхилено.
console.log(configProxy.port); // Вивід: 8080 (Вона не була видалена)
Пастки перелічення та опису об'єктів
5. `ownKeys(target)`
Ця пастка спрацьовує при операціях, які отримують список власних властивостей об'єкта, таких як Object.keys()
, Object.getOwnPropertyNames()
, Object.getOwnPropertySymbols()
та Reflect.ownKeys()
.
Приклад: Фільтрація ключів.
Поєднаймо це з нашим попереднім прикладом "приватних" властивостей, щоб повністю їх приховати.
const secretData = {
_apiKey: 'xyz123abc',
publicKey: 'pub456def',
id: 1
};
const keyHidingHandler = {
has(target, property) {
return !property.startsWith('_') && property in target;
},
ownKeys(target) {
return Reflect.ownKeys(target).filter(key => !key.startsWith('_'));
},
get(target, property, receiver) {
// Також запобігаємо прямому доступу
if (property.startsWith('_')) {
return undefined;
}
return Reflect.get(target, property, receiver);
}
};
const fullProxy = new Proxy(secretData, keyHidingHandler);
console.log(Object.keys(fullProxy)); // Вивід: ['publicKey', 'id']
console.log('publicKey' in fullProxy); // Вивід: true
console.log('_apiKey' in fullProxy); // Вивід: false
console.log(fullProxy._apiKey); // Вивід: undefined
Зверніть увагу, що ми використовуємо тут Reflect
. Об'єкт Reflect
надає методи для перехоплюваних операцій JavaScript, і його методи мають ті ж імена та сигнатури, що й пастки проксі. Використання Reflect
для перенаправлення оригінальної операції до цілі є найкращою практикою, що забезпечує коректне збереження поведінки за замовчуванням.
Пастки для функцій та конструкторів
Проксі не обмежуються звичайними об'єктами. Коли ціллю є функція, ви можете перехоплювати виклики та створення екземплярів.
6. `apply(target, thisArg, argumentsList)`
Ця пастка викликається, коли виконується проксі функції. Вона перехоплює виклик функції.
target
: Оригінальна функція.thisArg
: Контекстthis
для виклику.argumentsList
: Список аргументів, переданих у функцію.
Приклад: Логування викликів функцій та їхніх аргументів.
function sum(a, b) {
return a + b;
}
const loggingHandler = {
apply(target, thisArg, argumentsList) {
console.log(`Виклик функції '${target.name}' з аргументами: ${argumentsList}`);
// Виконуємо оригінальну функцію з правильним контекстом та аргументами
const result = Reflect.apply(target, thisArg, argumentsList);
console.log(`Функція '${target.name}' повернула: ${result}`);
return result;
}
};
const proxiedSum = new Proxy(sum, loggingHandler);
proxiedSum(5, 10);
// Вивід у консоль:
// Виклик функції 'sum' з аргументами: 5,10
// Функція 'sum' повернула: 15
7. `construct(target, argumentsList, newTarget)`
Ця пастка перехоплює використання оператора new
на проксі класу або функції.
Приклад: Реалізація патерну "Одинак" (Singleton).
class MyDatabaseConnection {
constructor(url) {
this.url = url;
console.log(`Підключення до ${this.url}...`);
}
}
let instance;
const singletonHandler = {
construct(target, argumentsList) {
if (!instance) {
console.log('Створення нового екземпляра.');
instance = Reflect.construct(target, argumentsList);
}
console.log('Повернення існуючого екземпляра.');
return instance;
}
};
const ProxiedConnection = new Proxy(MyDatabaseConnection, singletonHandler);
const conn1 = new ProxiedConnection('db://primary');
// Вивід у консоль:
// Створення нового екземпляра.
// Підключення до db://primary...
// Повернення існуючого екземпляра.
const conn2 = new ProxiedConnection('db://secondary'); // URL буде проігноровано
// Вивід у консоль:
// Повернення існуючого екземпляра.
console.log(conn1 === conn2); // Вивід: true
console.log(conn1.url); // Вивід: db://primary
console.log(conn2.url); // Вивід: db://primary
Практичні сценарії використання та просунуті патерни
Тепер, коли ми розглянули окремі пастки, подивимося, як їх можна поєднати для вирішення реальних проблем.
1. Абстракція API та трансформація даних
API часто повертають дані у форматі, який не відповідає угодам вашого застосунку (наприклад, snake_case
проти camelCase
). Проксі може прозоро обробляти це перетворення.
function snakeToCamel(s) {
return s.replace(/(_\w)/g, (m) => m[1].toUpperCase());
}
// Уявімо, що це наші сирі дані з API
const apiResponse = {
user_id: 123,
first_name: 'Alice',
last_name: 'Wonderland',
account_status: 'active'
};
const camelCaseHandler = {
get(target, property) {
const camelCaseProperty = snakeToCamel(property);
// Перевіряємо, чи існує версія в camelCase напряму
if (camelCaseProperty in target) {
return target[camelCaseProperty];
}
// Повертаємося до оригінального імені властивості
if (property in target) {
return target[property];
}
return undefined;
}
};
const userModel = new Proxy(apiResponse, camelCaseHandler);
// Тепер ми можемо отримувати доступ до властивостей, використовуючи camelCase, хоча вони зберігаються у snake_case
console.log(userModel.userId); // Вивід: 123
console.log(userModel.firstName); // Вивід: Alice
console.log(userModel.accountStatus); // Вивід: active
2. Спостерігачі (Observables) та прив'язка даних (Ядро сучасних фреймворків)
Проксі є двигуном реактивних систем у сучасних фреймворках, таких як Vue 3. Коли ви змінюєте властивість на проксі-об'єкті стану, пастка set
може використовуватися для запуску оновлень в UI або інших частинах застосунку.
Ось дуже спрощений приклад:
function createObservable(target, callback) {
const handler = {
set(obj, prop, value) {
const result = Reflect.set(obj, prop, value);
callback(prop, value); // Викликати колбек при зміні
return result;
}
};
return new Proxy(target, handler);
}
const state = {
count: 0,
message: 'Hello'
};
function render(prop, value) {
console.log(`ЗМІНУ ВИЯВЛЕНО: Властивості '${prop}' було присвоєно '${value}'. Перерендерінг UI...`);
}
const observableState = createObservable(state, render);
observableState.count = 1;
// Вивід у консоль: ЗМІНУ ВИЯВЛЕНО: Властивості 'count' було присвоєно '1'. Перерендерінг UI...
observableState.message = 'Goodbye';
// Вивід у консоль: ЗМІНУ ВИЯВЛЕНО: Властивості 'message' було присвоєно 'Goodbye'. Перерендерінг UI...
3. Від'ємні індекси масиву
Класичний і цікавий приклад — розширення нативної поведінки масиву для підтримки від'ємних індексів, де -1
посилається на останній елемент, подібно до таких мов, як Python.
function createNegativeArrayProxy(arr) {
const handler = {
get(target, property) {
const index = Number(property);
if (!Number.isNaN(index) && index < 0) {
// Перетворюємо від'ємний індекс на додатний, рахуючи з кінця
property = String(target.length + index);
}
return Reflect.get(target, property);
}
};
return new Proxy(arr, handler);
}
const originalArray = ['a', 'b', 'c', 'd', 'e'];
const proxiedArray = createNegativeArrayProxy(originalArray);
console.log(proxiedArray[0]); // Вивід: a
console.log(proxiedArray[-1]); // Вивід: e
console.log(proxiedArray[-2]); // Вивід: d
console.log(proxiedArray.length); // Вивід: 5
Аспекти продуктивності та найкращі практики
Хоча проксі неймовірно потужні, вони не є чарівною паличкою. Важливо розуміти їхні наслідки.
Накладні витрати на продуктивність
Проксі вводить додатковий рівень абстракції. Кожна операція над проксі-об'єктом повинна пройти через обробник, що додає невеликі накладні витрати порівняно з прямою операцією над звичайним об'єктом. Для більшості застосунків (наприклад, валідація даних або реактивність на рівні фреймворку) ці витрати незначні. Однак у критично важливому для продуктивності коді, наприклад, у щільному циклі, що обробляє мільйони елементів, це може стати вузьким місцем. Завжди проводьте тестування продуктивності, якщо вона є головним пріоритетом.
Інваріанти проксі
Пастка не може повністю "брехати" про природу цільового об'єкта. JavaScript застосовує набір правил, які називаються "інваріантами", яким повинні підкорятися пастки проксі. Порушення інваріанту призведе до TypeError
.
Наприклад, інваріант для пастки deleteProperty
полягає в тому, що вона не може повернути true
(вказуючи на успіх), якщо відповідна властивість на цільовому об'єкті є неконфігурованою. Це запобігає тому, щоб проксі стверджував, що він видалив властивість, яку не можна видалити.
const target = {};
Object.defineProperty(target, 'unbreakable', { value: 10, configurable: false });
const handler = {
deleteProperty(target, prop) {
// Це порушить інваріант
return true;
}
};
const proxy = new Proxy(target, handler);
try {
delete proxy.unbreakable; // Це спричинить помилку
} catch (e) {
console.error(e.message);
// Вивід: 'deleteProperty' on proxy: returned true for non-configurable property 'unbreakable'
}
Коли варто (і не варто) використовувати проксі
- Добре підходять для: Створення фреймворків та бібліотек (наприклад, управління станом, ORM), відлагодження та логування, реалізації надійних систем валідації та створення потужних API, які абстрагують базові структури даних.
- Варто розглянути альтернативи для: Критичних до продуктивності алгоритмів, простих розширень об'єктів, де буде достатньо класу або фабричної функції, або коли вам потрібно підтримувати дуже старі браузери без підтримки ES6.
Відкличні проксі
Для сценаріїв, де може знадобитися "вимкнути" проксі (наприклад, з міркувань безпеки або управління пам'яттю), JavaScript надає Proxy.revocable()
. Він повертає об'єкт, що містить і проксі, і функцію revoke
.
const target = { data: 'sensitive' };
const handler = {};
const { proxy, revoke } = Proxy.revocable(target, handler);
console.log(proxy.data); // Вивід: sensitive
// Тепер ми відкликаємо доступ проксі
revoke();
try {
console.log(proxy.data); // Це спричинить помилку
} catch (e) {
console.error(e.message);
// Вивід: Cannot perform 'get' on a proxy that has been revoked
}
Проксі в порівнянні з іншими техніками метапрограмування
До появи проксі розробники використовували інші методи для досягнення подібних цілей. Корисно розуміти, як проксі співвідносяться з ними.
`Object.defineProperty()`
Object.defineProperty()
модифікує об'єкт безпосередньо, визначаючи геттери та сеттери для конкретних властивостей. Проксі, з іншого боку, зовсім не змінюють оригінальний об'єкт; вони його обгортають.
- Область видимості: `defineProperty` працює для кожної властивості окремо. Ви повинні визначити геттер/сеттер для кожної властивості, яку хочете відстежувати. Пастки
get
таset
у проксі є глобальними, перехоплюючи операції над будь-якою властивістю, включаючи нові, додані пізніше. - Можливості: Проксі можуть перехоплювати ширший спектр операцій, як-от
deleteProperty
, операторin
та виклики функцій, чого `defineProperty` робити не може.
Висновок: Сила віртуалізації
JavaScript Proxy API — це більше, ніж просто хитра функція; це фундаментальна зміна в тому, як ми можемо проєктувати об'єкти та взаємодіяти з ними. Дозволяючи нам перехоплювати та налаштовувати фундаментальні операції, проксі відкривають двері у світ потужних патернів: від безшовної валідації та трансформації даних до реактивних систем, що лежать в основі сучасних користувацьких інтерфейсів.
Хоча вони мають невелику ціну у продуктивності та набір правил, яких слід дотримуватися, їхня здатність створювати чисті, слабко зв'язані та потужні абстракції є неперевершеною. Віртуалізуючи об'єкти, ви можете створювати системи, які є більш надійними, легкими в обслуговуванні та виразними. Наступного разу, коли ви зіткнетеся зі складною проблемою, пов'язаною з управлінням даними, валідацією чи спостереженням, подумайте, чи не є проксі правильним інструментом для цієї роботи. Цілком можливо, що це буде найелегантніше рішення у вашому інструментарії.