Раздел 2 · Программирование / CS

Метапрограммирование в JS и TS: Proxy, Reflect, декораторы

middle-senior~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Метапрограммирование в JS и TS: Proxy, Reflect, декораторы

Метапрограммирование это код, который работает с кодом: читает его структуру, перехватывает операции, дописывает поведение. В JS на этом стоят ORM (Drizzle и Prisma строят запросы через перехват обращений к свойствам), реактивность Vue, моки в тестах и Immer. Этот урок собирает инструментарий: Proxy и Reflect, Symbol-протоколы, декораторы и type-level приёмы TS. Дальше он раскрывается дважды: в уроке про AST и DSL и в воркшопе про самодельную ORM, где Proxy становится несущей конструкцией.

Три вида метапрограммирования

  • Интроспекция. Читаем структуру программы, не меняя её: Object.keys, typeof, Reflect.ownKeys, instanceof. Самый безобидный вид, им пользуется любая сериализация.
  • Перехват и самоизменение. Меняем поведение операций в рантайме: Proxy перехватывает доступ к свойствам, Object.defineProperty превращает поле в геттер. На этом виде стоит весь урок.
  • Кодогенерация. Порождаем новый код: от new Function до настоящих AST-трансформаций и codemod. Этим займётся следующий урок.

Сразу граница разумного, чтобы дальше читать спокойно: магия должна жить в библиотеке, за барьером абстракции, а не размазываться по бизнес-коду. Прикладной код, в котором на каждом шагу Proxy и defineProperty, не отлаживается; библиотека с магией внутри и честным интерфейсом снаружи (тот же Immer) отлаживается прекрасно.

Proxy и Reflect

Устройство

Proxy оборачивает целевой объект (target) и перехватывает операции над ним через методы обработчика (handler), они же ловушки. Ловушек тринадцать, по группам:

ГруппаЛовушки
доступ к свойствамget, set, has, deleteProperty
дескрипторы и перечислениеownKeys, getOwnPropertyDescriptor, defineProperty
прототип и расширяемостьgetPrototypeOf, setPrototypeOf, isExtensible, preventExtensions
вызовapply, construct

Непереопределённая ловушка по умолчанию пробрасывает операцию на target.

Прозрачный прокси и зачем нужен Reflect

У каждой ловушки есть одноимённый метод Reflect, и правильный проброс выглядит так:

const logged = <T extends object>(target: T): T =>
  new Proxy(target, {
    get(target, prop, receiver) {
      console.warn('get', prop);
      return Reflect.get(target, prop, receiver);
    },
    set(target, prop, value, receiver) {
      console.warn('set', prop, value);
      return Reflect.set(target, prop, value, receiver);
    },
  });

Почему Reflect.get(target, prop, receiver), а не просто target[prop]? Из-за геттеров и this. Если у target есть геттер, который внутри читает this.other, то при target[prop] этот геттер получит this равный target, и чтение other пройдёт мимо прокси, часть операций станет невидимой. Reflect.get с receiver пробрасывает правильный this (сам прокси), и геттер остаётся под наблюдением. Правило: в ловушке всегда пробрасывай через Reflect с полным набором аргументов, тогда прокси прозрачен.

Пример: валидация на set

type Settings = { port: number; host: string };

const validated = (settings: Settings) =>
  new Proxy(settings, {
    set(target, prop, value, receiver) {
      if (prop === 'port' && (typeof value !== 'number' || value < 1 || value > 65_535)) {
        throw new TypeError(`Некорректный порт: ${String(value)}`);
      }
      return Reflect.set(target, prop, value, receiver);
    },
  });

const config = validated({ port: 4321, host: 'localhost' });
config.port = 99_999; // TypeError, инвариант защищён

Узнаёшь инкапсуляцию из урока про ООП? Прокси это ещё один способ поставить все изменения состояния в одну проверяемую точку, не меняя интерфейс объекта.

Инварианты: про что Proxy не может врать

Прокси не всесилен: язык требует, чтобы ответы ловушек не противоречили неизменяемым фактам о target. Если свойство non-configurable, ловушка ownKeys обязана его показать, а get обязан вернуть его настоящее значение. Нарушение это TypeError в момент операции:

const target = {};
Object.defineProperty(target, 'id', { value: 1, configurable: false });

const lying = new Proxy(target, {
  ownKeys: () => [], // скрываем non-configurable свойство
});

Object.keys(lying); // TypeError: ownKeys обязан показать id

Зачем движку эта строгость: на дескрипторах строятся гарантии самого языка (Object.freeze, безопасность встроенных объектов), и прокси не должен уметь их подделывать. Первое домашнее упражнение воспроизводит этот TypeError руками.

Где Proxy в проде и сколько стоит

  • Реактивность Vue 3: reactive(obj) это Proxy, ловушки get и set собирают зависимости и запускают обновления. Подробно в уроке про Vue.
  • Immer: draft, по которому ты «мутируешь», это Proxy, записывающий изменения для copy-on-write. Разбор в уроке про персистентные структуры.
  • ORM: db.users без объявленного поля users это ловушка get, лениво создающая query-builder. Ровно это ты соберёшь в воркшопе ORM.

Цена: операция через прокси заметно дороже прямого доступа к свойству (движок не может её соптимизировать так же агрессивно), поэтому прокси не место в горячем цикле. И отдельный инструмент на случай, когда доступ нужно уметь отозвать: Proxy.revocable возвращает пару из прокси и функции revoke, после вызова которой любые операции кидают TypeError.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.