Мета и рантайм: 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. Остальные, на
случай, если строишь библиотеку.
операции (hint в скобках)
результат
выбери операциюкакая ловушка сработала
- 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.