Раздел 3 · JavaScript

Мета и рантайм: Proxy, Reflect, V8 под капотом

middle-senior~60 мин

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

Мета и рантайм: Proxy, Reflect, V8 под капотом

Это финальный урок JS-серии. До сих пор мы смотрели на язык глазами программиста: какие есть типы, как работают функции, что такое объект, как итерироваться по коллекции, как обрабатывать ошибки. Теперь пора посмотреть на JavaScript с двух других сторон. Со стороны стандарта, который описывает протоколы поверх языка. И со стороны движка, который этот язык исполняет.

Сверху язык даёт инструменты, чтобы говорить на нём о самом себе. Symbol помечает протоколы, Proxy перехватывает любые операции с объектом, Reflect зеркалит эти операции и упрощает написание прокси, новые декораторы дают синтаксис для оборачивания методов и классов. Это слой метапрограммирования. Фреймворки вроде Vue, Solid и MobX построены именно на нём, и без понимания этого слоя их магия выглядит магией, а не инженерным решением.

Снизу у языка есть среда исполнения. У V8 (движка Node.js, Bun, Chrome и Edge) три компилятора: Ignition, Maglev и TurboFan. Между ними прячется идея hidden classes, от которой зависит, как быстро объект читается. Сборщик мусора решает, когда освободить память и сколько на это потратить пауз. Альтернативные движки, JavaScriptCore в Safari и SpiderMonkey в Firefox, делают то же самое чуть иначе, и на уровне продакшна полезно знать, чем именно отличаются.

Урок проходит оба слоя по очереди. Сначала Symbol и well-known protocols, Proxy, Reflect, декораторы, ESNext recap. Потом V8 под капотом: pipeline компиляции, hidden classes, inline caches, GC, deopt-трейс. И в конце короткое сравнение с альтернативами и обзор stage-3 предложений TC39, которые скоро войдут в язык.

1. Symbol

Symbol, седьмой примитив. Уникальный, неперечислимый по умолчанию, не пересекается со строковыми ключами.

const s1 = Symbol('id');
const s2 = Symbol('id');

s1 === s2;       // false, описание это только метка
s1.description;  // 'id'
typeof s1;       // 'symbol'

// Symbol как ключ объекта
const KEY = Symbol('private');
const obj = { [KEY]: 'hidden' };

obj[KEY];                    // 'hidden'
Object.keys(obj);            // [] (Symbol-ключи не в keys())
Object.getOwnPropertySymbols(obj); // [Symbol(private)]
JSON.stringify(obj);         // '{}' (JSON их игнорирует)

Symbol.for('id') создаёт shared symbol в глобальном реестре (одинаковый при повторном for с тем же ключом). В большинстве проектов не нужен.

1.1. Well-known symbols

Это Symbol-константы, на которые опирается язык. Реализуй один из них на своём объекте, и язык начнёт обращаться с ним по-новому:

SymbolЧто меняет
Symbol.iteratorобъект становится iterable (см. урок 05)
Symbol.asyncIteratorобъект становится async iterable
Symbol.toPrimitiveконтролирует приведение к примитиву (+, ==, “)
Symbol.hasInstanceконтролирует поведение x instanceof Y
Symbol.toStringTagподменяет вывод Object.prototype.toString
Symbol.speciesзадаёт конструктор для производных в map/filter

Пример с toPrimitive:

class Money {
  constructor(kop) { this.kop = kop; }
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.kop / 100;
    if (hint === 'string') return `${this.kop / 100} EUR`;
    return this.kop;
  }
}

const m = new Money(1500);
+m;            // 15      hint 'number'
`${m}`;        // '15 EUR' hint 'string'
m + '';        // '1500'   hint 'default'

В быту well-known symbols трогают редко: Symbol.iterator для итерируемых типов (см. 05-collections-iteration-protocols), Symbol.toStringTag для красивого вывода в DevTools. Остальные, на случай, если строишь библиотеку.

Well-known Symbols: выбери класс и операцию

операции (hint в скобках)

результат

выбери операцию

какая ловушка сработала

  1. trace появится после операции

Well-known symbols это якоря, на которых стоит язык: реализуй Symbol.toPrimitive на своём классе, и +m,m + '', `$${m}` начнут спрашивать у тебя, в какой примитив привести объект. Symbol.toStringTag подменяет вывод Object.prototype.toString.call(...). Сравни три класса: у Money и Duration +m возвращает разные числа, потому что их toPrimitive('number') возвращает разное.

2. Proxy

Proxy, обёртка вокруг объекта, перехватывающая базовые операции. Создаётся с двумя аргументами: target (исходный объект) и handler (объект с ловушками).

const target = { name: 'Bat', age: 25 };

const handler = {
  get(obj, prop, receiver) {
    console.log(`get ${String(prop)}`);
    return Reflect.get(obj, prop, receiver);
  },
  set(obj, prop, value, receiver) {
    console.log(`set ${String(prop)} = ${value}`);
    return Reflect.set(obj, prop, value, receiver);
  },
};

const proxy = new Proxy(target, handler);

proxy.name;        // logs 'get name', returns 'Bat'
proxy.age = 26;    // logs 'set age = 26'
target.age;        // 26 (proxy писал в исходный target)

Полный список ловушек:

ЛовушкаЧто перехватывает
get(t, prop, recv)t.prop
set(t, prop, val, recv)t.prop = val
has(t, prop)prop in t
deleteProperty(t, prop)delete t.prop
apply(t, this, args)t(...args)
construct(t, args, newTarget)new t(...args)
getPrototypeOf(t)Object.getPrototypeOf(t)
setPrototypeOf(t, p)Object.setPrototypeOf(t, p)
defineProperty(t, prop, descriptor)Object.defineProperty(t, ...)
getOwnPropertyDescriptor(t, prop)Object.getOwnPropertyDescriptor
ownKeys(t)Object.keys, for...in, Object.getOwnPropertyNames
preventExtensions(t)Object.preventExtensions
isExtensible(t)Object.isExtensible

2.1. Применения

СлучайЧто перехватываем
Реактивный observable (Vue, MobX, signals)set для отслеживания мутаций
Auto-API клиент (api.users.list())get для динамической генерации методов
Validation (zod-style на чтении)set с проверкой типа
Sandboxing (запретить доступ к process)get/has с фильтром
Lazy-loading тяжёлого объектаget, который материализует данные при первом обращении
Mock-объекты в тестахпроизвольный handler

Простой observable:

function reactive(obj, onChange) {
  return new Proxy(obj, {
    set(t, prop, val, recv) {
      const ok = Reflect.set(t, prop, val, recv);
      if (ok) onChange(prop, val);
      return ok;
    },
  });
}

const state = reactive({ count: 0 }, (k, v) => console.log('changed:', k, v));
state.count = 1;   // 'changed: count 1'

Auto-API:

function makeApi(baseUrl) {
  return new Proxy({}, {
    get(_, method) {
      return (path, body) => fetch(`${baseUrl}${path}`, {
        method: String(method).toUpperCase(),
        body: body ? JSON.stringify(body) : undefined,
      }).then((r) => r.json());
    },
  });
}

const api = makeApi('https://api.example.com');
api.get('/users');                      // GET /users
api.post('/users', { name: 'Bat' });    // POST /users

2.2. Reflect

Reflect, парный API. Все его методы соответствуют ловушкам Proxy и дают штатный способ выполнить операцию изнутри handler-а:

Reflect.get(obj, prop, receiver);
Reflect.set(obj, prop, value, receiver);
Reflect.has(obj, prop);
Reflect.deleteProperty(obj, prop);
Reflect.ownKeys(obj);
Reflect.construct(Cls, args);
Reflect.apply(fn, thisArg, args);

Зачем: внутри Proxy-handler ты часто хочешь “просто сделать как обычно, но с логом”. Reflect.get(target, prop, receiver) делает именно это, правильно учитывая receiver (важно для getter-ов).

Proxy и Reflect вместе дают то, чего не умеет Object.freeze: представление, через которое объект можно только читать, пока его хозяин продолжает его менять. Собери такое сам, глубокое и с одинаковыми ссылками на вложенные объекты. Тесты гоняются в браузере.

2.3. Стоимость

Proxy не бесплатен: каждая операция проходит через handler. На горячем пути это даёт 2-10x slowdown в зависимости от сложности handler-а.

Не ставь Proxy в hot path без замера. В реактивных фреймворках (Vue 3, Solid через signals) Proxy окупается интерфейсом, но фреймворк ещё и сильно оптимизирует обходы.

3. Decorators

С ES2023 в стандарте новый синтаксис декораторов (TC39 stage 3 на момент 2026, но уже с native-поддержкой в Bun, в TypeScript 5.0+, в Babel-плагине).

function logged(value, context) {
  if (context.kind === 'method') {
    return function (...args) {
      console.log(`call ${context.name}(${args.join(', ')})`);
      return value.call(this, ...args);
    };
  }
}

class Calculator {
  @logged
  add(a, b) { return a + b; }
}

new Calculator().add(2, 3);   // 'call add(2, 3)' \n 5

Синтаксис: @decorator перед методом, полем, классом или accessor-ом. Декоратор, функция с двумя аргументами:

  • value, то, что декорируется (метод, класс, init-функция);
  • context, метаданные (kind, name, static, private, addInitializer).

В TypeScript / Angular / Nest до этого был “legacy” синтаксис (tsconfig: experimentalDecorators). Новый стандартный, не совместим с ним. Для нового кода используй стандартный, legacy остаётся для старых проектов.

Подробно про decorator-применения (memoize, validate, route, inject) в разделе 08 (08-frameworks), там их используют каркасы NestJS/Mikro-ORM.

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

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

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