Ленивый парсинг и пре-парсер
V8 запускает два парсера: полный, который строит AST и аллоцирует области видимости, и PreParser, который бегло проходит тела функций, находя границы и ранние ошибки без построения дерева. Функции пре-парсятся при загрузке и полностью парсятся только при первом вызове
Бандл вашего приложения объявляет при загрузке четыреста функций, но холодный путь вызывает, может, двадцать из них. Если бы V8 полностью парсил все четыреста заранее — строя AST и объект области видимости для каждой — большая часть этой работы была бы выброшена непрочитанной. Поэтому он так не делает. Он делает быстрый беглый проход по каждому телу функции, которое может отложить, и полностью парсит функцию только в тот момент, когда её кто-то реально вызывает. Подвох: функция, которую вызывают, в итоге парсится дважды.
Два парсера, а не один
Предыдущий урок описывал «парсер» как одну штуку. На деле V8 поставляет два парсера, разделяющих сканер. Понимание того, какой из них работает и когда, позволяет рассуждать о стоимости старта, а не просто гадать:
- Полный парсер. Строит полное AST, разрешает области видимости переменных, аллоцирует объекты областей видимости — всё, что нужно генератору байт-кода. Это дорогой.
- PreParser. Намеренно неполный парсер, который быстро бегло проходит тело функции. Он не строит AST и не аллоцирует области видимости. Его задача узка: найти, где функция заканчивается (чтобы движок знал диапазон байтов, к которому вернуться), и обнаружить ранние ошибки — синтаксические ошибки и горстку проблем класса
SyntaxError(returnвне функции, дублирующийся параметр в strict-режиме), которые спецификация требует сообщать даже для кода, который никогда не выполняется.
При холодной загрузке V8 не парсит большинство тел функций полностью. Он их пре-парсит: бегло проходит, записывает диапазон исходника и немного метаданных, идёт дальше. Полный парсинг данной функции откладывается до момента, когда функцию впервые вызовут. Это ленивый парсинг, и это крупнейший выигрыш на старте во фронт-энд конвейере, потому что большинство функций в типичном бандле на холодном пути никогда не вызываются.
Ловушка: вызванная функция парсится дважды
Ленивый парсинг — это ставка. Ставка окупается для функций, которые никогда не вызываются: вы потратили один дешёвый беглый проход вместо одного дорогого полного парсинга. Но для функции, которую вызывают, вы проигрываете ставку — вы платите за беглый pre-parse и затем за полный парсинг. Это проблема двойного парсинга: лениво распарсенная функция, которая позже выполняется, стоит примерно 2× работы парсинга по сравнению с функцией, распарсенной жадно один раз.
Для большинства кода это правильный компромисс: экономия на никогда не вызываемом большинстве перевешивает двойную стоимость на вызываемом меньшинстве. Но на горячем стартовом пути — функции, про которые вы знаете, что они выполняются сразу, вроде бутстрапа вашего фреймворка или инициализатора верхнего уровня модуля — платить за два парсинга это чистая трата. Вы бы предпочли, чтобы 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 При загрузке V8 пре-парсит тело: записывает его диапазон исходника и проверяет ранние ошибки
- 2 AST или область видимости пока не строятся; движок идёт дальше к остальной части файла
- 3 Функцию вызывают в первый раз
- 4 V8 возвращается и полностью парсит этот диапазон — строя AST и области видимости — затем выпускает байт-код
▸Граничные случаи
Есть тонкая причина корректности, по которой PreParser вообще существует, а не V8 просто пропускает невызванные функции: спецификация требует сообщать ранние ошибки для синтаксически невалидного кода, даже если он никогда не выполняется. Так что функция с синтаксической ошибкой должна бросить на этапе загрузки, а не тихо таиться до вызова. PreParser — самый дешёвый способ соблюсти это: он валидирует синтаксис и несколько правил ранних ошибок (вроде дублирующихся параметров в strict-режиме), не платя за полное AST.
- 01Какие два парсера у V8 и что делает каждый?
- 02Объясните проблему двойного парсинга и когда ленивый парсинг это чистый проигрыш.
- 03Как заставить стартовую функцию парситься жадно и кто использует это на практике?
V8 не парсит каждую функцию полностью при загрузке — он поставляет два парсера. Полный парсер строит AST, разрешает области видимости и аллоцирует объекты областей видимости (дорогой путь, нужный генератору байт-кода), тогда как PreParser — это быстрый, намеренно неполный беглый проход, который не строит ни AST, ни областей видимости: он лишь находит диапазон исходника каждой функции и сообщает ранние ошибки (синтаксические ошибки, дублирующиеся параметры в strict-режиме), которые спецификация требует даже для кода, который никогда не выполняется. При холодной загрузке V8 пре-парсит тела функций и полностью парсит данную функцию лишь при первом её вызове — ленивый парсинг, крупнейший выигрыш на старте во фронт-энде, потому что большинство функций на холодном пути никогда не вызываются. Компромисс — проблема двойного парсинга: вызванная функция платит и за pre-parse, и за полный парсинг, примерно 2× работы парсинга, что расточительно для кода, про который вы знаете, что он выполняется сразу. Аварийный выход — эвристика жадного парсинга: функция, обёрнутая в скобки, IIFE/PIFE (function(){…})(), парсится жадно, потому что ведущая ( сигнализирует немедленное исполнение — трюк, который инструмент optimize-js и современные сборщики используют, чтобы держать стартовый путь вне двойного парсинга. Вложенные функции пре-парсятся, когда их внешняя функция парсится полностью, так что лень рекурсивна, уровень за уровнем. Теперь, когда встретишь медленный холодный старт при небольшом бандле, спроси себя: обёрнуты ли бутстрап-функции для жадного парсинга, или они платят двойной налог?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.