Открываешь сайт, кликаешь по кнопке — и что-то происходит: разворачивается меню, отправляется форма, прилетает новое сообщение. Всё это делает JavaScript. Но сам по себе JavaScript — это всего лишь текст в файле, набор букв. Чтобы из букв получилось действие, кто-то должен этот текст прочитать и выполнить, шаг за шагом.
Этого «кого-то» называют движком (engine). Движок — это отдельная программа на C++ внутри браузера, и у каждого браузера он свой. В Chrome и Node.js работает V8, в Firefox — SpiderMonkey, в Safari — JavaScriptCore. Названия разные, задача одна и та же: взять ваш JS-код и реально его исполнить.
И вот важная граница, без которой дальше всё запутается. Движок умеет ровно одно — исполнять JavaScript. А привычные вещи вроде fetch, setTimeout, document.querySelector и кликов по кнопкам — это не движок, а окружение (runtime): сам браузер или Node.js, внутри которых движок живёт. Аналогия: движок — это мотор автомобиля, а руль, колёса и фары — это уже автомобиль вокруг мотора. Двигаться по дороге без них тоже не получится, но это уже другая история.
Про то, как окружение раскидывает задачи по очереди (это называется event loop), у нас есть отдельная статья — «Как на самом деле работает однопоточность в JavaScript». А здесь заглянем внутрь самого движка: разберёмся, из каких пяти кирпичиков он собран и что делает каждый.

Парсер: превращает текст в дерево
Когда мы пишем let x = 1 + 2, для нас это понятный кусочек кода. Для движка — просто строка символов: l, e, t, пробел, x, пробел, и так далее. Чтобы с этой строкой что-то делать, её сначала надо понять.
Этим занимается парсер. Работает в два шага.
Шаг 1 — токенизация. Текст режется на «слова»: ключевое слово let, имя x, оператор =, число 1, плюс, число 2, точка с запятой. Каждое такое «слово» называется токен.
Шаг 2 — построение AST. AST расшифровывается как Abstract Syntax Tree — абстрактное синтаксическое дерево. Звучит сложно, но идея простая: токены складываются в структуру, где видно, кто чьим родителем является.
Сравните строку и её AST для let x = 1 + 2;:
{
"type": "VariableDeclaration",
"kind": "let",
"declarations": [
{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "x" },
"init": {
"type": "BinaryExpression",
"operator": "+",
"left": { "type": "Literal", "value": 1 },
"right": { "type": "Literal", "value": 2 }
}
}
]
}
Зачем дерево, а не оставить как строку? Со строкой невозможно работать программно: для движка одинаково выглядят валидный let x = 1 + 2 и сломанный l3t = 1 +;. С деревом всё понятно: его можно пройти, проверить и, как мы увидим дальше, превратить в инструкции для процессора.
Хочется потыкать AST вживую — открой astexplorer.net, вставь свой кусок кода в левую панель, AST появится справа.
Интерпретатор: исполняет байткод
AST красивое, но компьютер всё ещё не знает, как его «запустить». Процессор понимает только машинный код — набор очень простых инструкций вида «возьми число из ячейки A, прибавь число из ячейки B, положи в ячейку C».
Можно было бы сразу перевести AST в машинный код — это называется компиляция. Так делают C++, Rust, Go. Но есть нюанс: компиляция долгая. Пока компилируешь — пользователь смотрит на пустую страницу. Для языка веба это не годится.
Поэтому движок выбирает компромисс. Он быстро переводит AST в байткод — упрощённый язык инструкций. Это не машинный код, но и не AST. Что-то посередине: понятно компьютеру, но всё ещё абстрактно от железа.
Кусок движка, который исполняет байткод, называется интерпретатор. В V8 он называется Ignition.
Аналогия. AST — это рецепт салата на словах: «посолить по вкусу». Машинный код — пошаговая инструкция роботу-повару: «вращение шпинделя 1200 об/мин, добавить 1.7 г соли». Байткод — рецепт-чеклист: «достать соль, насыпать чайную ложку, перемешать». И человеку понятно, и кухонному роботу — и переписывать быстро.
Байткод можно увидеть руками. В Node.js есть флаг --print-bytecode. Для такой функции:
function add(a, b) {
return a + b;
}
add(10, 20);
Запустив node --print-bytecode --print-bytecode-filter=add file.js, увидим примерно такое:
Parameter count 3
Register count 0
Bytecode length 6
0x... @ 0 : 0b 03 Ldar a1
0x... @ 2 : 39 02 00 Add a0, [0]
0x... @ 5 : a8 Return
Перевод: «загрузи второй параметр в аккумулятор, прибавь к нему первый параметр, верни результат». Скучно, прямолинейно, быстро. Так бы и работало вечно — если бы не оптимизация.
JIT-компилятор: переписывает горячие функции в машинный код
Интерпретатор хорош тем, что мгновенно стартует, и плох тем, что исполняет медленно. Если функция вызвана один раз — разница незаметна. А если миллион раз в цикле — начинает чувствоваться.
Поэтому движок следит, какие функции зовутся часто. Их называют горячими (hot). Для них включается второй уровень: JIT-компилятор. JIT расшифровывается как Just-In-Time, что переводится как «прямо во время выполнения». Идея: пока программа работает, движок параллельно компилирует её горячие куски в настоящий машинный код, и при следующем вызове функции исполняет уже его.
В V8 JIT-компиляторов несколько, и они работают по уровням:
- Sparkplug — самый быстрый, но без серьёзных оптимизаций.
- Maglev — средний уровень, делает базовые оптимизации.
- TurboFan — самый медленный, но и самый умный, выжимает максимум.
Главный трюк JIT-компилятора — type feedback (обратная связь по типам). Пока интерпретатор гонял функцию, он запоминал: «сюда всегда приходил объект формы {name, age}». JIT этим пользуется и пишет машинный код «как будто всегда приходит именно такой объект». Безо всяких проверок «а вдруг там не объект» — это очень быстро.
Но что если завтра в эту же функцию прилетит объект другой формы? Скомпилированный код не подходит. Тогда движок деоптимизирует — выбрасывает скомпилированный код и возвращается к интерпретатору. Это дорого.
Отсюда практический вывод. В горячем коде полезно держать форму объектов однородной. Когда в свойство всегда обращаются у объектов одного типа — это называется мономорфный доступ (monomorphic). Когда форм две-четыре — полиморфный. Больше четырёх — мегаморфный, и оптимизация просто отключается.
Простой пример, на котором это видно:
// Все элементы массива — одной формы (мономорфно):
const items = [];
for (let i = 0; i < 1000000; i++) {
items.push({ id: i, name: 'a' });
}
// А тут формы разные (полиморфно):
const mixed = [];
for (let i = 0; i < 1000000; i++) {
if (i % 2) mixed.push({ id: i, name: 'a' });
else mixed.push({ id: i, name: 'a', extra: true });
}
Функция, читающая items[i].id, будет работать заметно быстрее аналогичной для mixed. Не в разы, но измеримо. На небольших объёмах это не важно, на больших — уже да.
Память: стек и куча
Где хранится let x = 5? А let arr = [1, 2, 3]? У движка два места для хранения данных, и они работают по-разному.
Стек вызовов (call stack). Это стопка. Когда вызывается функция, на стопку кладётся «фрейм» — карточка с её локальными переменными, аргументами и адресом, куда вернуть результат. Функция закончилась — карточку убрали. Просто и быстро.
Что лежит в стеке: примитивы (числа, булевые значения, маленькие строки), ссылки на объекты, адреса возврата. Сами объекты — нет.
Размер стека ограничен. Обычно от 1 до 8 МБ (мегабайт), зависит от движка и окружения. Если функция бесконечно зовёт сама себя — стопка переполняется, и движок бросает ошибку RangeError: Maximum call stack size exceeded. Это и есть знаменитый stack overflow.
function tooDeep() {
tooDeep();
}
tooDeep(); // RangeError: Maximum call stack size exceeded
Куча (heap). Это большая свалка. Здесь живут объекты, массивы, длинные строки, функции — всё, что не помещается аккуратно на стопку. Объекты в куче живут до тех пор, пока на них кто-то ссылается.
Когда вы пишете let obj = {a: 1}, переменная obj (ссылка) лежит на стеке, а сам объект {a: 1} — в куче. Стек указывает на кучу, как закладка на страницу в книге.
Поэтому, кстати, const obj = {} и потом obj.x = 1 работает: const запрещает менять ссылку (то, что на стеке), но сам объект в куче меняй сколько угодно.
Сборщик мусора (GC)
Куча конечна. Если в неё постоянно класть и ничего не убирать — рано или поздно она забьётся, и программа упадёт.
В JS убирать вручную ничего не надо — за это отвечает сборщик мусора (Garbage Collector, GC). В отличие от языков вроде C, где нужно вызывать free() на каждый освобождённый объект, в JS память освобождается сама. Это удобно, но даром не даётся: иногда GC просыпается и тормозит программу, пока чистит.
Базовый алгоритм работы — mark-and-sweep («пометить и подмести»).
У движка есть корни (roots) — это переменные, до которых можно достать прямо сейчас: глобальные, локальные в активных функциях, поля у активных DOM-узлов. GC идёт от корней по ссылкам и помечает всё, до чего смог дойти. Когда обход закончен — всё, что не помечено, признаётся мусором и освобождается.
Если на объект больше никто не ссылается — GC его соберёт. Если есть хоть одна ссылка, даже забытая в массиве или в обработчике события — объект жив, память не освободится. Отсюда классические утечки памяти — про них у нас отдельная статья.
Поколения (generations). Большинство объектов в JS живёт миллисекунды: создались, попользовались, выкинули (типичный случай — временные объекты внутри .map или .filter). Поэтому GC делит кучу на «молодых» (young) и «старых» (old). Молодых проверяет часто и быстро. До старых добирается редко, но проверяет глубоко. Так дешевле.
Поймать момент, когда конкретный объект собрался, можно с помощью WeakRef и FinalizationRegistry:
const registry = new FinalizationRegistry((label) => {
console.log(`Объект "${label}" собран сборщиком мусора`);
});
let user = { name: 'Аня' };
registry.register(user, 'Аня');
user = null; // больше ссылок нет
// Через какое-то время в консоль придёт сообщение от registry.
Прелесть в том, что «какое-то время» никто не гарантирует: GC сам решает, когда ему удобно. Но рано или поздно — сработает.
Где это знание реально пригодится
Устройство движка не нужно держать в голове каждый день. Но когда оно нужно — без него никак. Вот ситуации, в которых вспоминаешь эту статью.
- Читаешь stack trace в ошибке. Теперь понятно, что «стек» в названии — это та самая стопка фреймов, и читать его удобнее снизу вверх (внизу — самый ранний вызов).
- «Первый запуск всегда медленнее». Это не магия и не «кэш браузера». Просто на первом проходе работал интерпретатор, на втором и далее — уже JIT.
- Видишь в проекте WeakMap или WeakRef. Сразу понятно, для чего: чтобы не мешать GC собирать ненужные объекты.
- Объясняешь, почему obj.x иногда медленный. Скорее всего, у obj в этом месте слишком много разных форм — полиморфный путь.
- На собеседовании спрашивают про hot functions, hidden classes или почему try/catch когда-то выключал оптимизации в V8. Слова знакомые — разговор поддержать можно.
Шпаргалка
Всё, что мы прошли, одной картинкой:
| Компонент | Что делает | На примере V8 |
| Парсер | Текст программы → AST | встроен в V8 |
| Интерпретатор | AST → байткод → выполнение | Ignition |
| JIT-компилятор | Горячий байткод → машинный код | Sparkplug / Maglev / TurboFan |
| Стек вызовов | Фреймы и примитивы; маленький, быстрый | общий для движка |
| Куча | Объекты и длинные строки; большая | общий для движка |
| Сборщик мусора | Подметает кучу, освобождает память | Orinoco / поколения |
И помним правило про границу. Парсер, интерпретатор, JIT, стек, куча и GC — это движок. А setTimeout, fetch, DOM и event loop — это окружение вокруг движка. В одних задачах вы будете думать про первое, в других — про второе. Главное — уметь различать.
Комментарии (0)