Погружение в 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со значением 42Token::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. От среды к среде.