Веб-разработчики знают, как легко разрастаются размеры веб-страниц. Но загрузка страницы — это не просто передача байтов по проводу. Когда браузер загрузил скрипты, ему нужно их отпарсить, интерпретировать и запустить. В статье мы внимательно рассмотрим эту фазу и узнаем, почему она может стать причиной замедления запуска вашего приложения и как это исправить.
Исторически сложилось так, что мы не тратим много времени на оптимизацию парсинга/компилирования JavaScript. Мы считаем, что скрипты будут моментально отпарсены и выполнены, как только парсер дойдёт до тега
<script>
. Но это не так. Вот упрощённая схема работы V8:Это идеализированное представление нашего рабочего конвейера.
Давайте рассмотрим некоторые ключевые фазы.
Что замедляет загрузку наших веб-приложений?
Во время запуска JavaScript-движок тратит значительное время на парсинг, компилирование и исполнение скриптов. Это важно, ведь если движок будет делать это довольно долго, то начало взаимодействия пользователей с нашим сайтом задержится. Допустим, они видят кнопку, но в течение нескольких секунд она не реагирует на нажатия. Это может привести к деградации UX.
Длительность парсинга и компилирования для популярного сайта с использованием статистики runtime-вызовов V8 в Chrome Canary. Обратите внимание, как и без того небыстрые на настольных компьютерах парсинг и компилирование могут стать ещё медленнее на смартфонах
Время запуска важно для кода, чувствительного к производительности. По факту JS-движок V8 тратит много времени на парсинг и компилирование таких сайтов, как Facebook, Wikipedia и Reddit:
Розовые области (JavaScript) отражают время, потраченное на работу V8 и Blink C++, оранжевые и жёлтые — длительность парсинга и компилирования
У многих сайтов и фреймворков длительность парсинга и компилирования — слабое место. Ниже цитируются Себастьян Маркбейдж (Facebook) и Роб Вормалд (Google):
Парсинг/компилирование — огромная проблема. Я попрошу наших ребят поделиться данными. Однако измерять нужно дисконнект.
— @sebmarkbage
Я понимаю эти данные так, что главные затраты на запуск в Angular приходятся в основном на парсинг JS, до того как мы вообще касаемся DOM.
— @robwormald
Сэм Сакконе выявляет стоимость JS-парсинга в «Planning for Performance»
«Мобильность» веба увеличивается, и важно понимать, что на смартфонах парсинг/компилирование может быть в 2—5 раз дольше, чем на настольных компьютерах. Причём производительность топовых смартфонов очень сильно отличается от какого-нибудь Moto G4. Это подчёркивает важность тестирования на репрезентативном оборудовании (а не только на топовом!), чтобы проверить качество пользовательского опыта.
Длительность парсинга 1-пакета (bundle) JavaScript на настольных и мобильных устройствах разных классов. Обратите внимание, что смартфоны вроде iPhone 7 по производительности близки к MacBook Pro, и сравните падение показателей на устройствах среднего уровня
Если наши веб-приложения используют огромные пакеты, то применение современных методик поставки, таких как code-splitting, tree-shaking и кеширование Service Worker, может оказать очень сильное влияние. С другой стороны, даже маленький пакет, коряво написанный или использующий посредственные библиотеки, может привести к тому, что основной поток надолго застрянет на компилировании или вызовах функций. Важно оценивать картину целостно, понимая, где именно находятся узкие места.
Длительность парсинга и компилирования JavaScript — это узкое место для среднестатистического сайта?
Наверняка сейчас вы думаете: «Но я же не Facebook». Вы можете спросить: «Насколько велика длительность парсинга и компилирования для среднестатистических сайтов?» Давайте изучим этот вопрос!
Я потратил два месяца на измерение производительности большого количества работающих сайтов (более 6 тыс.), построенных с использованием различных библиотек и фреймворков — React, Angular, Ember и Vue. Большинство тестов были воспроизведены на WebPageTest, так что вы легко можете прогнать их самостоятельно или внимательно изучить имеющиеся результаты. Вот некоторые выводы.
Приложения становятся интерактивными через 8 секунд на десктопах (кабельное подключение) и через 16 секунд на смартфонах (Moto G4 с 3G):
С чем это связано? На десктопах на запуск большинства приложений тратится в среднем около 4 секунд (парсинг/компилирование/исполнение).
На смартфонах длительность парсинга была примерно на 36 % выше, чем на десктопах.
Все использовали огромные JS-пакеты? Не настолько большие, как я предполагал, но есть ещё куда стремиться. В среднем разработчики применяли для своих страниц пакеты размером 410 Кб, сжатые с помощью gzip. Это согласуется с данными HTTP Archive — 420 Кб JS в среднем на страницу. Некоторые фрики передавали по кабелю до 10 Мб.
Статистика HTTP Archive: в среднем на страницу приходится 420 Кб JavaScript
Размер скриптов имеет значение, но не решающее. Длительность парсинга и компилирования необязательно линейно зависит от размера скриптов. Более компактные JavaScript-пакеты в целом демонстрируют более быструю загрузку (в зависимости от браузера, устройства и подключения), но 200 Кб JS !== 200 Кб чего-то другого, так что длительность парсинга и компилирования может сильно варьироваться.
Современное измерение длительности парсинга и компилирования JavaScript
Инструментарий разработчиков в Chrome
Зайдите в Timeline (панель Performance) > Bottom-Up/Call и увидите Tree/Event Log, из которого вы получите представление о времени, потраченном на парсинг и компилирование. Ради более подробной картины (например, длительность парсинга, препарсинга или ленивого компилирования) можно включить статистику runtime-вызовов V8. В Canary это делается так: Experiments > V8 Runtime Call Stats on Timeline.
Трейсинг в Chrome
about:tracing — низкоуровневый инструмент трассировки в Chrome позволит использовать категорию disabled-by-default-v8.runtime_stats, чтобы сделать более глубокие выводы относительно того, на что тратится время работы V8. В движке есть свежее пошаговое руководство по использованию инструмента.
WebPageTest
Страница Processing Breakdown на WebPageTest содержит данные о длительности компилирования в V8, EvaluateScript и FunctionCall, когда мы выполняем трассировку с помощью включённого инструмента Chrome > Capture Dev Tools Timeline.
Также можно извлечь статистику runtime-вызовов, задав кастомную категорию трассировки disabled-by-default-v8.runtime_stats (Пэт Минен из WPT делает это по умолчанию!).
Как можно извлечь из этого пользу: https://gist.github.com/addyosmani/45b135900a7e3296e22673148ae5165b.
User Timing
Можно измерять длительность парсинга с помощью User Timing API:
Третий
<script>
здесь неважен. Но он обретает важность, будучи первым <script>
, отделённым от второго (performance.mark() начинается до <script>
).Такой подход способен повлиять на последующие перезагрузки со стороны препарсера V8. Это можно обойти, добавляя случайное строковое значение в конце скрипта, нечто подобное сделал в своих бенчмарках Нолан Лоусон.
Для измерения влияния длительности парсинга JavaScript я использую аналогичный подход, применяя Google Analytics:
Кастомные измерения parse позволяют измерять длительность парсинга JavaScript для реальных пользователей и устройств, заходящих на мои страницы
DeviceTiming
Инструмент DeviceTiming поможет в измерении длительности парсинга/компилирования скриптов в контролируемой среде. Локальные скрипты помещаются в инструментальную обёртку, и при каждом обращении к страницам с разных устройств (ноутбуков, смартфонов, планшетов) мы можем локально сравнивать длительность парсинга/исполнения. В выступлении Даниэля Эспесета «Benchmarking JS Parsing and Execution on Mobile Devices» этот инструмент рассматривается подробнее.
Как можно уменьшить длительность парсинга JavaScript?
- Меньше JavaScript. Чем меньше скриптов нужно парсить, тем короче фаза парсинга/компилирования.
- Используйте методику code-splitting только для поставки кода, который необходим для направления пользователя по странице, а остальное подгружайте в ленивом режиме. Во многих случаях это поможет избежать парсинга большого количества JS. В реализации такого подхода полезны паттерны наподобие PRPL, который сегодня используют Flipkart, Housing.com и Twitter.
- Используйте потоковую загрузку скриптов (script streaming). Раньше V8 предлагал разработчикам использовать async/defer, чтобы с помощью потоковой загрузки скриптов на 10—20 % уменьшить длительности парсинга. Это как минимум позволяет HTML-парсеру раньше обнаруживать источник, передавать задачу потоку потоковой загрузки и не замедлять парсинг документа. Теперь это делается и для скриптов, блокирующих парсер (parser-blocking), и я не думаю, что есть какие-то другие задачи. При наличии одного streamer-потока V8 рекомендует грузить сначала более крупные пакеты.
- Измеряйте стоимость парсинга зависимостей — библиотек и фреймворков. Везде, где возможно, переходите на зависимости с более быстрым парсингом (например, вместо React лучше воспользоваться Preact или Inferno, которые требуют меньше байтов для начальной загрузки и быстрее парсятся/компилируются). Пол Льюис недавно в своей статье поднял вопрос стоимости начальной загрузки фреймворков. Себастьян Маркбейдж также отметил хороший способ измерения стоимости начальной загрузки фреймворков — сначала отрендерить view, стереть и отрендерить снова, это даст вам понимание его масштабируемости. Первая отрисовка выполняет роль прогрева для лениво компилируемого кода, более крупное дерево которого может получить выгоду от масштабирования.
Если выбранный вами JavaScript-фреймворк поддерживает режим компилирования перед выполнением (ahead-of-time compilation, AoT), то это существенно поможет уменьшить длительность парсинга/компилирования. Например, это идёт на пользу Angular-приложениям:
Выступление Нолана Лоусона «Solving the Web Performance Crisis»
Что делают браузеры для ускорения парсинга/компилирования?
Не только разработчики собирают реальную статистику, чтобы найти, как бы ещё улучшить скорость запуска. Благодаря V8 обнаружилось, что Octane, один из старейших бенчмарков, был плохим прокси для измерения реальной производительности 25 популярных сайтов. Octane может быть прокси:
- для JS-фреймворков (обычно не моно-/полиморфический код),
- для запуска страничных приложений (real-page app) (большая часть кода «холодная»).
Оба эти случая довольно важны для веба, но всё-таки Octane не подходит для всех типов рабочей нагрузки. Команда разработчиков V8 потратила много сил на улучшение времени запуска:
Год от года производительность V8 при запуске JavaScript улучшается примерно на 25 %. Плотнее занялись производительностью реальных приложений.
— @addyosmani
Судя по данным Octane-Codeload, примерно на 25 % улучшилась производительность V8 и при парсинге многочисленных страниц:
В этом отношении улучшились результаты и Pinterest. Также в последние годы есть ряд других подтверждений качественного роста V8 с точки зрения длительности парсинга и компилирования.
Кеширование кода
Из статьи «Использование кеширования кода в V8».
В Chrome 42 появилось кеширование кода — способ хранения локальной копии скомпилированного кода, чтобы при возвращении на страницу пропускались этапы извлечения скриптов, парсинга и компилирования. При повторных визитах это даёт в Chrome примерно 40-процентное ускорение компилирования, но нужно уточнить кое-что:
- Кеширование кода срабатывает для скриптов, которые исполнялись дважды в течение 72 часов.
- Для скриптов Service Worker: то же самое условие.
- Для скриптов, сохраняемых в хранилище скриптов посредством Service Worker, кеширование срабатывает при первом исполнении.
Так что если код должен быть закеширован, то V8 пропустит парсинг и компилирование с третьей загрузки.
Можно поиграть с этим механизмом: chrome://flags/#v8-cache-strategies-for-cache-storage. Также можно запустить Chrome с флагами
— js-flags=profile-deserialization
и посмотреть, загружаются ли объекты из кеша (в логе они представлены как события десериализации).Одно пояснение: кешируется лишь тот код, который компилируется жадно (eagerly compiled). В целом это код верхнего уровня, который выполняется лишь раз, для настройки глобальных значений. Определения функций обычно компилируются лениво и не всегда кешируются. IIFE (для пользователей optimize-js ;)) также включены в кеш кода V8, поскольку они уже жадно скомпилированы.
Потоковая загрузка скриптов (Script Streaming)
Потоковая загрузка скриптов позволяет парсить скрипты асинхронно или с задержкой, перемещая их в отдельный фоновый поток с началом загрузки. Это позволяет примерно на 10 % ускорить загрузку страницы. Как выше отмечалось, этот механизм теперь работает и для синхронизации скриптов.
Сегодня V8 позволяет парсить в фоновом потоке все скрипты, даже блокирующие парсер (parser blocking)
<script src=””>
, так что это всем пойдёт на пользу. Нюанс в том, что фоновый поток — единственный, так что имеет смысл в первую очередь обрабатывать большие / критически важные скрипты. Обязательно проводите измерения, чтобы определить, где можно добиться улучшений.Поскольку
<script defer>
в <head>
, мы можем заранее определить ресурс и затем отпарсить его в фоновом потоке.С помощью DevTools Timeline можно также проверять, применялась ли потоковая передача к правильным скриптам. Если у вас один большой скрипт, на который приходится большая часть времени парсинга, то имеет смысл (обычно) проверить, применяется ли к нему стриминг.
Улучшение парсинга и компилирования
Продолжаем работу с более компактным и быстрым парсером, который освобождает память и эффективнее работает со структурами данных. Сегодня главной причиной задержек основного потока в V8 является нелинейная зависимость стоимости парсинга. Пример UMD:
(function (global, module) { … })(this, function module() { my functions })
V8 не знает, что module точно нужен, так что мы не будем его компилировать в ходе компилирования основного скрипта. Когда мы наконец решим скомпилировать module, то нам нужно будет репарсить все внутренние функции. Это приводит к нелинейности длительности парсинга в V8. Каждая функция на глубине N парсится N раз, что приводит к задержкам.
Разработчики V8 уже работают над сбором информации о внутренних функциях в ходе начального компилирования, так что любые последующие компилирования могут игнорировать свои внутренние функции. Это должно привести к большому росту производительности модульных (module-style) функций.
Подробнее об этом в статье The V8 Parser(s) — Design, Challenges, and Parsing JavaScript Better.
Также разработчики V8 исследуют возможность переноса части компилирования JavaScript во время запуска в фон.
Прекомпилирование JavaScript?
Каждые несколько лет в движках предлагаются способы прекомпилирования скриптов, чтобы нам не приходилось тратить время на парсинг или компилирование возникающего кода (code pops up). Вместо этого во время сборки или на стороне сервера можно просто генерировать байткод. Я считаю, что передача приложению байткода может замедлить загрузку (байткод занимает больше места), и ради обеспечения безопасности вам наверняка придётся подписывать код и процесс. Сегодня разработчики V8 считают, что прекомпилирование не даcт особого выигрыша. Но они открыты для обсуждения идей, которые приведут к ускорению фазы запуска. Разработчики стараются сделать V8 более агрессивным с точки зрения компилирования и кеширования скриптов, когда вы обновляете сайт в Service Worker.
Обсуждение прекомпилирования с Facebook и Akamai, а также мои заметки по этому вопросу можно найти здесь.
«Хак» со скобками Optimize JS для ленивого парсинга
JavaScript-движки оснащены эвристикой ленивого парсинга: большая часть функций в скриптах препарсятся до завершения полного цикла парсинга (например, для поиска синтаксических ошибок). Этот подход базируется на идее, что большинство страниц содержат JS-функции, которые если и исполняются, то лениво.
Препарсинг может ускорить запуск за счёт того, что функции проверяются только на наличие минимально необходимой браузеру информации. Это противоречит использованию IIFE. Хотя для них движки пытаются пропустить препарсинг, эвристика не всегда срабатывает безошибочно, и в таких ситуациях полезны инструменты наподобие optimize-js.
optimize-js заранее парсит скрипты и вставляет скобки, если знает (или предполагает благодаря эвристике), что там функции будут выполнены немедленно. Это ускоряет исполнение. С некоторыми функциями (например, с IIFE!) такой хак работает верно. С другими всё зависит от эвристики (например, в Browserify или Webpack предполагается, что все модули загружаются жадно, а это не всегда соответствует действительности). Разработчики V8 надеются, что в будущем нужда в подобных хаках отпадёт, но сегодня это полезная оптимизация, если вы знаете, что делаете.
Авторы V8 также работают над снижением стоимости ошибок, что в будущем должно снизить пользу от хака со скобками.
Заключение
Стартовая производительность имеет значение. Комбинация медленного парсинга, компилирования и исполнения может стать узким местом для страниц, которые должны грузиться быстро. Измеряйте длительность этих фаз для ваших страниц. Найдите способы их ускорения.
А мы, со своей стороны, продолжим работать над улучшением стартовой производительности V8. Мы обещаем ;)
Полезные ссылки
- Planning for Performance
- Solving the Web Performance Crisis by Nolan Lawson
- JS Parse and Execution Time
- Measuring Javascript Parse and Load
- Unpacking the Black Box: Benchmarking JS Parsing and Execution on Mobile Devices (slides)
- When everything’s important, nothing is!
- The truth about traditional JavaScript benchmarks
- Do Browsers Parse JavaScript On Every Page Load