Що таке Proxy Object в JavaScript
Proxy object - це вбудований конструктор JavaScript, який обгортає цільовий об'єкт і перехоплює операції над ним через handler з опціональними trap-методами.
Теорія
TL;DR
- Уяви прохідну охорону: кожне читання, запис чи видалення проходить через твою trap-функцію перш ніж потрапити до цільового об'єкта
- Головна різниця від
Object.defineProperty: Proxy перехоплює ВСІ операції над БУДЬ-ЯКОЮ властивістю, навіть тими, що ще не існують; defineProperty працює тільки зі статично відомими ключами Proxy.revocable()повертає{proxy, revoke}; після викликуrevoke()кожна операція через цей proxy одразу кидає помилку- V8-бенчмарки показують, що Proxy в 2-5 разів повільніший за прямий доступ до властивостей; не використовуй у tight loops
- Завжди передавай
receiverу методиReflectвсередині trap, щоб зберегти правильний контекстthis
Швидкий приклад
const user = { name: 'Alice', age: 25 };
const proxy = new Proxy(user, {
get(target, prop, receiver) {
console.log(`Accessing ${prop}`);
return Reflect.get(target, prop, receiver); // зберігаємо receiver для коректного this
}
});
console.log(proxy.name); // Accessing name -> Alice
proxy.age; // Accessing ageТретій аргумент receiver важливий, коли методи використовують this. Без нього виклики через ланцюжок прототипів ламаються так, що важко зрозуміти чому.
Proxy проти Object.defineProperty
Object.defineProperty патчить одну конкретну властивість на момент написання коду. Він не перехоплює оператор in, for...in, delete або властивості, додані пізніше. Proxy працює на рівні об'єкта: один handler покриває всі операції над target, включно з динамічними ключами та майбутніми властивостями.
Саме через це Vue 3 замінив підхід на основі Object.defineProperty (з Vue 2) на Proxy-реактивність. Старий підхід не бачив присвоювань за індексом у масивах і нових властивостей. Proxy ловить обидва випадки.
Коли використовувати
- Логування кожного читання та запису під час розробки: trap
getіset - Валідація вхідних даних для об'єктів, структура яких змінюється під час виконання: trap
set - Read-only обгортка над об'єктом: trap
setіdeletePropertyкидають помилку - Тимчасовий доступ до чутливих даних:
Proxy.revocable()зsetTimeoutнаrevoke - Реактивний стан у Vue 3 або MobX
Одне спостереження з продакшену: Proxy-валідація, яка випадково опинилась всередині циклу рендеру React, викликала помітні просідання FPS. Профайлер одразу показав причину, але це хороший нагадувач: накладні витрати Proxy ростуть лінійно з кількістю викликів.
Як JavaScript-рушій обробляє Proxy
V8 зберігає прихований слот handler у кожному екземплярі Proxy. Коли ти читаєш proxy.prop, V8 перевіряє, чи є в handler trap get. Якщо є, викликає твою функцію з (target, prop, receiver). Якщо trap відсутній, V8 передає виклик до Reflect.get(target, prop, receiver) як поведінку за замовчуванням. Та сама логіка діє для set, deleteProperty, has, ownKeys, apply, construct та інших фундаментальних операцій зі специфікації.
Revocable proxy (відкличний proxy) зберігає окрему функцію revoke, яка обнуляє слот handler при виклику. Після цього будь-яка операція через proxy одразу кидає TypeError без можливості відновлення.
Типові помилки
1. Використання змінної proxy всередині власного trap
// Неправильно: p - це сам proxy, тому p[prop] знову запускає get trap
const p = new Proxy({}, {
get(target, prop) {
return p[prop]; // нескінченна рекурсія
}
});
p.x; // RangeError: Maximum call stack size exceeded// Правильно: використовуй target, а не змінну proxy
const p = new Proxy({}, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
}
});2. Пропущений аргумент receiver ламає виклики методів
// Неправильно: без receiver `this` не вказуватиме на proxy у спадкованих методах
const p = new Proxy(obj, {
get(target, prop) {
return target[prop];
}
});// Правильно: передай receiver, щоб зберегти `this` через ланцюжок прототипів
const p = new Proxy(obj, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
}
});3. Пряма мутація target всередині trap обходить set trap
// Неправильно: пряма мутація target не запускає set trap
const p = new Proxy({ count: 0 }, {
get(target, prop) {
target.count++; // set trap не спрацьовує
return target[prop];
}
});Якщо потрібно записати значення з trap, використовуй Reflect.set(target, prop, value, receiver), щоб повний ланцюжок trap залишався активним.
4. Порівняння proxy з оригіналом через ===
const original = { x: 1 };
const proxy = new Proxy(original, {});
console.log(proxy === original); // false - це різні об'єктиProxy створює окрему обгортку. Строге порівняння завжди повертає false. Зберігай посилання на оригінал окремо, якщо потрібно порівнювати ідентичність.
5. Забутий виклик revoke
const secret = { apiKey: 'xyz-123' };
const { proxy, revoke } = Proxy.revocable(secret, {});
// Без revoke() proxy тримає secret доступним нескінченно
setTimeout(revoke, 1000);
console.log(proxy.apiKey); // xyz-123 (до revoke)
// Після revoke: TypeError: Cannot perform 'get' on a proxy that has been revokedДе зустрічається в реальних проектах
- Vue 3: замінив
Object.definePropertyна Proxy для реактивного стану, щоб відловлювати мутації масивів і нові властивості - MobX: observable-об'єкти використовують Proxy trap для відстеження читань і записів та запуску реакцій
- Immer: обгортає чорновик стану в Proxy, щоб ти міг писати мутації, які виробляють незмінні оновлення
- Zustand: використовує Proxy для інспекції змін стану в devtools
- Node.js vm2: пісочниця запускає код всередині відкличних Proxy, що обмежують доступ до об'єктів хоста
Follow-up питання
Q: Яка повна сигнатура trap get?
A: get(target, property, receiver). target - обгорнутий об'єкт, property - ключ, до якого звертаються, receiver - сам proxy або об'єкт, що ініціював пошук через ланцюжок прототипів.
Q: Як Proxy перехоплює цикл for...in?
A: Через trap ownKeys. Поверни порожній масив і for...in не побачить жодної властивості. Без trap цикл працює звично з target.
Q: Що відбувається після виклику revoke()?
A: Слот handler обнулюється. Кожна наступна операція через proxy одразу кидає TypeError. Відновити proxy неможливо.
Q: Чому Proxy повільніший за прямий доступ до властивостей?
A: Кожна перехоплена операція проходить через додатковий виклик функції та внутрішній пошук handler у V8. Накладні витрати складають приблизно 2-5x порівняно з прямим читанням властивості. У звичайній логіці додатку це не відчутно; у hot paths відчутно.
Q: Як реалізувати глибокий (deep) валідаційний Proxy? (рівень senior)
A: У trap set перевіряй, чи є вхідне значення об'єктом або масивом. Якщо так, перед присвоєнням обгорни його ще одним Proxy і рекурсивно повтори логіку. Тоді кожен запис на будь-якій глибині проходить валідацію, а не тільки верхній рівень.
Приклади
Базовий: логування доступу до властивостей
const config = { debug: true, timeout: 3000 };
const trackedConfig = new Proxy(config, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
console.log(`[config] read "${prop}" = ${value}`);
return value;
},
set(target, prop, value, receiver) {
console.log(`[config] set "${prop}" = ${value}`);
return Reflect.set(target, prop, value, receiver);
}
});
trackedConfig.timeout; // [config] read "timeout" = 3000
trackedConfig.debug = false; // [config] set "debug" = falseОбидва trap делегують до Reflect з повною сигнатурою. Target залишається чистим; всі побічні ефекти живуть у handler.
Середній: валідація стану в рантаймі
Цей патерн схожий на те, що Zustand використовує у своєму devtools middleware.
const state = {
todos: [{ id: 1, text: 'Buy milk', done: false }]
};
const validatedState = new Proxy(state, {
set(target, prop, value, receiver) {
if (prop === 'todos') {
if (!Array.isArray(value)) throw new TypeError('todos must be an array');
value.forEach(todo => {
if (typeof todo.text !== 'string') throw new TypeError('todo.text must be a string');
});
}
return Reflect.set(target, prop, value, receiver);
}
});
validatedState.todos = [{ id: 2, text: 'Walk dog', done: false }]; // OK
validatedState.todos = [{ id: 3, text: 123, done: false }]; // TypeError: todo.text must be a stringВалідація запускається при кожному присвоєнні todos. Оригінальний об'єкт state напряму не чіпається.
Просунутий: відкличний доступ до чутливих даних
function createTemporaryAccess(data, durationMs) {
const { proxy, revoke } = Proxy.revocable(data, {
get(target, prop, receiver) {
console.log(`[access] reading ${String(prop)}`);
return Reflect.get(target, prop, receiver);
}
});
setTimeout(revoke, durationMs);
return proxy;
}
const credentials = createTemporaryAccess({ apiKey: 'xyz-123' }, 2000);
console.log(credentials.apiKey); // [access] reading apiKey -> xyz-123
setTimeout(() => {
try {
console.log(credentials.apiKey);
} catch (e) {
console.log(e.message); // Cannot perform 'get' on a proxy that has been revoked
}
}, 3000);Після revoke() змінна credentials все ще існує в пам'яті, але будь-яка операція через неї кидає помилку. Оригінальний об'єкт data стає доступним для збирача сміття, якщо немає інших посилань на нього.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.