[Перевод] Руководство по быстрой загрузке страниц
Мы в 1cloud занимаемся построением облачного сервиса — наши пользователи могут заказывать у нас виртуальные серверы, и очень часто на них запускаются сайты. Но не всегда они загружаются быстро (хотя со своей стороны мы прикладываем максимум усилий к повышению быстродействия и надежности). Как ускорить загрузку страниц?
Решить эту проблему помогут советы из материала эксперта по Ruby Нейта Беркопеца (Nate Berkopec) — мы представляем вашему вниманию адаптированный перевод заметки.
Примечание: Это технически сложный текст, так что если вы заметите ошибку или неточность перевода — напишите нам, и мы все поправим, чтобы сделать материал лучше.
Предисловие
Ваш сайт медленный, но бэкенд быстрый. Как диагностировать проблемы с производительностью на фронтенде? Мы обсудим все аспекты, связанные с конструированием веб-страниц, и затронем вопросы их профилирования на миллисекундных интервалах с помощью Chrome Timeline.
Время ответа сервера очень просто замерить, но это бесполезная метрика с точки зрения конечного пользователя. Пользователю все равно, насколько быстр ваш супер-пупер-мега-Node.js-сервер — ему нужно лишь, чтобы страница полностью загружалась так быстро, как только возможно. Руководитель проекта наседает на технарей, требуя увеличить скорость работы сайта — но тем нечего ему ответить, потому что их микросервисная архитектура позволяет добиться время ответа в 10 наносекунд. Что не так-то?
Для начала, давайте рассмотрим, что на самом деле нужно для конструирования веб-страницы. Сервер должен ответить с помощью HTML (нужно учесть задержки сети и время раундтрипа), нужно распарсить JS, CSS и HTML, осуществить рендеринг и отрисовку, а также нужно выполнить весь связанный со страницей Javascript-код. Короче, нужно сделать много всего. И время ответа сервера, обычно, относится лишь к малой части этой работы, иногда даже меньше 10%. Вдобавок, любой из перечисленных ниже шагов может пойти не так, как задумывалось:
- Время ответа может резко увеличиться при ненадлежащем использовании кеширования, как на уровне приложения, так и на уровне HTTP. Плохие SQL-запросы в определенных частях приложения также могут значительно замедлить работу.
- Ресурсы JS и CSS должны быть конкатенированы, минифицированы и расположены в правильном месте документы, иначе может не удастся рендеринг, а браузер остановит подгрузку внешних ресурсов (об этом ниже). Вдобавок, в наши дни популярность JQuery и CSS привела к тому, что многие разработчики даже не задумываются о том, сколько JS и CSS на самом деле загружаются на каждой странице. Даже при использовании минификации и gzip, ужатые до <100 килобайт CSS и JS после распаковки все равно надо распарсить и загрузить, чтобы создать DOM и CSSOM. Размер ужатого gzip файла важен для того, чтобы понять, сколько времени CSS или JS будет идти по сети, но распакованный размер важнее для того, чтобы понять, сколько понадобится клиенту на парсинг и конструирование страницы.
- У веб-разработчиков (особенно не джаваскриптеров, а например у разработчиков Rails) есть ужасная привычка помещать кучу кода в
$(document).ready();
или пытаться загрузить Javascript как-то еще. Все это приводит к необходимости выполнения куч Javascript на каждой странице, еще больше замедляя загрузку.
Так что же должен делать хороший разработчик? Как можно увеличить скорость загрузки до смешных величин?
Вместо того, чтобы сказать вам, что техника XYZ работает быстрее, чем другая, мы на примерах рассмотрим, как это сделать, и почему это работает. Не надо верить никому на слово, нужно тестировать самому. И для этого нам понадобится инструмент для профилирования.
Знакомимся с Chrome Timeline
Отличное средство для определения производительности фронтенда — это Chrome Timeline. Инструмент вроде RUM (real user monitoring) от New Relic позволяют получить общее представление о том, как для пользователя загружается страница, а Chrome Timeline дает разработчику помиллисекундную разбивку того, что действительно происходит в ходе каждого конкретного веб-взаимодействия. Его можно использовать не только для анализа загрузки страниц, но и для профилирования взаимодействий Javascript после ее завершения.
Важно учитывать, что вся документация Google по Chrome Timeline просто феерически устарела и показывает «водопадный» взгляд, которого в Chrome уже вообще нет, так что наш пост будет самой актуальной инструкцией по работе с инструментом.
Чтобы открыть Chrome Timeline, нужно зайти в меню Chrome Developer Tools (CMD + Alt + I на Маке) и выбрать вкладку Timeline. После этого вы увидите пустой таймлайн с отметками миллисекунд. Нужно убрать галку из чекбоксов «causes», «paint» и «memory» и выключить счетчик FPS, нажав на иконку графика. Эти функции полезны при профилировании JS-приложений на стороне клиента, что за рамками нашей статьи.
Вот так должны выглядеть настройки
Chrome Timeline записывает взаимодействия страницы, похоже на VCR. Чтобы запустить его, можно кликнуть на кнопку записи в любой момент, повторный клик остановит запись. Если Timeline открыть в процессе перезагрузки, то запись будет идти до конца загрузки.
Проверим его на примере страницы http://todomvc-turbolinks.herokuapp.com/
. Это имплементация TodoMVC. Когда Timeline открыт, можно запустить полную загрузку страницы клавиша CMD + Shift + R, Chrome автоматически запишет процесс загрузки.
Важный момент: в Chrome Timeline показываются и расширения браузера. Любое расширение, которое изменяет страницу, может попасть в мониторинг, сделав данные более запутанными. Лучше предварительно отключить все расширения, чтобы они не мешали профилированию.
Начнем с прогулки по типичному пути загрузки HTML-страницы, а затем разберемся с тем, что это все значит с точки зрения скорости, и как можно ее увеличить.
Timeline выглядит примерно так:
254 миллисекунды от обновления страницы до его завершения — неплохо для старенького Rails-приложения, да?
Получение HTML
Прежде всего, обратим внимание на большой кусок свободного времени в начале. Почти ничего не происходит на протяжение 67 миллисекунд после начала перезагрузки. Что происходит? Это комбинация времени ответа сервера (у этого конкретного приложения оно составляет около 20 миллисекунд) и задержки сети (в зависимости от того, как далеко вы от Восточного Побережья США, составит от 100 до 300 мс).
Несмотря на то, что мы живем в эпоху оптоволокна и огромного количества кабелей, на путешествие HTTP-запросов все равно тратится много времени. Даже при теоретической максимальной скорости HTTP-запроса (скорость света) пользователю из Сингапура понадобится 70 миллисекунд на путь до сервера в США. А HTTP не идет со скоростью света — кабельный интернет работает на вполовину меньше скорости. Кроме того, запросы совершают множество промежуточных остановок по пути — их можно увидеть с помощью traceroute. Узнать среднюю сетевую задержку для конкретного сервера можно с помощью ping (для этого он и нужен!)
К примеру, кто-то живет в Нью-Йорке. Если он «пинганет» сервер времени NIST в Орегоне, то увидит задержку в 100 мс. Значительно больше, чем в случае, когда, как мы предполагали, пакеты летят со скоростью света (~ 26 мс). Для сравнения, средняя сетевая задержка для временного сервера в Пенсильвании составит 20 мс. А в Индонезии? Раундтрип пакетов составит огромные 364 мс. Для сайтов, которые стараются удерживать время загрузки страницы меньше 1 секунды, это показывает важность наличия географически распределенных зеркал сетей доставки контента (CDN).
Давайте взглянем подробнее на первое событие в Timeline — оно случает на середине большого периода простоя. Событие называется “Receive Response”. Также могут показаться разные события, связанные с незагрузкой страницы — Receive Data и, наконец, Finish Loading. Что происходит в этом месте?
Сервер начал отвечать на наш запрос, когда вы видите первое событие Receive Response. По мере прихода байтов по проводу появятся события Receive Data, в конце все венчается событием Finish Loading. Этот паттерн будет возникать для любых ресурсов, которые нужны для загрузки страницы — изображений, CSS, JS и т.п. После завершения загрузки, можно переходить к парсингу.
Парсим HTML
«Парсинг HTML» звучит, как что-то простое, но Chrome (и любой другой браузер) должен для этого выполнить много работы. Браузер считывает байты HTML из сети (или с диска, если вы просматриваете страницу с компьютера) и конвертирует их в кодировку UTF-8 или другую выбранную пользователем. Затем браузер «токенизирует» — вычленяет из длинных строк текста теги вроде <img>
и <a>
. Браузер конвертирует строку HTML весом в ~100kb в массив из нескольких строк. Затем он преобразует эти строки в объекты и, наконец, конструирует из них DOM. На сложных страницах эти шаги могут занимать много времени — например, только на парсинг страницы издания The Verge займет где-то 200мс. Ничего себе.
Также можно увидеть два события Send Request (они очень маленькие) ниже события Parse HTML. Если вы еще не поняли, то о чем мы говорим, называется flamegraph. События располагаются под другими событиями, когда первое вызвано вторым. Два события Send Request вызывают Javascript и CSS-файлы, связанные с заголовком. У нас Rails-приложение, так что таких файлов по одному.
Две небольшие голубые черточки — это отправленные запросы HTML и CSS
Вдобавок, Javascript-файл помечен атрибутом async:
<script src="/assets/application-0b54454ea478523c05eca86602b42d6542063387c4ee7e6d28b0ce20f5e2c86c.js" async="async" data-turbolinks-track="true"></script>
Обычно, когда браузер видит Javascript-тег вроде этого в заголовке, он полностью останавливается до окончания загрузки и оценки скрипта. Если скрипт удаленный, то нужно ждать, пока он загрузится. Это может занять много времени — даже больше чем целая секунда, если включить еще и сетевую загрузку и время на оценку скрипта. Причина того, почему браузеры ведут себя именно так, заключается в том, что Javascript может модифицировать DOM — каждый раз, когда браузер видит тег скрипта, он должен выполнить весь процесс, иначе он может изменить шаблон или DOM.
Поскольку скрипт был помечен атрибутом async, этого не происходит — браузер не остановится для загрузки оценки Javascript. Это позволяет серьезно выиграть в скорости.
Браузер не станет ждать внешнего CSS прежде чем продолжить работу. Если задуматься, то в этом есть смысл. CSS не меняет DOM, он только стилизует его и делает более симпатичным.Более того, чтобы применить CSS, предварительно нужно сконструировать DOM. Так что браузер просто посылает запрос на CSS и переходит к следующему шагу.
Необходимо помнить, что шаг парсинга HTML будет возникать каждый раз, когда браузеру понадобиться разобрать новый HTML — например, для AJAX-запроса.
Пересчет стилей
Следующее крупное событие в таймлайне называется Recalculate Styles (выделено фиолетовым цветом). Это событие охватывает множество вещей, которые происходит в процессе конструирования страницы. Прежде всего, сюда относится конструирование CSSOM.
HTML преобразуется в DOM, а CSS — в CSSOM. После загрузки CSS должен быть конвертирован -> токенизирован -> преобразован -> сконструирован. Все, как в случае HTML. Этот процесс длится на протяжении всех баров события Recalculate Styles в таймлайне.
Пересчет стилей также значит, что с CSS происходит и множество других запутанных вещей., например «рекурсивное вычисление обрабатываемых стилей», чтобы это ни значило. Суть в том, что если событие Recalculate Styles идет слишком долго, то CSS-код слишком сложный. Нужно убрать из него ненужные и необязательные правила.
Почему мы видим события Recalculate Styles, когда CSS еще даже не загружен? Браузер применяет к документу дефолтный CSS, также могут применяться любые атрибуты style из HTML-кода (один из распространенных display: none).
Возможно, вы увидите и больше фиолетовых событий (Recalculate Styles и его собрат Layout) позднее в таймлайне. И снова браузер не ждет CSS для завершения загрузки — он уже рассчитывает стили и шаблоны на основе только лишь HTML-разметки и установок браузера по умолчанию. События рендеринга возникнут после завершения загрузки CSS.
Шаблон (Layout)
Немногим после первого события Recalculate Styles должно появиться фиолетовое событие Layout. К этому моменту ваш браузер должен иметь в памяти все нужные DOM и CSSOM, которые нужно преобразовать в пиксели на экране.
Браузер проходит по видимым элементам DOM (дерево рендеринга) и выясняют видимость каждого узла, применимые CSS-стили и относительную геометрию (50% ширины родительского объекта и т.п.) Сложный CSS-код очевидным образом сделает этот этап длиннее, как же и сложный HTML.
Большое количество событий Layout в ходе загрузки страницы может приводить к так называемой «молотьбе шаблонов» (layout thrashing). Каждый раз, как вы изменяете геометрию объекта (высоту, ширину и т.п.), запускается событие Layout. И браузер не может сказать, какую часть страницы надо пересчитать. Поэтому ему приходится пересчитывать шаблон для всего документа. Этот процесс особенно медленно выполняется для шаблонов с плавающими элементами. Явление Layout thrashing обычно вызвано взаимодействием Javascript c DOM, еще его может вызывать использования различных файлов стилей. Подробнее об этом можно почитать здесь.
Молотьба шаблонов, как она есть
Резюмируя — в ходе шага Layout, браузер вычисляет, что видимо, что нет, и где эти элементы должны располагаться на странице.
DomContentLoaded
К этому моменту вы должны увидеть голубой бар в Timeline — это событие DomContentLoaded. На данном этапе браузер уже закончил парсинг HTML, а также запуск и блокирование Javascript (Javascript либо встроен в страницу, либо есть скрипт тег, не помеченный атрибутом async). Большинство браузеров также к этому моменту еще ничего не нарисовали на экране.
Чтобы ускорить DomContenLoaded можно проделать несколько вещей:
- Проставить для скриптов атрибуты async где только возможно. Перемещение тегов скрипта в конец документа не поможет увеличить скорость, DomContentLoaded, поскольку браузеру все равно нужно оценивать Javascript до выполнения конструкции DOM. Async означает, что единственной синхронно выполняющейся частью скрипта будет начало загрузки самого скрипта, его выполнение будет отложено на потом. Илья Григорик считает, что использование тегов async является более чистым и эффективным способом, чем использование так намазываемой инъекции async-скриптов.
- Использовать менее сложную HTML-разметку.
- Избегать «молотьбы шаблонов» (см. выше). Не нужно использовать более одного файла стилей!
- При модерации осуществлять инлайнинг стилей. Это означает, что браузер может попытаться распарсить файл стилей прежде чем перейти к обработки остальной части документа. Google рекомендует осуществлять инлайнинг только стилей, которые нужны для отображения контента после этого момента. Это замедлит DOMContentLoaded, но ускорит событие загрузки окна (load). Может быть и так, но не нужно инлайнить весь CSS. Кроме того, узнать, какое CSS-правило нужно для контента, расположенного выше, во фреймворках типа Booststrap кажется непростой задачей. Необходимо рендерить весь CSS «выше сгиба». Так что правило должно быть таким — не нужно осуществлять инлайнинг всего CSS, если только у вас его не меньше 50 килобайт. Как только HTTP2 получит большее распространение и мы сможем загружать по одному и тому же соединению CSS, HTML и JS, такая оптимизация больше не понадобится.
Отрисовка
Продвигаясь по таймлайну вправо вы должны начать замечать зеленые бары. Это события Paint. В них может происходит много всего (и в Chrome даже есть инструменты профилирования только для событий отрисовки), но так глубоко в нашей статьей мы копать не будем. Все, что вам нужно знать, это что события Paint возникают после того, как браузер заканчивает рендеринг (фиолетовые бары, процесс преобразования CSS и HTML в шаблон) и должен преобразовать шаблон в пиксели на экране.
Зеленый бар в таймлайне это первая отрисовка — первый раз, когда что-то появляется на экране. Оптимизация первой отрисовки большей частью относится к оптимизации DOMContentLoaded и передачи стилей на клиент с максимальной скоростью. Любая таблица стилей, в которой не определен медиазапрос (например, print) заблокирует рендеринг страницы до момента, пока она не будет загружена и не осуществлен парсинг.
Парсинг авторских стилей
Продолжим проматывать таймлайн вправо. Наверняка вы заметили, как долго длится этот этап. В нашем случае 40 миллисекунд ушло только на то, чтобы дождаться полной подгрузки файла стилей (а у этого приложения он совсем невелик). Чтобы быть совсем точным, мы отправили запрос для подгрузки стилей на 65 миллисекунде, и он пришел только на 101-й. Конечно, это все равно очень быстро — в реальном приложении время будет больше — как минимум 200-350 мс — и тут больше нечего оптимизировать. Также не стоит забывать про сетевую задержку.
Как только файл стилей загружен, происходит его парсинг. Появится еще один цикл фиолетовых (пересчитываются CSSOM, повторно рендерится шаблон) и зеленых событий (когда шаблон обновлен, результат рендерится на экран).
Стили нашего приложения очень просты, но все равно приложение тратит почти 30 мс на ожидание загрузки CSS. Возможно, имеет смысл изучить влияние на производительность инлайнинга всего файла стилей в секцию HEAD страницы. Большинству сайтов такая оптимизация ничего не даст, но поскольку приложение бездействует 20 мс в ожидание загрузки стилей, можно попробовать снизить сетевой раундтрип.
Javascript
В конце концов загрузка Javascript завершится (событие Finish Loading для JS-файла). Спустя миллисекунду-другую вы увидите большой желтый бар Evaluate Script. График баров здесь начнет уходить глубже. В нашем случае, сложно сказать, в чем здесь дело, потому что Javascript был минифицирован. Но на этапе разработки с еще не минифицированным кодом можно многое узнать о том, почему тратится столько времени.
В нашем случае приложение очень простое, но поскольку в нем задействовано приличное количество Javascript, то 76 мс ушло только на его парсинг и оценку. Это будет происходить при каждой загрузке страницы, также процесс будет занимать вдвое дольше на мобильных браузерах.
После оценки скрипты, вероятно, вы увидите пару баров событий Recalculate Style и Paint. Они вызваны тем, что Javascript пытается еще что-то поменять в шаблоне.
Наконец, появится событие load. Почти для каждого приложения к этому событию будут привязаны несколько Javascript-функций. Как только все вызовы, связанные с load, завершатся, появится красный бар, который сигнализирует о завершении load. Вот теперь страница окончательно загружена.
Использование Chrome Timeline для поиска проблем скорости
Допустим, у вас сайт, которому требуется 5-10 секунд на то, чтобы добраться до события load. Как можно использовать Timeine для профилирования и поиска узких мест для оптимизации?
- Нужно сделать жесткое обновление (Ctrl+Shift+R) и загрузить в Timeline свежие данные.
- Следует просмотреть график-пирог для всего процесса загрузки. После жесткой перезагрузки Chrome покажет агрегированную статистику для всего процесса загрузки страницы в виде пирога. В нашем примере 2,23 секунды ушло от перезагрузки до события load. Таким образом можно увидеть этапы, на которые тратится много времени — проблема в парсинге, разборе скрипта, рендеринге или отрисовке? Или слишком велико время простоя?
- Важно сократить время простоя. Причиной простоя может быть медленный ответ сервера или запросы ресурсов. Если много времени тратится просто так, то нужно проверить скорость работы сервера. Если тут все ок, то возможно есть неоптимизированные ресурсы (см. секцию DomContentLoaded).
- Сокращение загрузки. В этом контексте «загрузка» означает время, потраченное на парсинг HTML и CSS. Чтобы снизить это время есть не так много способов — основной из них это снижение объема HTML и CSS-кода, отправляемого клиенту.
- Ускорение разбора. Время, потраченное на оценку скриптов, обычно представляет большую часть времени загрузки страницы, кроме ожидания сети. Большая часть сайтов использует несколько разных JavaScript-плагинов вроде Olark или Mixpanel. Лучше добавить для таких скриптов, где только возможно, тег async, чтобы убрать их из пути рендеринга — даже если разработчики утверждают, что их скрипт и так async.
- Ускорение рендеринга и отрисовки. Иногда изменения в шаблон и необходимость повторного рендеринга возникает из-за использования средств вроде Optimize.ly. Это сложный момент — основная задача Optimize.ly заключается в серьезном изменении контента на странице, так что использование для него тега async может привести к «вспышке нестилизованного контента», при которой одна часть страницы будет выглядеть отлично от другой. Это неприемлемо, так что в случае Optimize.ly придется страдать от падения скорости.
TL:DR;
Ниже представлено несколько советов по ускорению загрузки страниц:
- Должен быть только один удаленный JS-файл и один удаленный CSS-файл. Если вы используете Rails, то это сделано заранее. Помните, что каждый маркетинговый инструмент вроде Olark или Optimizely попытается встроить скрипты и стили в вашу страницу, замедляя ее. Помните, что цена использования этих средств не равняется нулю. Нет никакого оправдания загрузки разных CSS и JS-файлов со своего домена. Наличие только одного экземпляра таких файлов снижает сетевые задержки — для пользователей во многих случаях это критично (например, если они из другой стран или сидят через мобильную сеть). Кроме, того большое количество файлов стилей приводит к возникновению «молотьбы шаблонов».
- Все должно быть Async! Асинхронные JS-приложения, которые загружают и вставляют на страницу собственные скрипты (как тот же Mixpanel) не являются по-настоящему асинхронными. Использование атрибута async для скриптов позволит всегда получать выигрыш в скорости. Атрибут не влияет на инлайновые JS-теги (без атрибута src), так что может понадобиться вынести элементы вроде Mixpanel в удаленный файл, расположенный на собственном сервере (в Rails, можно поместить его в application.js) и затем поставить атрибут async уже для него. Использование async для внешних скриптов позволяет вывести из под блокировки рендеринга, так что страница будет рендериться без ожидания оценки этих скриптов.
- CSS идет перед JavaScript. Если есть железная необходимость размещения внешнего JS на своей странице и нельзя использовать тег async, то внешний СSS должен располагаться перед JS-кодом. Внешний CSS не блокирует дальнейшую обработки страницы, в отличие от JS. Нужно отправить все наши запросы до того, как мы начнем ожидать загрузки удаленного JS.
- JavaScript не бесплатен. Неважно, как мал ваш JS после обработки gzip’ом — каждый дополнительный JS приводит к переоценке каждой загрузки страницы. Даже если браузеру нужно загрузить JavaScript только однажды и потом использовать закешированную копию, ему все равно понадобится оценивать весь этот код при каждой загрузке страницы. Не верите? Посмотрите на сайт The Verge и на то, сколько времени их страницы тратят на выполнение JavaScript.
$(document).ready
не бесплатен. Каждый раз, как вы добавляете что-то в документ, вы добавляете выполнение скрипта, которая отодвигает окончание загрузки страницы. Нужно смотреть на Chrome Timeline в поиске события load — если оно длинное и бар уходит в глубину, то следует изучить возможность привязки к готовности документа меньшего количества событий. Может быть, обработчики можно связать с DomContentLoad?