Короткий ответ
Стабильная форма JavaScript-объекта позволяет V8 читать поля через hidden classes и inline cache. Разный порядок полей, условное добавление свойств и особенно delete размывают этот контракт; на горячем пути это переводит доступ от monomorphic к megamorphic или dictionary mode и заметно увеличивает стоимость операции.Объекты в JS не бесплатные
_Hidden classes, inline cache и почему shape объекта важнее, чем кажется._
Продолжу про микро-оптимизаций. После Date.now() и parseInt/parseFloat — про, казалось бы, самую безобидную вещь: обычный {}.
Сразу оговорка: для 99% систем это не имеет значения. А вот если у тебя hot path, через который проходят десятки тысяч объектов в секунду, или ты пишешь библиотечный код, стоит знать, что V8 на самом деле делает с твоими объектами.
И чего он там с ними делает?
Когда ты создаёшь объект, V8 не выделяет «мешок ключей». Он строит для него hidden class (_map_ внутри V8) структуру, которая описывает layout: какие у объекта поля, в каком порядке они лежат, какого типа, по каким офсетам читать. Сам объект это компактный массив значений, как struct в C. Hidden class хранится отдельно, и каждый объект держит на него ссылку.
Hidden classes связаны между собой через transitions и вместе образуют transition tree. Когда ты добавляешь поле к объекту, V8 идёт по исходящему переходу из текущего hidden class: если переход с таким полем уже есть переключается на существующий дочерний, если нет создаёт новый hidden class и новую ветку дерева.
Если несколько объектов идут по одним и тем же переходам в одинаковом порядке они разделяют один hidden class. Это даёт V8 базу для inline cache: «по этому офсету всегда лежит string, читаем напрямую, без проверок».
Когда hidden classes начинают расходиться (по порядку, по типам, по добавлению/удалению), inline cache переходит: – monomorphic (один shape — fast path), – polymorphic (до 4 shapes — V8 проверяет каждый, всё ещё быстро), – megamorphic (4+ — V8 сдаётся, идёт через generic lookup).
Стоимость растёт на каждом шаге.
Где ты теряешь?
Классический пример постепенная сборка vs литерал:
const a = {}
a.id = 1
a.name = 'jopa'
const b = { id: 1, name: 'jopa' }
Если порядок добавления полей совпадает, оба варианта в итоге попадают на один hidden class. У литерала есть бонус V8 запоминает финальный shape как boilerplate и при повторных вызовах аллоцирует объект сразу с готовым layout, без прохода по transition tree. Но в hot loop разница в установившемся режиме копеечная.
Хуже, если порядок полей в разных фабриках не совпадает:
function makeA(id, name) { return { id, name } }
function makeB(name, id) { return { name, id } }
Это уже два разных hidden class. Любой код, который читает obj.id через эти фабрики, видит два shape на одном call site и IC становится polymorphic. Само по себе ещё не больно, но если таких разных объектов не два, а шесть, то добро пожаловать в megamorphic.
Ещё кейс условное добавление полей:
const user = { id, name }
if (isAdmin) {
user.role = 'admin'
}
Часть объектов имеет shape {id, name}, часть — {id, name, role}. Уже polymorphic. Лучше класть role: null сразу и потом присвоить значение при необходимости.
delete — отдельная история
delete user.name
Вот тут V8 уже не прощает. delete переводит объект в dictionary mode (он же slow mode, normalized properties). Это hashmap вместо фиксированного layout. Ни inline cache, ни офсетов, каждое чтение поля идёт через hash lookup в C++ runtime. И обратно V8 объект уже не вернёт, даже если форма стабилизировалась.
Вроде как память освободил. А по факту превратил объект в Map без типизации.
Если поле больше не нужно лучше присвой null или undefined. Shape сохранится, движок не потеряет в оптимизации.
А что по цифрам?
Стенд: Node 24, миллион объектов с тремя полями, в hot loop читаем obj.id, меряем ns/op. По три прогона, чтобы JIT успел устаканиться.
— literal (same shape): 3.99 нс — stepwise (same order): 4.08 нс — poly IC (2 shape): 4.65 нс — megamorphic (6 shapes): 8.51 нс — dict mode (after delete): 56.12 нс
Что видно: – literal vs stepwise — разницы практически нет. V8 одинаково хорошо справляется с обоими. – polymorphic IC (2 shape) — ~15% оверхед. V8 спокойно тянет до 4 shape на одном call site. – megamorphic (6 shape) — x2+ к чтению. Уже серьёзно. – delete (dictionary mode) — x14 медленнее. Больно :)
На 100k RPS × 50 чтений полей на запрос с dict-mode объектами вместо нормальных — потеря ~260 мс CPU/с.
А что было на Node 22?
Для наглядности — те же сценарии на Node 22:
— literal: 11.0 → 4.0 нс (x2.8) — stepwise: 11.1 → 4.1 нс (x2.7) — poly (2 shape): 10.3 → 4.7 нс (x2.2) — megamorphic: 14.1 → 8.5 нс (x1.7) — dict mode: 56.8 → 56.1 нс (=)
Что интересно:
– Fast path между релизами стал ~x2.5 быстрее. TurboFan и Maglev продолжают тюниться, монохромное чтение поля на Node 24 — 4 нс против 11 на Node 22. – Dict mode — константа. 56 нс на обеих версиях. Это C++ hashmap lookup, JIT там не играет, оптимизировать нечего. – Относительная разница fast/slow растёт. На Node 22 delete был x5 медленнее literal'а, на Node 24 — уже x14. Fast path уходит вперёд, slow path стоит.
Вывод: с каждым новым V8 сидеть в dictionary mode или megamorphic IC становится всё дороже _относительно_ правильного кода.
А что делать то?
– В hot path — литералы, со всеми полями сразу. Не знаешь значение полож null. – Поля во всех фабриках — в одном порядке. Делаешь { id, name }, делай везде { id, name }. – delete — не нужон. Только obj.field = null (или undefined). – Если объект сложный и часто создаётся — класс или фабрика. Один shape гарантированно. – Не стоит превращать один call site в универсальную точку на все случаи жизни: четыре shape потолок V8, дальше generic.
Что в итоге?
Объект в JS не «просто словарь». Это контракт с V8: ты обещаешь стабильный shape, V8 обещает быструю работу через hidden classes и inline cache. Нарушаешь — съезжаешь в polymorphic, потом megamorphic, потом dictionary mode. С каждым шагом дороже.
В обычном CRUD это не заметно. На hot path — это десятки процентов CPU и заметные хвосты по p99.
Date.now() тратил CPU на syscall, parseFloat — на парсинг, плохой shape — на cache miss. А потом говорят: "ой да ну какой Node, он же медленный, давайте напишем на Go и пойдем за ванильным лате на растительном"
Node быстрый. Просто не мешай ему.
Что еще почитать? – V8 internals: hidden classes & inline caches — Mathias Bynens, must-read – Fast properties in V8 — официальный блог V8 – Elements kinds in V8 — про массивы и их shapes