Открываешь сайт, кликаешь по кнопке — и что-то происходит: разворачивается меню, отправляется форма, прилетает новое сообщение. Всё это делает 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 сам решает, когда ему удобно. Но рано или поздно — сработает.

Поддержка браузерами
chrome
Chrome
84
firefox
Firefox
79
edge
Edge
84
safari
Safari
14.1
opera
Opera
70

Где это знание реально пригодится

Устройство движка не нужно держать в голове каждый день. Но когда оно нужно — без него никак. Вот ситуации, в которых вспоминаешь эту статью.

  1. Читаешь stack trace в ошибке. Теперь понятно, что «стек» в названии — это та самая стопка фреймов, и читать его удобнее снизу вверх (внизу — самый ранний вызов).
  2. «Первый запуск всегда медленнее». Это не магия и не «кэш браузера». Просто на первом проходе работал интерпретатор, на втором и далее — уже JIT.
  3. Видишь в проекте WeakMap или WeakRef. Сразу понятно, для чего: чтобы не мешать GC собирать ненужные объекты.
  4. Объясняешь, почему obj.x иногда медленный. Скорее всего, у obj в этом месте слишком много разных форм — полиморфный путь.
  5. На собеседовании спрашивают про hot functions, hidden classes или почему try/catch когда-то выключал оптимизации в V8. Слова знакомые — разговор поддержать можно.

Шпаргалка

Всё, что мы прошли, одной картинкой:

Компонент Что делает На примере V8
Парсер Текст программы → AST встроен в V8
Интерпретатор AST → байткод → выполнение Ignition
JIT-компилятор Горячий байткод → машинный код Sparkplug / Maglev / TurboFan
Стек вызовов Фреймы и примитивы; маленький, быстрый общий для движка
Куча Объекты и длинные строки; большая общий для движка
Сборщик мусора Подметает кучу, освобождает память Orinoco / поколения

И помним правило про границу. Парсер, интерпретатор, JIT, стек, куча и GC — это движок. А setTimeout, fetch, DOM и event loop — это окружение вокруг движка. В одних задачах вы будете думать про первое, в других — про второе. Главное — уметь различать.