Всем привет. Меня зовут Виктор Степанов, я frontend chapter lead на платформе СберТеха GitVerse. Хочу рассказать про внутреннюю «механику» V8 и показать, как писать более быстрый код. Поехали!

V8 JavaScript Engine увидел свет осенью 2008 года вместе с первым публичным релизом Chromium. И на сегодняшний день плотно, но незаметно вошёл в жизни всех, кто пользуется интернетом, так как используется для запуска JavaScript в большинстве браузеров, а также для его запуска в качестве серверного решения Node.js. Миллионы разработчиков каждый день пишут программное обеспечение, которое впоследствии будет исполняться на этом движке — и значительная часть этих славных ребят не имеет ни малейшего представления о том, почему те или иные механизмы в V8 JavaScript Engine работают именно так, как они работают.

Архитектура V8: обзор основных компонентов

Чтобы писать слова, нам сначала надо научиться писать буквы. Так и изучая работу движка, нам надо предварительно разобраться в его основных компонентах.

Парсинг кода

Первый шаг выполнения JavaScript-кода — преобразование исходного текста программы в структуры данных, которые движок может понять. Оно состоит из двух этапов — лексического и синтаксического анализа кода. Также стоит помнить, что для повышения производительности V8 не сразу парсит весь код. Если функция ещё не вызвана, то она парсится только частично (лениво). Это экономит время и ресурсы.

Лексический анализ

На этом этапе парсинга исходный код разбивается на токены (лексемы). Например, код let x = 42; разбивается на токены let, x, = и 42. На этом этапе проверяются синтаксические ошибки (например, незакрытые скобки и тому подобное).

Синтаксический анализ

Токены собираются в абстрактное синтаксическое дерево (Abstract Syntax Tree, AST). Например, для кода выше AST будет выглядеть так:

VariableDeclaration
├── Identifier: x
└── Literal: 42

Компиляция и интерпретация

После парсинга код выполняется через комбинацию интерпретации и компиляции.

Интерпретатор Ignition

Ignition — ключевой компонент архитектуры V8, который отвечает за интерпретацию JavaScript-кода. Его основная задача заключается в преобразовании абстрактного синтаксического дерева (AST) в байт-код — промежуточное представление программы, которое легче интерпретировать и оптимизировать, чем исходный текст на JavaScript.

Байт-код — это упрощённая форма машинного кода, которая служит мостом между высокоуровневым JavaScript и низкоуровневыми инструкциями процессора. В отличие от исходного кода JavaScript, байт-код более компактен и легко обрабатывается движком. Он состоит из последовательности инструкций, которые напрямую выполняются интерпретатором Ignition.

Пример:

function add(a, b) {
  return a + b;
}

После парсинга этот код преобразуется в AST, а затем Ignition генерирует следующий байт-код:

LOAD_ARG 0      // Загрузить первый аргумент (a)
LOAD_ARG 1      // Загрузить второй аргумент (b)
ADD             // Выполнить операцию сложения
RETURN          // Вернуть результат

Компилятор TurboFan

TurboFan — это оптимизирующий компилятор, который преобразует «горячий» код (часто выполняемые функции) в машинный. Процесс называется Just-In-Time (JIT) компиляцией:

  • «горячие» функции идентифицируются через профилирование;

  • эти функции компилируются в высокооптимизированный машинный код;

  • если функция перестаёт быть «горячей», то она может быть деоптимизирована.

Сборщик мусора (Garbage Collector)

V8 использует собственный механизм управления памятью, включая сборщик мусора (Garbage Collector, GC).

Young Generation (Scavenge)

Недавно созданные объекты помещаются в категорию «молодое поколение». Если объекты выживают после нескольких циклов сборки мусора, они перемещаются в категорию «старое поколение».

Old Generation (Mark-Sweep, Mark-Compact)

«Старое поколение» содержит долгоживущие объекты. Применительно к ним сборщик мусора использует стратегии Mark-Sweep (поиск неиспользуемых объектов) и Mark-Compact (дефрагментация памяти).

Минимизация мусора

Частое создание и удаление объектов замедляет работу приложения. Рекомендация: переиспользовать объекты, где это возможно.

Оптимизации в V8

Inline Caching

Одним из ключевых механизмов оптимизации в V8 является Inline Caching (IC). Он позволяет ускорить выполнение функций, кешируя результаты их вызовов на основе типов аргументов. Когда функция вызывается с одинаковыми типами аргументов, V8 может «запомнить» это и использовать заранее подготовленные инструкции для обработки данных. Однако эффективность Inline Caching зависит от состояния функции, которое может быть mono-morphic, poly-morphic или mega-morphic.

Состояние mono-morphic

Функция всегда вызывается с одними и теми же типами аргументов. Inline Caching работает максимально эффективно, так как движок точно знает, как обрабатывать данные. Пример:

function add(a, b) {
  return a + b;
}
add(1, 2); // Mono-morphic: a и b всегда числа
add(3, 4);

Здесь add всегда вызывается с числами. V8 создает кеш для операции сложения чисел, что ускоряет выполнение функции.

Состояние poly-morphic

Функция вызывается с несколькими разными типами аргументов (обычно до 4 типов). Inline Caching становится менее эффективным, так как движок должен поддерживать несколько вариантов обработки данных. Пример:

function add(a, b) {
  return a + b;
}

add(1, 2); // Числа
add("hello", "world"); // Строки

Первый вызов работает с числами, второй — со строками. V8 создаёт отдельные кеши для каждого типа аргументов, что увеличивает нагрузку на движок.

Состояние mega-morphic

Функция вызывается с большим количеством различных типов аргументов (более 4 типов). Inline Caching перестает работать, так как количество вариантов становится слишком большим для эффективного кеширования. Пример:

function add(a, b) {
  return a + b;
}

add(1, 2); // Числа
add("hello", "world"); // Строки
add([], []); // Массивы
add({}, {}); // Объекты
add(true, false); // Булевы значения

Здесь функция вызывается с разными типами данных: числами, строками, массивами, объектами и булевыми значениями. V8 не может эффективно кэшировать такие вызовы, что приводит к значительному замедлению.

Hidden Classes

V8 использует механизм скрытых классов (Hidden Classes), чтобы оптимизировать доступ к свойствам объектов. Этот механизм позволяет движку быстро находить и работать со свойствами объектов, что критично для производительности JavaScript-приложений. Когда вы создаёте объект в JavaScript, V8 автоматически создаёт для него скрытый класс (или «структуру»). Этот класс описывает структуру объекта: какие свойства он содержит и в каком порядке они добавлены. Пример:

const obj = { x: 1, y: 2 };

Сначала создаётся пустой объект ({}), а затем добавляются свойства x и y.

V8 создаёт скрытый класс для каждого шага:

  1. класс 1: {} (пустой объект);

  2. класс 2: { x } (добавлено свойство x);

  3. класс 3: { x, y } (добавлено свойство y).

Почему важно соблюдать порядок добавления свойств? V8 использует скрытые классы для ускорения доступа к свойствам объектов. Если два объекта имеют одинаковый скрытый класс, то V8 может использовать заранее подготовленные инструкции для работы с их свойствами. Однако если порядок добавления свойств отличается, V8 создаёт разные скрытые классы, что снижает производительность.

Хороший пример:

const obj1 = { x: 1 };
obj1.y = 2; // Порядок добавления: x → y

Плохой пример:

const obj2 = {};
obj2.y = 2;
obj2.x = 1; // Порядок добавления: y → x

Здесь V8 создаёт другой скрытый класс, так как порядок добавления свойств отличается. Из-за этого движок должен поддерживать несколько скрытых классов для объектов с одинаковыми свойствами, что замедляет выполнение программы.

Inline Expansion

Ещё один из ключевых механизмов оптимизации в V8 — Inline Expansion (или Inlining ). Этот процесс позволяет «встраивать» код часто вызываемых функций непосредственно в место их вызова, что устраняет накладные расходы на вызов функции и упрощает дальнейшую оптимизацию.

Inline Expansion — это процесс замены вызова функции её телом. Вместо того, чтобы переходить к выполнению функции, её код встраивается в вызывающую функцию.

Пример:

function square(x) {
  return x * x;
}

function calculate(x) {
  return square(x) + square(x);
}

Движок V8 обнаруживает, что функция square вызывается часто, и может «раскрыть» её, заменив вызовы её телом:

function calculate(x) {
  return x  x + x  x; // Inline Expansion
}

Здесь вызовы square(x) заменяются непосредственно на выражение x * x. Это упрощает код и позволяет компилятору TurboFan применять дополнительные оптимизации, например:

  • устранение повторяющихся вычислений;

  • упрощение выражений.

Почему это важно?

Каждый вызов функции в JavaScript требует:

  • сохранения текущего контекста;

  • передачи аргументов;

  • выделения стека для вызываемой функции.

Inline Expansion устраняет эти накладные расходы, так как код функции выполняется сразу в месте вызова.

function calculate(x) {
  return x  x + x  x; // Inline Expansion
}

После встраивания функции TurboFan может применить дополнительные оптимизации:

  • Constant Folding — если аргументы известны заранее, выражения могут быть вычислены на этапе компиляции;

  • Common Subexpression Elimination — повторяющиеся вычисления могут быть выполнены один раз;

  • Dead Code Elimination — ненужный код может быть удалён.

function calculate(x) {
  return 2  (x  x); // TurboFan может упростить это выражение
}

Dead Code Elimination

Dead Code Elimination — это процесс, при котором компилятор или движок JavaScript удаляет из программы части кода, которые никогда не будут выполнены. Это важная оптимизация, которая помогает уменьшить размер скомпилированного кода и улучшить производительность.

Что такое «мёртвый» код?

«Мёртвый» код — это часть программы, которая:

  • никогда не выполняется;

  • не влияет на результат работы программы;

  • не используется в других частях кода.

Примеры:

  • код внутри недостижимых условий (например, if (false));

  • неиспользуемые переменные или функции;

  • вызовы функций, результат которых игнорируется.

V8 анализирует код на этапе компиляции, чтобы выявить недостижимые или ненужные части, что позволяет уменьшать размер компилируемого кода, а также улучшить работу оптимизатора TurboFan.

Подводные камни и антипаттерны

Типы данных

V8 оптимизирует выполнение, предполагая, что переменная всегда имеет один конкретный тип данных (например, число или строку). Если тип данных переменной меняется в процессе выполнения, движок теряет возможность применять оптимизации для этой переменной.

Когда переменная становится poly-morphic (многоформенной), V8 должен использовать более сложные механизмы для её обработки.

Старайтесь сохранять типы данных постоянными. Например: 

javascript     
let x = 1;     
let y = "string"; 

Это позволит движку эффективно оптимизировать каждую переменную.

Eval и with

Конструкции eval и with нарушают предсказуемость кода, так как они динамически изменяют область видимости. Это не позволяет V8 заранее определить, какие переменные будут доступны и как они используются.

function badExample(obj) {
  with (obj) {
    console.log(x); // V8 не может предсказать, что такое x
  }
}

Кроме того, eval выполняет строку как JavaScript-код, что усложняет статический анализ.

Пример:

function dynamicCode(code) {
  eval(code); // Код внутри eval невозможно оптимизировать
}

С with есть своя проблема: эта конструкция создаёт новую динамическую область видимости, что затрудняет оптимизацию доступа к переменным. Пример:

const obj = { x: 42 };
with (obj) {
  console.log(x); // Движок не знает заранее, что x — это свойство объекта
}

Try-Catch

До недавнего времени код внутри блока try-catch не мог быть оптимизирован TurboFan. Это связано с тем, что механизм обработки исключений требует дополнительных проверок и усложняет анализ потока выполнения.

function riskyOperation() {
  try {
    throw new Error("Something went wrong");
  } catch (e) {
    console.log(e.message);
  }
}

Блок try-catch создаёт дополнительные накладные расходы. Оптимизатор не мог применить Inline Caching или другие механизмы внутри catch.

Начиная с версии V8 6.0, поддержка оптимизации кода внутри try-catch значительно улучшилась. Однако по-прежнему рекомендуется избегать чрезмерного использования try-catch, особенно для обработки ожидаемых ситуаций.

Асинхронный код

Современный JavaScript активно использует асинхронные операции через Promise и async/await. Хотя V8 хорошо оптимизирует их, есть несколько моментов, которые могут повлиять на производительность.

Цепочки .then() могут создавать дополнительные накладные расходы, если они слишком длинные или содержат много мелких операций. Пример:

function chainPromises() {
  return Promise.resolve(1)
    .then((x) => x + 1)
    .then((x) => x * 2)
    .then((x) => console.log(x));
}

Код с async/await транспилируется в цепочки Promise, что может привести к созданию лишних обёрток. Пример:

async function fetchData() {
  const data = await fetch("/api/data");
  const result = await data.json();
  console.log(result);
}

Оптимизация

V8 автоматически оптимизирует async/await, преобразуя его в более эффективный код. Однако избегайте избыточного использования await в простых ситуациях. Пример:

async function optimizedFetch() {
  const [data1, data2] = await Promise.all([
    fetch("/api/data1"),
    fetch("/api/data2"),
  ]);
  console.log(await data1.json(), await data2.json());
}

Практические примеры

Оптимизация циклов

Циклы — это один из самых распространённых элементов в JavaScript, и их производительность может сильно влиять на общую скорость выполнения программы. V8 оптимизирует циклы, но есть несколько способов сделать их ещё быстрее.

// Плохо
for (let i = 0; i < array.length; i++) {
  console.log(array[i]);
}

// Лучше
const length = array.length;
for (let i = 0; i < length; i++) {
  console.log(array[i]);
}

В первом случае array.length вычисляется на каждой итерации цикла. Это создаёт ненужные накладные расходы, особенно если массив большой.

Во втором варианте длина массива сохраняется в переменную length перед началом цикла. Это позволяет избежать повторного вычисления array.length в каждой итерации.

Дополнительные рекомендации:

  • Используйте методы массивов (forEach, map, filter) там, где это уместно, так как они более читаемы и часто оптимизируются движком.

  • Избегайте сложных операций внутри цикла, таких как вызовы функций с побочными эффектами.

Работа с объектами

V8 использует скрытые классы (Hidden Classes) для оптимизации доступа к свойствам объектов. Чтобы эти оптимизации работали эффективно, важно соблюдать порядок добавления свойств и минимизировать динамическое изменение структуры объекта.

// Хорошо
const obj = { x: 1, y: 2 };

// Плохо
const obj = {};
obj.x = 1;
obj.y = 2;

Проблема второго варианта в том, что когда свойства добавляются динамически, V8 создаёт новые скрытые классы для каждого шага. Это замедляет доступ к свойствам объекта.

А первый вариант сразу определяет все свойства объекта, что позволяет V8 использовать один скрытый класс для всех таких объектов.

Дополнительные рекомендации:

  • Используйте классы или конструкторы для создания объектов с одинаковой структурой.

  • Избегайте добавления новых свойств после создания объекта.

Оптимизация массивов

Массивы в JavaScript могут содержать элементы разных типов, но это снижает производительность. V8 оптимизирует массивы только тогда, когда они содержат элементы одного типа.

const numbers = [1, 2, 3]; // Хорошо
const mixed = [1, "2", true]; // Плохо

Если массив содержит элементы разных типов (например, числа, строки и булевы значения), то V8 не может применить оптимизации для работы с массивами. Это приводит к замедлению операций, таких как итерация или поиск.

Используйте массивы с элементами одного типа. Например, если вам нужно хранить только числа, убедитесь, что массив содержит только числа.

Дополнительные рекомендации:

  • Используйте TypedArray (например, Uint8Array, Float32Array) для работы с числовыми данными, если требуется максимальная производительность.

  • Избегайте частого изменения размера массива (например, через push или splice), так как это может привести к созданию нового массива.

Заключение

Чтобы ваш код работал быстро и эффективно, важно писать его так, чтобы он был понятен не только вам, но и движку V8. Избегайте смешивания типов данных, минимизируйте использование опасных конструкций вроде eval и with, а также следите за структурой объектов и порядком добавления их свойств.

Эти простые, но важные практики помогут V8 максимально эффективно оптимизировать выполнение программы. Помните, что даже небольшие изменения в написании кода могут привести к значительным улучшениям в производительности, а чистый и предсказуемый код всегда будет лучшим союзником в достижении высокой скорости работы вашего приложения.

Комментарии (8)


  1. nihil-pro
    30.06.2025 07:34

    frontend chapter lead

    Даже с гуглом было довольно трудно разобраться что это


    1. space2pacman
      30.06.2025 07:34

      Главный лидер?


      1. nihil-pro
        30.06.2025 07:34

        Глава глав? ))


        1. nin-jin
          30.06.2025 07:34

          Ну это же гигачад, а не какой-то там хуман.


    1. akabrr
      30.06.2025 07:34

      chapter что-то из вархаммера :)


  1. nin-jin
    30.06.2025 07:34

    Мне вот интересно, публикация столь низкосортной нейросетевой галюцинации, дискредетирующей Сбер, как компании, где работают высококлассные специалисты, - это личная инициатива нерадивого сотрудника или новая политика компании?


  1. shaman4d
    30.06.2025 07:34

    "// Лучше const length = array.length;" я то думал что это давно пофиксили...


    1. Vitaly_js
      30.06.2025 07:34

      А мне еще другое любопытно. Описанный пример загрязняет область видимости переменными цикла. Что не очень удобно, если имеются несколько, например, идущих подряд циклов. И что тогда автор делает, придумывает разные имена или использует какой-нибудь такой подход, что бы сохранить оптимизацию производительности:

      for (let i = 0, max = array.length; i < length; i++) {
        console.log(array[i]);
      }

      или

      {
        const max = array.length
        for (let i = 0, max = length; i < length; i++) {
          console.log(array[i]);
        }
      }