Допустим, в приложении есть объект с данными пользователя, и с ним нужно сделать три вещи: записывать в журнал каждое изменение, не пускать в него заведомо неправильные значения и перерисовывать страницу, когда что-то поменялось. Можно написать отдельную функцию на каждое поле и следить, чтобы никто не менял объект в обход неё. А можно поставить перед объектом посредника, через которого будут проходить все обращения. В JavaScript такой посредник называется Proxy, а его верный помощник — Reflect.
Разберём оба инструмента с нуля: что это, из каких частей состоит, зачем они нужны вместе и где пригодятся в обычном фронтенде.
Proxy — посредник между кодом и объектом
Удобно представить секретаря в приёмной. Посетители не заходят к директору напрямую: все просьбы идут через секретаря. Он может записать визит в журнал, отказать, если человек пришёл не по делу, или просто передать просьбу дальше. Директор при этом не меняется — меняется только путь к нему.
Proxy работает так же. Он оборачивает обычный объект и получает право вмешаться в любую операцию с ним: чтение поля, запись, удаление, проверку через in. Создаётся он конструктором с двумя аргументами:
const user = { name: 'Мария', city: 'Lviv' };
const rules = {};
const userProxy = new Proxy(user, rules);
console.log(userProxy.name); // 'Мария'
userProxy.city = 'Warsaw';
console.log(user.city); // 'Warsaw'
Здесь три термина, которые встретятся дальше на каждом шагу:
- target (цель) — исходный объект, который оборачивают. В примере это user.
- handler (обработчик) — объект с правилами, второй аргумент. В примере это rules.
- ловушка (trap) — метод внутри handler, который срабатывает на определённую операцию. Метод get ловит чтение, set — запись и так далее.
Пока handler пустой, прокси полностью прозрачен: всё, что делается с userProxy, без изменений уходит в user. Именно поэтому запись в userProxy.city поменяла поле у исходного объекта. Прокси не копирует данные, он только стоит на пути к ним.
Самая распространенная ловушка: get
Добавим в handler метод get. Теперь каждое чтение поля через прокси сначала попадёт в него:
const product = { title: 'Наушники', price: 120 };
const productProxy = new Proxy(product, {
get(target, prop) {
console.log(`Кто-то читает поле ${prop}`);
return target[prop];
}
});
productProxy.price;
// Кто-то читает поле price
Ловушка получает исходный объект и имя поля, а то, что она вернёт, и станет результатом чтения. Если забыть return, любое поле будет читаться как undefined. Строка return target[prop] здесь работает, но у неё есть слабое место — к нему вернёмся в разделе про Reflect.
Какие операции можно перехватить
Ловушек в спецификации тринадцать, но в повседневной работе хватает нескольких:
| Ловушка | Когда срабатывает | Пример операции |
| get | чтение поля | proxy.name |
| set | запись поля | proxy.name = 'Олег' |
| has | проверка оператором in | 'name' in proxy |
| deleteProperty | удаление поля | delete proxy.name |
| ownKeys | получение списка ключей | Object.keys(proxy) |
| apply | вызов, если обёрнута функция | proxy(1, 2) |
| construct | вызов через new | new proxy() |
Например, ловушки has и deleteProperty вместе позволяют спрятать служебные поля, которые по договорённости начинаются с подчёркивания:
const account = { login: 'maria', _token: 'a1b2c3' };
const safeAccount = new Proxy(account, {
has(target, prop) {
if (prop.startsWith('_')) return false;
return prop in target;
},
deleteProperty(target, prop) {
if (prop.startsWith('_')) return false; // в строгом режиме delete выбросит TypeError
return delete target[prop];
}
});
console.log('_token' in safeAccount); // false
console.log('login' in safeAccount); // true
Ловушки, которых нет в handler, работают по умолчанию. Поэтому прокси выше по-прежнему читает и записывает поля как обычный объект — изменились только in и delete.
Reflect — передать операцию дальше без самодеятельности
Внутри ловушки почти всегда нужно сделать две вещи: добавить что-то своё (записать в журнал, проверить значение) и затем выполнить исходную операцию так, как её выполнил бы сам объект. Для второй части и существует Reflect.
Это не конструктор и не класс, а просто встроенный объект с набором методов. Главное, что про них нужно запомнить: для каждой ловушки Proxy есть метод Reflect с тем же именем и теми же аргументами.
const book = { title: 'Чистый код' };
Reflect.get(book, 'title'); // как book.title
Reflect.set(book, 'year', 2008); // как book.year = 2008, вернёт true
Reflect.has(book, 'title'); // как 'title' in book
Reflect.deleteProperty(book, 'year'); // как delete book.year, вернёт true
Reflect.ownKeys(book); // ['title'], включая ключи-символы
Благодаря совпадению имён шаблон ловушки получается однотипным: сначала своя логика, потом вызов одноимённого метода Reflect с теми же аргументами.
const handler = {
get(target, prop, receiver) {
console.log(`Чтение: ${String(prop)}`);
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`Запись: ${String(prop)} = ${value}`);
return Reflect.set(target, prop, value, receiver);
}
};
Обёртка String(prop) здесь не для красоты. Имя поля в ловушке — это строка или символ (Symbol), а символ нельзя напрямую подставить в шаблонную строку: получится TypeError. Встроенные механизмы вроде перебора через for...of обращаются к полям-символам, так что такое чтение рано или поздно пройдёт через ловушку. Чем ещё Reflect лучше ручного target[prop], видно на двух случаях ниже.
Ловушка set обязана вернуть true
Ловушка set должна сообщить, удалась ли запись: вернуть true, если всё в порядке. Если вернуть false или ничего не вернуть, в строгом режиме присваивание выбросит ошибку. А строгий режим сегодня включён почти везде: в ES-модулях и классах он работает автоматически. Подробнее о нём — в статье о директиве use strict.
'use strict';
const settings = new Proxy({}, {
set(target, prop, value) {
target[prop] = value; // значение записали, но забыли return
}
});
settings.theme = 'dark';
// TypeError: 'set' on proxy: trap returned falsish for property 'theme'
Reflect.set сам возвращает true или false, поэтому строка return Reflect.set(...) закрывает этот вопрос раз и навсегда.
Третий аргумент receiver и геттеры
У ловушек get и set есть ещё один аргумент — receiver. Обычно это сам прокси, то есть объект, через который пришло обращение. Нужен он для геттеров — свойств, которые вычисляются на лету и внутри используют this:
const employee = {
firstName: 'Мария',
lastName: 'Коваль',
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
};
const viaTarget = new Proxy(employee, {
get(target, prop) {
console.log(`Чтение: ${String(prop)}`);
return target[prop];
}
});
const viaReflect = new Proxy(employee, {
get(target, prop, receiver) {
console.log(`Чтение: ${String(prop)}`);
return Reflect.get(target, prop, receiver);
}
});
viaTarget.fullName;
// Чтение: fullName
viaReflect.fullName;
// Чтение: fullName
// Чтение: firstName
// Чтение: lastName
В первом прокси геттер вызывается на исходном объекте, и this внутри него указывает на employee. Чтения firstName и lastName идут мимо прокси, и журнал их не видит. Reflect.get с третьим аргументом запускает геттер с this, равным прокси, и все вложенные чтения тоже проходят через ловушку. Для журнала это значит неполные записи, а для реактивного интерфейса — страницу, которая не обновится. Если тема this пока плавает, её подробно разбирает мануал о this в вызове метода.
// Короткая запись того же самого: передать все аргументы как есть
get(target, prop, receiver) {
return Reflect.get(...arguments);
}
Три задачи, которые Proxy решает на практике
Журнал чтений и записей
Самое простое применение — отладка. Обернув объект с настройками, можно увидеть, какой код и в каком порядке трогает его поля. Удобно вынести обёртку в функцию, чтобы подключать журнал к любому объекту:
function withLog(obj, label) {
return new Proxy(obj, {
get(target, prop, receiver) {
console.log(`[${label}] чтение ${String(prop)}`);
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`[${label}] запись ${String(prop)} = ${value}`);
return Reflect.set(target, prop, value, receiver);
}
});
}
const prefs = withLog({ theme: 'light', fontSize: 16 }, 'prefs');
prefs.theme = 'dark';
// [prefs] запись theme = dark
С массивами журнал показывает то, что обычно скрыто. Метод push под капотом делает две записи: сначала кладёт элемент по новому индексу, потом обновляет длину. Заодно видно, что индексы приходят в ловушку строками:
const tags = withLog([], 'tags');
tags.push('css');
// [tags] чтение push
// [tags] чтение length
// [tags] запись 0 = css
// [tags] запись length = 1
Проверка значений перед записью
Ловушка set срабатывает до того, как значение попадёт в объект, а значит, её можно использовать как фильтр. Правила проверки удобно держать в отдельном объекте — так они собраны в одном месте, а не разбросаны по коду формы:
const checks = {
age: (value) => Number.isInteger(value) && value >= 0 && value <= 120,
email: (value) => typeof value === 'string' && value.includes('@')
};
const profile = new Proxy({}, {
set(target, prop, value, receiver) {
const check = checks[prop];
if (check && !check(value)) {
throw new RangeError(`Недопустимое значение для ${prop}: ${value}`);
}
return Reflect.set(target, prop, value, receiver);
}
});
profile.age = 34; // записалось
profile.age = -5; // RangeError: Недопустимое значение для age: -5
profile.nickname = 42; // правила нет — записалось без проверки
Ошибка прерывает присваивание, и в объекте остаётся предыдущее значение. Чтобы показать её пользователю, а не уронить скрипт, запись оборачивают в try...catch:
try {
profile.age = Number(ageInput.value);
message.textContent = 'Сохранено';
} catch (error) {
message.textContent = error.message;
}
Такая проверка не заменяет валидацию на сервере: данные из браузера всё равно нужно перепроверять там. Её задача — не дать неправильному значению расползтись по интерфейсу.
Страница, которая обновляется сама
Раз ловушка знает о каждой записи, она может не только проверять значение, но и сразу обновить интерфейс. Свяжем поля состояния с элементами страницы через атрибут data-bind:
<p>Привет, <span data-bind="username">гость</span>!</p>
<p>В корзине: <span data-bind="cartCount">0</span></p>
const state = new Proxy({ username: 'гость', cartCount: 0 }, {
set(target, prop, value, receiver) {
const ok = Reflect.set(target, prop, value, receiver);
document.querySelectorAll(`[data-bind="${prop}"]`).forEach((el) => {
el.textContent = value;
});
return ok;
}
});
state.username = 'Мария'; // текст на странице поменялся
state.cartCount += 1; // и здесь тоже
Код, который меняет данные, больше не думает о DOM: он просто пишет в state, а обновлением занимается одно место. querySelectorAll выбран не случайно: если элемента с нужным атрибутом нет, он вернёт пустой список, и ловушка ничего не сломает. Вариант с getElementById(prop).innerText упал бы на первом же поле без пары в разметке.
На этой идее построена реактивность во Vue 3 — как она устроена там, рассказано в статье о реактивности во Vue.js. Учебный вариант выше следит только за полями первого уровня: запись в state.address.city пройдёт мимо, потому что вложенный объект сам не обёрнут. Фреймворки решают это, оборачивая вложенные объекты в прокси при первом чтении.
Подводные камни
Прокси и исходный объект — разные объекты
Сравнение userProxy === user даёт false. А главное, изменения напрямую в исходном объекте проходят мимо ловушек: никакой проверки, никакого журнала, никакого обновления страницы.
const raw = { count: 0 };
const watched = withLog(raw, 'counter');
watched.count = 1; // [counter] запись count = 1
raw.count = 2; // тишина — ловушка не сработала
console.log(watched.count); // [counter] чтение count, затем 2
Правило простое: после создания прокси работать только с ним, а ссылку на исходный объект никуда не отдавать. Удобнее всего создавать объект прямо внутри new Proxy({ ... }, handler), как в примерах выше.
Map, Set, Date и приватные поля ломаются
Некоторые встроенные объекты хранят данные не в обычных полях, а во внутренних слотах — скрытых ячейках, до которых добраться можно только через сам объект. Так устроены Map, Set, Date, а также приватные поля классов с решёткой. Когда метод вызывается через прокси, this внутри него указывает на прокси, а у прокси этих слотов нет:
const cache = new Proxy(new Map(), {});
cache.set('user', 'Мария');
// TypeError: Method Map.prototype.set called on incompatible receiver #<Map>
class Wallet {
#balance = 100;
getBalance() { return this.#balance; }
}
const wallet = new Proxy(new Wallet(), {});
wallet.getBalance();
// TypeError: Cannot read private member #balance from an object whose class did not declare it
Обходной путь — в ловушке get привязывать методы к исходному объекту, чтобы они выполнялись на нём:
const cache = new Proxy(new Map(), {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, target);
return typeof value === 'function' ? value.bind(target) : value;
}
});
cache.set('user', 'Мария');
console.log(cache.get('user')); // 'Мария'
Цена обхода в том, что вызовы cache.set(...) теперь не проходят через ловушку set — это вызов метода, а не запись поля. Чтобы следить за изменениями Map, нужно перехватывать сами методы, и именно так поступают библиотеки реактивности.
Скорость и неочевидность
- Каждая операция становится вызовом функции. Для объекта настроек это незаметно, но массив на десятки тысяч элементов, который перебирается в цикле анимации, через прокси заметно замедлится. Горячие данные лучше не оборачивать или оборачивать уже готовый результат.
- Поведение не видно со стороны. Кто читает profile.age = -5, не ждёт исключения от простого присваивания. Обёртку стоит создавать в одной функции с понятным именем вроде withLog или createValidatedProfile, а не разбрасывать new Proxy по коду.
- Прокси может врать. Ловушки has и ownKeys способны показать совсем не то, что лежит в объекте. Как это сказывается на проверках «пустой ли объект», разобрано в главе мануала о преобразовании объекта в булевое значение.
Когда Proxy не нужен
Если следить нужно за одним-двумя полями, проще обычные геттер и сеттер или функция вроде updateUsername(). Чтобы объект нельзя было менять вообще, есть Object.freeze(). Proxy выигрывает там, где правила общие для любого поля, в том числе для тех, которых при создании объекта ещё не было: журнал, проверки по таблице правил, синхронизация с интерфейсом.
Поддержка браузерами
Proxy:
Reflect:
Оба инструмента вошли в стандарт ES2015. Полноценного полифила для Proxy нет: перехват любых операций старыми средствами языка не сымитировать, а частичные заменители умеют ловить только чтение и запись заранее известных полей. Поэтому библиотеки, которым нужна поддержка совсем старых браузеров, обходятся без прокси — так было, например, во Vue 2.
Итог
- Proxy — обёртка над объектом, которая перехватывает операции с ним. Исходный объект — target, правила — handler, каждое правило — ловушка.
- Ловушки, которых нет в handler, работают как обычно. Чаще всего нужны get и set.
- Reflect выполняет исходную операцию: у него есть метод на каждую ловушку с тем же именем и аргументами. Шаблон ловушки — своя логика, затем return Reflect.метод(...).
- Reflect.set сам возвращает нужный true, а receiver в Reflect.get не даёт геттерам обойти прокси.
- Практические задачи — журнал изменений, проверка значений до записи и интерфейс, который обновляется при изменении данных.
- Работать нужно только с прокси, а не с исходным объектом. Map, Set, Date и приватные поля требуют привязки методов к target.
Комментарии (0)