Skip to main content

Що таке 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

Швидкий приклад

javascript
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

javascript
// Неправильно: p - це сам proxy, тому p[prop] знову запускає get trap const p = new Proxy({}, { get(target, prop) { return p[prop]; // нескінченна рекурсія } }); p.x; // RangeError: Maximum call stack size exceeded
javascript
// Правильно: використовуй target, а не змінну proxy const p = new Proxy({}, { get(target, prop, receiver) { return Reflect.get(target, prop, receiver); } });

2. Пропущений аргумент receiver ламає виклики методів

javascript
// Неправильно: без receiver `this` не вказуватиме на proxy у спадкованих методах const p = new Proxy(obj, { get(target, prop) { return target[prop]; } });
javascript
// Правильно: передай receiver, щоб зберегти `this` через ланцюжок прототипів const p = new Proxy(obj, { get(target, prop, receiver) { return Reflect.get(target, prop, receiver); } });

3. Пряма мутація target всередині trap обходить set trap

javascript
// Неправильно: пряма мутація 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 з оригіналом через ===

javascript
const original = { x: 1 }; const proxy = new Proxy(original, {}); console.log(proxy === original); // false - це різні об'єкти

Proxy створює окрему обгортку. Строге порівняння завжди повертає false. Зберігай посилання на оригінал окремо, якщо потрібно порівнювати ідентичність.

5. Забутий виклик revoke

javascript
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 і рекурсивно повтори логіку. Тоді кожен запис на будь-якій глибині проходить валідацію, а не тільки верхній рівень.

Приклади

Базовий: логування доступу до властивостей

javascript
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.

javascript
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 напряму не чіпається.

Просунутий: відкличний доступ до чутливих даних

javascript
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 стає доступним для збирача сміття, якщо немає інших посилань на нього.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?