Главная / Статьи / Понимание JavaScript Proxy: ловушки, метод Reflect и реактивные паттерны

Понимание JavaScript Proxy: ловушки, метод Reflect и реактивные паттерны

Узнайте, как объекты Proxy и Reflect в JavaScript перехватывают доступ к свойствам для реализации механизмов проверки, виртуальных свойств и реактивных фреймворков.

1096 слов

Каждый раз, когда вы пишете obj.property или присваиваете ему значение с помощью obj.property = value, вы используете операции, которые JavaScript выполняет незаметно, без встроенных способов отслеживания или изменения происходящего внутри. Объект Proxy полностью устраняет это предположение. Он позволяет обернуть целевой объект и перехватывать эти основные операции — чтение, запись, удаление — до того, как они вступят в силу. Именно этот механизм перехвата является настоящим двигателем многих тех функций, которые кажутся «магией» в современных библиотеках и инструментах JavaScript.

Обертка, способная перехватывать всё

Proxy находится между вашим кодом и объектом, который он оборачивает, причем набор функций-ловушек определяет, что на самом деле происходит при выполнении каждого типа операций над этим объектом.

const config = { retries: 3, timeout: 5000 };

const guardedConfig = new Proxy(config, {
  set(target, key, value) {
    if (key === "retries" && (typeof value !== "number" || value < 0)) {
      throw new TypeError("retries must be a non-negative number");
    }
    target[key] = value;
    return true;
  },
});

guardedConfig.retries = 5;   // works fine
guardedConfig.retries = -1;  // throws TypeError, caught before it ever reaches the object

Ничто в строке guardedConfig.retries = 5 не выглядит иначе, чем обычное присваивание свойства. Именно в этом и заключается суть такого подхода: перехват остается незаметным в месте вызова. Вы можете добавлять проверки, логирование или другие побочные эффекты к тому, что выглядит как обычный доступ к свойству, не заставляя менять код, который фактически использует объект.

Механизм, лежащий в основе реактивных фреймворков

function reactive(target, onChange) {
  return new Proxy(target, {
    get(obj, key) {
      return obj[key];
    },
    set(obj, key, value) {
      const changed = obj[key] !== value;
      obj[key] = value;
      if (changed) onChange(key, value);
      return true;
    },
  });
}

const state = reactive({ count: 0 }, (key, value) => {
  console.log(`${key} changed to ${value}, re-rendering...`);
});

state.count = 1; // "count changed to 1, re-rendering..." — no explicit call needed

Запись state.count = 1 выглядит как совершенно обычное присваивание, поскольку синтаксически оно и есть таковым. Однако значение придаёт специальная ловушка типа set, превращающая эту незаметную строку в событие, на которое может отреагировать остальная часть системы. Именно это является основой реактивных фреймворков. Нет никакого специального типа данных «наблюдаемых» значениях, который нужно было бы использовать везде. Речь идёт о простых объектах, обернутых в Proxy, который бесшумно фиксирует любые изменения, внесённые в них.

Виртуальные свойства: значения, существующие только по запросу

Ловушка типа get может возвращать значение, которое на самом деле никогда не хранилось в исходном объекте, что позволяет предоставлять вычисляемые или производные значения как обычные свойства.

const product = { name: "Desk Lamp", priceCents: 3499 };

const productView = new Proxy(product, {
  get(target, key) {
    if (key === "priceDollars") {
      return (target.priceCents / 100).toFixed(2);
    }
    return target[key];
  },
});

console.log(productView.priceDollars); // "34.99" — computed on read, not stored anywhere

В объекте product вовсе нет поля priceDollars. Оно генерируется динамически каждый раз при обращении к нему внутри трэпа get. Это существенно отличается от геттера, написанного с использованием get внутри класса или объектного литерала, поскольку трэп Proxy может перехватывать доступ к ключам, которые вообще не были заранее объявлены. Это полезно, например, когда нужно обернуть ответ API дополнительными вычисленными полями, не меняя исходный контент.

Почему Reflect часто используется вместе с Proxy

Есть нюанс, который путает новичков при первом написании трэпа, требующего перехода к стандартному поведению. Простой способ реализации легко приводит к скрытым ошибкам.

const handler = {
  get(target, key) {
    console.log(`reading ${key}`);
    return target[key]; // works, but loses some edge-case correctness
  },
};

Для простых объектов этот подход в основном работает, но более безопасным и правильным способом перенаправления операции на её стандартное поведение является использование Reflect. Он предоставляет функцию для каждой ситуации, точно воспроизводящую операцию, сохраняющую правильную привязку this и корректно обрабатывающую более сложные случаи, такие как наследуемые геттеры.

const handler = {
  get(target, key, receiver) {
    console.log(`reading ${key}`);
    return Reflect.get(target, key, receiver);
  },
};

Вызов Reflect.get(target, key, receiver) воспроизводит точно тот же результат, что и обычный доступ к свойству, включая крайние случаи с прототипами и геттерами, зависящими от this, а также ситуации, когда target[key] может незаметно вернуть неверный результат. В практическом коде с Proxy почти всегда принято вызывать соответствующий метод Reflect изнутри каждой ловушки, если только вы не собираетесь намеренно изменить стандартное поведение, поскольку именно эта версия гарантированно соответствует тому, что обычно происходит при обычном доступе к свойству.

Ловушки, ограничивающие, а не расширяющие

Ловушки не только полезны для добавления поведения, но также могут легко его устранить. Ловушка has контролирует то, что возвращает оператор in, а ловушка deleteProperty может полностью запретить удаление.

const secureRecord = new Proxy(
  { id: 1, ssn: "123-45-6789" },
  {
    get(target, key) {
      if (key === "ssn") throw new Error("Direct access to ssn is not allowed");
      return Reflect.get(target, key);
    },
    deleteProperty() {
      throw new Error("Deleting fields is not allowed on this record");
    },
  }
);

console.log(secureRecord.id); // 1
console.log(secureRecord.ssn); // throws
delete secureRecord.id; // throws

Такой вид защиты существенно отличается от приватного поля класса: его можно применить к любому объекту, без необходимости того, чтобы этот объект изначально был определен как класс, и он позволяет задавать узкие правила для каждого ключа, которые простое использование атрибута #private выразить не может.

Одна идея, множество ловушек

Каждая «ловушка» на самом деле отвечает на один и тот же вопрос: что должно произойти в момент, когда код пытается выполнить эту повседневную операцию над данным объектом? Чтение значения, запись значения, удаление элемента, проверка наличия ключа — всё это обычно является невидимыми, автоматическими действиями, а Proxy превращает каждое из них в момент, который можно наблюдать или перенаправлять. Системы реактивного состояния, обёртки для валидации, слои контроля доступа, виртуальные вычисляемые поля — ни один из этих инструментов не является отдельным трюком, взятым из другого источника. Все они построены на одном и том же основном механизме, просто направленном на разную «ловушку».

Связанная литература