Допустим, в приложении есть объект с данными пользователя, и с ним нужно сделать три вещи: записывать в журнал каждое изменение, не пускать в него заведомо неправильные значения и перерисовывать страницу, когда что-то поменялось. Можно написать отдельную функцию на каждое поле и следить, чтобы никто не менял объект в обход неё. А можно поставить перед объектом посредника, через которого будут проходить все обращения. В 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:

Поддержка браузерами
chrome
Chrome
49
firefox
Firefox
18
edge
Edge
12
safari
Safari
10
opera
Opera
36

Reflect:

Поддержка браузерами
chrome
Chrome
49
firefox
Firefox
42
edge
Edge
12
safari
Safari
10
opera
Opera
36

Оба инструмента вошли в стандарт ES2015. Полноценного полифила для Proxy нет: перехват любых операций старыми средствами языка не сымитировать, а частичные заменители умеют ловить только чтение и запись заранее известных полей. Поэтому библиотеки, которым нужна поддержка совсем старых браузеров, обходятся без прокси — так было, например, во Vue 2.

Итог

  • Proxy — обёртка над объектом, которая перехватывает операции с ним. Исходный объект — target, правила — handler, каждое правило — ловушка.
  • Ловушки, которых нет в handler, работают как обычно. Чаще всего нужны get и set.
  • Reflect выполняет исходную операцию: у него есть метод на каждую ловушку с тем же именем и аргументами. Шаблон ловушки — своя логика, затем return Reflect.метод(...).
  • Reflect.set сам возвращает нужный true, а receiver в Reflect.get не даёт геттерам обойти прокси.
  • Практические задачи — журнал изменений, проверка значений до записи и интерфейс, который обновляется при изменении данных.
  • Работать нужно только с прокси, а не с исходным объектом. Map, Set, Date и приватные поля требуют привязки методов к target.