open atlas
↑ К треку
Внутренности движка JavaScript JSE · 01 · 01

От исходного текста к AST

Прежде чем выполнится строка кода, V8 сканирует UTF-16-исходник в токены, а парсер рекурсивного спуска строит абстрактное синтаксическое дерево. AST — это временное промежуточное представление: его потребляет генератор байт-кода, после чего дерево выбрасывается.

JSE Junior ◷ 12 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Вы выпускаете бандл в 4 МБ, и страница висит пустой 600 мс, прежде чем отрисуется хоть один символ. Сеть закончила работу треть секунды назад. Что произошло между? V8 пришлось прочитать каждый байт этого текста, нарезать его на токены и построить из них дерево — и всё это до того, как ваш первый оператор смог хотя бы начать исполняться. Этот пустой промежуток — это парсер, и его стоимость пропорциональна тому, сколько исходника вы ему передали.

Две стадии до того, как что-либо запустится

Когда рантайм передаёт V8 строку JavaScript, до исполнения первой инструкции происходят две вещи, и это отдельные фазы:

  1. Сканирование (лексинг). Сканер читает сырой исходник — внутри V8 хранит его как UTF-16 — посимвольно и группирует символы в токены: неделимые слова языка. const x = 42; превращается в поток токенов Keyword(const), Identifier(x), Punctuator(=), Number(42), Punctuator(;). Пробелы и комментарии поглощаются и отбрасываются. Сканер — это горячий внутренний цикл парсинга: V8 многократно переписывал его ради пропускной способности по UTF-16, потому что он касается каждого байта.

  2. Парсинг. Парсер вытягивает токены из сканера и собирает их в абстрактное синтаксическое дерево (AST) — структурированное, вложенное представление грамматики программы. const x = 42; становится узлом VariableDeclaration, содержащим VariableDeclarator с Identifier (x) и NumericLiteral (42). Плоский поток токенов превращается в дерево, фиксирующее структуру: какое выражение вложено в какой оператор, какой блок ограничивает область видимости какой переменной.

Как парсер строит дерево: рекурсивный спуск + Pratt

Парсер V8 — это написанный вручную парсер рекурсивного спуска: одна функция на правило грамматики, каждая вызывает остальные по мере вложенности грамматики. ParseStatement может вызвать ParseExpression, та — ParseBlock, который снова вызывает ParseStatement — стек вызовов отражает вложенность вашего кода. Вот почему патологически глубоко вложенное выражение (тысячи вложенных скобок) может бросить RangeError: Maximum call stack size exceeded на этапе парсинга, ещё до исполнения.

Операторы парсятся сверху вниз просто, но у выражений есть приоритет: a + b * c должно сначала связать b * c. Наивный парсер рекурсивного спуска решает это одной функцией на каждый уровень приоритета, что многословно. Вместо этого V8 для выражений использует операторно-приоритетный (Pratt) парсинг: единый цикл, который потребляет операторы, пока их сила связывания достаточно высока, поднимаясь по уровням приоритета по требованию. Результат — то же корректное дерево, построенное намного меньшим кодом и с меньшим числом вызовов функций.

// a + b * c парсится в такую форму дерева (* связывает крепче, чем +):
//        (+)
//       /   \
//      a    (*)
//          /   \
//         b     c

AST временное — это НЕ то, что исполняется

Это самая важная поправка к наивной модели. Люди представляют, как движок «обходит AST» для исполнения программы, как древовидный интерпретатор из курса по компиляторам. V8 так не делает. Единственный потребитель AST — это генератор байт-кода: V8 обходит дерево один раз, выпускает для него байт-код Ignition, после чего дерево становится недостижимым и освобождается сборщиком мусора. С этого момента исполняется байт-код — AST больше нет. (Следующие уроки этого юнита разбирают байт-код и цикл интерпретатора.)

Итак, AST — это промежуточное представление: удобная структурированная форма, существующая лишь для того, чтобы быть пониженной во что-то, что интерпретатор может исполнять эффективно. Хранить его — пустая трата памяти; обходить его как дерево было бы намного медленнее, чем исполнять плоский байт-код.

Стоимость парсинга линейна по размеру исходника

Почему это важно инженеру? Потому что каждый добавленный вами килобайт в бандл добавляет фиксированный налог парсинга — даже если файл закэширован у пользователя. Сканирование касается каждого байта; парсинг посещает примерно каждый токен один раз. Поэтому время парсинга растёт линейно с размером исходника — удвойте байты, примерно удвоится и стоимость парсинга. Вот почему размер бандла оказывает прямое, механическое влияние на задержку старта, отдельное от времени загрузки: даже полностью закэшированный бандл при холодном старте всё равно приходится заново сканировать и парсить.

Где проявляется стоимость парсера
Сканирование + парсинг как доля старта JS
до ~15-20%
Стоимость парсинга от байтов исходника
примерно линейна
Внутренняя кодировка исходника в V8
UTF-16
Флаг для дампа AST в d8
--print-ast
Быстрый C++-путь для JSON
JSON.parse
Время жизни AST после выпуска байт-кода
выброшено (GC)

Можно увидеть дерево, которое V8 реально строит: запустите d8 --print-ast script.js, и он напечатает AST узел за узлом. Это самый ясный способ убедиться, что, скажем, стрелочная функция и функциональное выражение дают разные типы узлов или что await десугарится так, как говорит спецификация.

Режим отказа: гигантский литерал данных, разобранный жадно

Вот боевая ловушка. Команда встраивает большой набор данных прямо в JS-модуль как литерал объекта или массива — многомегабайтный «JSON-в-JS»:

// data.js — 3 МБ такого, встроено в бандл
export const LOOKUP = { "0001": { name: "...", coords: [] }, /* ещё 50k */ };

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

Фикс — вынести данные из JS в настоящий файл .json, загрузить его как строку и передать в JSON.parse:

const LOOKUP = JSON.parse(await fetch("/data.json").then(r => r.text()));

У JSON.parse есть выделенный, тщательно настроенный C++-путь, который драматически быстрее, чем общий парсер JavaScript, строящий узлы AST — он не производит AST, не аллоцирует объекты областей видимости и парсит куда более простую грамматику. Те же байты стоят доли времени. Правило большого пальца: данные должны путешествовать как данные, а не как код. Именно это делают сборщики вроде webpack и esbuild, когда извлекают большие константы, и именно это команда V8 рекомендует для крупных нагрузок.

Викторина

После того как V8 построил AST для вашей функции, что происходит с этим деревом?

Викторина

У вас есть таблица поиска в 3 МБ, замедляющая холодный старт. Почему вынести её в файл .json, парсимый через JSON.parse, быстрее, чем встроить как литерал объекта JS?

Расставь шаги по порядку

Расставьте по порядку фронт-энд стадии, через которые проходит свежий скрипт, от сырого текста до формы, которая реально исполняется.

  1. 1 Сканер читает UTF-16-исходник и выпускает поток токенов
  2. 2 Парсер рекурсивного спуска собирает токены в AST
  3. 3 Генератор байт-кода обходит AST и выпускает байт-код Ignition
  4. 4 AST становится недостижимым и собирается мусорщиком; исполняется байт-код
Почему это работает

Почему внутри UTF-16? Строки JavaScript определены спецификацией как последовательности кодовых единиц UTF-16 (поэтому суррогатные пары считаются как 2 в .length). V8 хранит исходник и строки в этой кодировке, чтобы строковые операции соответствовали семантике спецификации без перекодирования, а сканер настроен быстро ходить по двухбайтовым единицам. Чисто ASCII-исходник может использовать однобайтовое представление как оптимизацию, но логическая модель — UTF-16.

Вспомните перед уходом
  1. 01
    Пройдите фронт-энд конвейер от исходного текста до представления, которое реально исполняется, назвав каждый артефакт.
  2. 02
    Почему «движок обходит AST, чтобы выполнить программу» неверно, и что исполняется вместо этого?
  3. 03
    Таблица поиска в 3 МБ, встроенная как литерал объекта JS, замедляет холодный старт. Объясните механизм и фикс.
Итог

Прежде чем выполнится любой JavaScript, V8 прогоняет исходник через фронт-энд конвейер. Сканер читает UTF-16-исходник байт за байтом и группирует его в токены — слова языка — отбрасывая пробелы и комментарии. Затем написанный вручную парсер рекурсивного спуска собирает эти токены в абстрактное синтаксическое дерево, используя Pratt/операторно-приоритетный парсинг для выражений, чтобы приоритет (a + b * c) выходил верным; глубоко вложенный код может даже переполнить стек вызовов парсера. Принципиально, что AST — это временное промежуточное представление: его единственный потребитель — генератор байт-кода, который обходит его один раз, чтобы выпустить байт-код Ignition, после чего дерево выбрасывается GC. Исполняется байт-код, а не AST. Поскольку сканирование и парсинг посещают каждый байт и токен, стоимость парсинга примерно линейна по размеру исходника, что даёт размеру бандла прямое механическое влияние на задержку холодного старта, отдельное от времени загрузки. Классический режим отказа — многомегабайтный литерал данных, встроенный как JS, который V8 обязан полностью распарсить в узлы AST на критическом пути; фикс — поставить его как файл .json и использовать C++-путь в JSON.parse. Теперь, когда увидишь 600 мс пустого экрана перед первой отрисовкой на закэшированном бандле, ты знаешь, куда смотреть в первую очередь: парсер и то, что ему передали.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.