Погружение в v8. Часть 3. Парсинг, AST и анализ кода.

Погружение в v8. Часть 3. Парсинг, AST и анализ кода.

Anastasia Kotova

Предыдущие части

Погружение в V8. Часть 1. История.

Погружение в V8. Часть 2. Из чего состоит движок.


Введение

В предыдущих частях мы рассмотрели историю V8 и архитектуру его интерпретатора Ignition и компиляторов Sparkplug, Maglev и TurboFan. Но прежде чем любой из них сможет начать работу, JavaScript-код должен пройти через несколько подготовительных этапов.

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

В этой части мы разберём, как V8 разбирает JavaScript-код: от первого символа до готового байткода, который может исполнить Ignition.

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

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

Например, код:

const x = 42;

Превращается в последовательность токенов:

  • Token::CONST (ключевое слово)
  • Token::IDENTIFIER со значением "x"
  • Token::ASSIGN (оператор =)
  • Token::NUMBER со значением 42
  • Token::SEMICOLON (завершающая точка с запятой)

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

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

Обработка whitespace и комментариев

Хотя пробелы, табы и комментарии кажутся «шумом», для V8 они играют важную роль. Все такие символы классифицируются как Token::WHITESPACE, и перед обработкой нового токена движок последовательно их пропускает. Однако есть одна важная деталь: если среди пропущенного whitespace встретился перевод строки, это может повлиять на дальнейшую токенизацию — особенно из-за правил автоматической вставки точки с запятой.

function test() {
    return
    42;
}

Сканер должен понимать, что после return нужно вставить точку с запятой, и функция вернёт undefined, а не 42.

Abstract Syntax Tree (AST)

После токенизации начинается синтаксический анализ — построение Abstract Syntax Tree (AST). AST — это древовидная структура, которая отражает синтаксическую структуру программы, но абстрагируется от конкретных деталей записи.

Например, выражение a + b * c превращается в дерево:

    +
   / \\
  a   *
     / \\
    b   c

Для примера в JavaScript:

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

add(2, 3);

представление AST для функции add будет выглядеть так:

--- AST ---
FUNC at 12
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "add"
. INFERRED NAME ""
. PARAMS
. . VAR (0x11c00e33470) (mode = VAR, assigned = false) "a"
. . VAR (0x11c00e334f0) (mode = VAR, assigned = false) "b"
. DECLS
. . VARIABLE (0x11c00e33470) (mode = VAR, assigned = false) "a"
. . VARIABLE (0x11c00e334f0) (mode = VAR, assigned = false) "b"
. RETURN at 25
. . kAdd at 34
. . . VAR PROXY parameter[0] (0x11c00e33470) (mode = VAR, assigned = false) "a"
. . . VAR PROXY parameter[1] (0x11c00e334f0) (mode = VAR, assigned = false) "b"

А для корня скрипта:

--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . FUNCTION "add" = function add
. EXPRESSION STATEMENT at 42
. . kAssign at -1
. . . VAR PROXY local[0] (0x11c00e334e8) (mode = TEMPORARY, assigned = true) ".result"
. . . CALL
. . . . VAR PROXY unallocated (0x11c00e333e0) (mode = VAR, assigned = true) "add"
. . . . LITERAL 2
. . . . LITERAL 3
. RETURN at -1
. . VAR PROXY local[0] (0x11c00e334e8) (mode = TEMPORARY, assigned = true) ".result"

Отложенный парсинг функций

Чтобы ускорить загрузку скриптов и сэкономить ресурсы, V8 применяет стратегию отложенного парсинга (lazy parsing). Вместо того чтобы сразу разбирать весь код и строить для него AST, движок может временно ограничиться предварительной проверкой функции с помощью preparser’а.

Preparser — это облегчённый синтаксический анализ, который:

  • Проверяет валидность кода (нет ли синтаксических ошибок).
  • Отслеживает объявления переменных и ссылки на них — чтобы корректно распределить переменные по стеку или контексту.
  • Пропускает внутренние функции и не строит для них AST до тех пор, пока они реально не понадобятся.

Это особенно полезно при работе с большими скриптами, в которых много функций может не использоваться при старте. Например:

function outerFunction() {
    // Эта функция будет preparsed
    function innerFunction() {
        console.log("Hello");
    }

    // А эта часть распарсится полностью
    console.log("Outer");
}

В отличие от полноценного парсера, preparser не сохраняет дерево синтаксического разбора. Вместо этого он собирает минимальный набор данных — например, информацию о замыканиях (closure scope) и необходимости использования контекста (heap allocation).

Исключение для preparser-а составляют функции, которые, скорее всего, будут выполнены сразу, например:

(function() { /* ... */ })();

Такие конструкции в V8 называются PIFEs (Possibly-Invoked Function Expressions). Их preparser не откладывает и сразу отправляет в полноценный парсинг. Для этого он использует эвристику, основанную на анализе синтаксических конструкций, включая наличие скобок вокруг объявления функции.

Preparser позволяет V8 балансировать между скоростью старта и полнотой разбора кода, экономя ресурсы без потерь в совместимости.

Анализ скоупов и переменных

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

Каждая переменная либо размещается в стеке, либо в контексте, если к ней могут обращаться вложенные функции.

Рассмотрим пример:

function outer() {
    const b = 1;
    function inner(a) {
      console.log(a + b);
    }
    return inner;
}

Если вывести для него информацию о скоупах, то получим следующие данные:

Inner function scope:
function outer () { // (0x12400e33220) (14, 111)
  // NormalFunction
  // 2 heap slots
  // local vars:
  CONST b;  // (0x12400e30a48) forced context allocation, never assigned
  VAR inner;  // (0x12400e30d58) never assigned

  function () { // (0x12400e30a78) (54, 91)
    // NormalFunction
    // 2 heap slots
    // local vars:
    VAR a;  // (0x12400e30cb0) never assigned
  }
}
Global scope:
global { // (0x12400e33030) (0, 112)
  // will be compiled
  // NormalFunction
  // local vars:
  VAR outer;  // (0x12400e334d8) 

  function outer () { // (0x12400e33220) (14, 111)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}

Обратим внимание на строчку CONST b; // (0x12400e30a48) forced context allocation, never assigned — это значит, что b доступна в контесте inner.

Если мы уберём использование b внутри inner:

function outer() {
    const b = 1;
    function inner(a) {
      console.log(a);
    }
    return inner;
}

то данные изменятся на CONST b; // (0x10c00e30a48) never assigned.

А если совсем разделим функции:

function outer() {
    const b = 1;
}

function inner(a) {
    console.log(a);
}

то и скоупы тоже будут отдельные:

Inner function scope:
function outer () { // (0x13c00e33220) (14, 37)
  // NormalFunction
  // 2 heap slots
  // local vars:
  CONST b;  // (0x13c00e30a48) never assigned
}
Inner function scope:
function inner () { // (0x13c00e33410) (53, 80)
  // NormalFunction
  // 2 heap slots
  // local vars:
  VAR a;  // (0x13c00e30a30) never assigned
}
Global scope:
global { // (0x13c00e33030) (0, 81)
  // will be compiled
  // NormalFunction
  // local vars:
  VAR inner;  // (0x13c00e335d0) 
  VAR outer;  // (0x13c00e333e0) 

  function inner () { // (0x13c00e33410) (53, 80)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }

  function outer () { // (0x13c00e33220) (14, 37)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}

От AST к байткоду

После завершения анализа AST передаётся генератору байткода. Этот компонент обходит дерево и для каждого узла генерирует соответствующие инструкции байткода.

Для нашего первого примера:

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

add(2, 3);

байткод для функции add будет выглядеть так:

0x153a001000c8 @    0 : 0b 04             Ldar a1
0x153a001000ca @    2 : 40 03 00          Add a0, [0]
0x153a001000cd @    5 : b7                Return

А для корня скрипта:

0x153a00100084 @    0 : 13 00             LdaConstant [0]
0x153a00100086 @    2 : d1                Star1
0x153a00100087 @    3 : 1b fe f7          Mov <closure>, r2
0x153a0010008a @    6 : 6e 70 01 f8 02    CallRuntime [DeclareGlobals], r1-r2
0x153a0010008f @   11 : 23 01 00          LdaGlobal [1], [0]
0x153a00100092 @   14 : d1                Star1
0x153a00100093 @   15 : 0d 02             LdaSmi [2]
0x153a00100095 @   17 : d0                Star2
0x153a00100096 @   18 : 0d 03             LdaSmi [3]
0x153a00100098 @   20 : cf                Star3
0x153a00100099 @   21 : 6c f8 f7 f6 02    CallUndefinedReceiver2 r1, r2, r3, [2]
0x153a0010009e @   26 : d2                Star0
0x153a0010009f @   27 : b7                Return

На этапах парсинга и генерации байткода внутри V8 уже применяются простые оптимизации, такие как замена паттернов инструкций на более эффективные или удаление недостижимого кода.

// Будет удалена
function test() {
 const y = 7;
}

const x = 2 + 3; // Превращается в const x = 5;

Влияние на производительность

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

  • Минификация. Как показали исследования, сокращённые имена переменных и отсутствие лишних пробелов действительно ускоряют токенизацию.
  • Code splitting. Разделение кода на модули позволяет парсить только ту часть, которая нужна в текущий момент. Остальное можно отложить до первого использования, что экономит ресурсы.
  • Простая структура функций. Чем меньше глубоко вложенных функций и IIFE, тем легче preparser-у определить, что можно пока не разбирать. Это снижает нагрузку на ранних этапах и ускоряет старт.

Следующие части

Погружение в v8. Часть 4. Управление памятью и сборка мусора.

Погружение в v8. Часть 5. Скрытые оптимизации.

Погружение в v8. Часть 6. От среды к среде.


Report Page