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

Ленивый парсинг и пре-парсер

V8 запускает два парсера: полный, который строит AST и аллоцирует области видимости, и PreParser, который бегло проходит тела функций, находя границы и ранние ошибки без построения дерева. Функции пре-парсятся при загрузке и полностью парсятся только при первом вызове

JSE Junior ◷ 13 min
Уровень
ОсновыJuniorMiddleSenior

Бандл вашего приложения объявляет при загрузке четыреста функций, но холодный путь вызывает, может, двадцать из них. Если бы V8 полностью парсил все четыреста заранее — строя AST и объект области видимости для каждой — большая часть этой работы была бы выброшена непрочитанной. Поэтому он так не делает. Он делает быстрый беглый проход по каждому телу функции, которое может отложить, и полностью парсит функцию только в тот момент, когда её кто-то реально вызывает. Подвох: функция, которую вызывают, в итоге парсится дважды.

Два парсера, а не один

Предыдущий урок описывал «парсер» как одну штуку. На деле V8 поставляет два парсера, разделяющих сканер. Понимание того, какой из них работает и когда, позволяет рассуждать о стоимости старта, а не просто гадать:

  • Полный парсер. Строит полное AST, разрешает области видимости переменных, аллоцирует объекты областей видимости — всё, что нужно генератору байт-кода. Это дорогой.
  • PreParser. Намеренно неполный парсер, который быстро бегло проходит тело функции. Он не строит AST и не аллоцирует области видимости. Его задача узка: найти, где функция заканчивается (чтобы движок знал диапазон байтов, к которому вернуться), и обнаружить ранние ошибки — синтаксические ошибки и горстку проблем класса SyntaxError (return вне функции, дублирующийся параметр в strict-режиме), которые спецификация требует сообщать даже для кода, который никогда не выполняется.

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

Ловушка: вызванная функция парсится дважды

Ленивый парсинг — это ставка. Ставка окупается для функций, которые никогда не вызываются: вы потратили один дешёвый беглый проход вместо одного дорогого полного парсинга. Но для функции, которую вызывают, вы проигрываете ставку — вы платите за беглый pre-parse и затем за полный парсинг. Это проблема двойного парсинга: лениво распарсенная функция, которая позже выполняется, стоит примерно работы парсинга по сравнению с функцией, распарсенной жадно один раз.

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

Пре-парсер против полного парсера
PreParser строит AST?
нет
PreParser аллоцирует области видимости?
нет
Скорость PreParser против полного парсинга
примерно ~2x быстрее
Вызванная лениво распарсенная функция платит
pre-parse + полный парсинг
Штраф двойного парсинга
~2x работы парсинга
Ранние ошибки на pre-parse
синтаксис, дубль параметров (strict)

Эвристика жадного парсинга: скобки значат «запусти меня сейчас»

У V8 есть эвристика, чтобы вывести функцию из ленивого парсинга: если функциональное выражение обёрнуто в скобки, V8 парсит его жадно, потому что скобки — классический сигнал немедленно вызываемого функционального выражения (IIFE) — функции, которую автор намерен вызвать сразу.

// Парсится жадно: скобки сигнализируют «это выполняется немедленно».
(function () {
  bootstrapTheApp();
})();

// По умолчанию парсится лениво: выглядит как определение для вызова позже.
function maybeLater() {
  doExpensiveThing();
}

Функцию, написанную так, чтобы выглядеть как IIFE — даже со скобками, но вызываемую отдельно — называют PIFE (Possibly-Invoked Function Expression), и V8 трактует ведущую ( как подсказку жадного парсинга. Это ровно тот трюк, который эксплуатировал старый инструмент optimize-js: он механически оборачивал функции бутстрапа модулей в скобки, чтобы V8 парсил их жадно и пропускал двойной парсинг на стартовом пути. Современные сборщики (webpack, Rollup) выпускают обёртки IIFE/PIFE вокруг фабрик модулей по той же причине — это реальная, измеримая оптимизация старта, а не фольклор.

Вложенные функции едут вместе со своей внешней функцией

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

Викторина

Функция на вашем пути бутстрапа лениво распарсена и затем вызвана один раз при старте. Сколько раз парсится её тело и оптимально ли это здесь?

Викторина

Почему обёртывание стартовой функции в скобки — (function(){...})() — заставляет V8 парсить её жадно?

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

Расставьте по порядку события парсинга для обычной (не-IIFE) функции, которая определена при загрузке и затем вызвана один раз.

  1. 1 При загрузке V8 пре-парсит тело: записывает его диапазон исходника и проверяет ранние ошибки
  2. 2 AST или область видимости пока не строятся; движок идёт дальше к остальной части файла
  3. 3 Функцию вызывают в первый раз
  4. 4 V8 возвращается и полностью парсит этот диапазон — строя AST и области видимости — затем выпускает байт-код
Граничные случаи

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

Вспомните перед уходом
  1. 01
    Какие два парсера у V8 и что делает каждый?
  2. 02
    Объясните проблему двойного парсинга и когда ленивый парсинг это чистый проигрыш.
  3. 03
    Как заставить стартовую функцию парситься жадно и кто использует это на практике?
Итог

V8 не парсит каждую функцию полностью при загрузке — он поставляет два парсера. Полный парсер строит AST, разрешает области видимости и аллоцирует объекты областей видимости (дорогой путь, нужный генератору байт-кода), тогда как PreParser — это быстрый, намеренно неполный беглый проход, который не строит ни AST, ни областей видимости: он лишь находит диапазон исходника каждой функции и сообщает ранние ошибки (синтаксические ошибки, дублирующиеся параметры в strict-режиме), которые спецификация требует даже для кода, который никогда не выполняется. При холодной загрузке V8 пре-парсит тела функций и полностью парсит данную функцию лишь при первом её вызове — ленивый парсинг, крупнейший выигрыш на старте во фронт-энде, потому что большинство функций на холодном пути никогда не вызываются. Компромисс — проблема двойного парсинга: вызванная функция платит и за pre-parse, и за полный парсинг, примерно 2× работы парсинга, что расточительно для кода, про который вы знаете, что он выполняется сразу. Аварийный выход — эвристика жадного парсинга: функция, обёрнутая в скобки, IIFE/PIFE (function(){…})(), парсится жадно, потому что ведущая ( сигнализирует немедленное исполнение — трюк, который инструмент optimize-js и современные сборщики используют, чтобы держать стартовый путь вне двойного парсинга. Вложенные функции пре-парсятся, когда их внешняя функция парсится полностью, так что лень рекурсивна, уровень за уровнем. Теперь, когда встретишь медленный холодный старт при небольшом бандле, спроси себя: обёрнуты ли бутстрап-функции для жадного парсинга, или они платят двойной налог?

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.