В среде веб-разработчиков все больше распространяется знание о том, что скорость важна. Многие стараются ускориться: используют сжатие gzip, минификацию, кеширующие заголовки, сокращение запросов, оптимизацию картинок и другие.

После выполнения этих рекомендаций возникает вопрос: а что именно мы оптимизируем? Оказывается, что в большинстве случаев это время полной загрузки страницы со всеми элементами. Однако, это не совсем то, что нужно. На самом деле важно время, за которое пользователь получает «первый экран» страницы с важными функциональными элементами (заголовок, текст, описание товара и т.д.) Другими словами, важен момент начала рендеринга страницы. Здесь и возникает критический путь рендеринга, который определяет все действия, которые должен выполнить браузер для начала отрисовки страницы. С этой штукой мы и будем разбираться в статье.

Что такое критический путь?


Критический путь рендеринга – это набор минимально необходимых для начала отрисовки страницы действий, ресурсов и вычислений.

Критический путь можно измерять в количестве критических ресурсов, минимальном времени загрузки (измеряется в RTT) и объеме критических ресурсов (в байтах).

Для иллюстрации возьмем простейший пример: HTML страницу размером 1 кб без внешних ресурсов. Критический путь будет: 1 ресурс (HTML-документ), 1 RTT (минимально), 1 кб трафика. Однако, таких простых страниц в природе почти не встретить, поэтому покажем, как можно определять критический путь на реальных веб-страницах.

Определение критического пути


Настоящая веб-страница состоит из HTML-документа и некоторого количества внешних ресурсов: CSS-файлы, JS-файлы, шрифты, картинки и т. д. Современные браузеры стараются максимально оптимизировать процесс загрузки страницы, чтобы начать рендеринг как можно быстрее. Однако, браузеры ограничены спецификациями CSS и JS, поэтому должны строить страницу в строгой последовательности. Конечный этап критического пути – построение Render Tree, по которому браузер может начинать рендеринг.

Посмотрим основные шаги, которые включает в себя критический путь:
  1. Получить HTML-документ.
  2. Провести парсинг HTML на предмет включенных ресурсов.
  3. Построить DOM tree (document object model).
  4. Отправить запросы критических элементов.
  5. Получить весь CSS-код (также запустить запросы на JS-файлы).
  6. Построить CSSOM tree.
  7. Выполнить весь полученный JS-код.
  8. Перестроить DOM tree (при необходимости).
  9. Построить Render tree и начать отрисовку страницы.

Из этой последовательности можно сделать несколько важных выводов.

Во-первых в критическом пути участвуют ресурсы c CSS и JS-кодом. Остальные внешние ресурсы там не учитываются.
Во-вторых, JS-код не может выполняться, пока не получены все ресурсы CSS и не построено CSSOM дерево.
В-третьих, страница не может быть отрисована до выполнения JS-кода, так как он может изменять DOM-дерево.
Но не всё так просто: дело, как обычно, в деталях. Наша задача: максимально сократить критический путь рендеринга для нашей страницы.

Способы сокращения критического пути


Для примера возьмем код страницы из демонстрации плагина Autocomplete из jQuery UI.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>jQuery UI Autocomplete - Default functionality</title>
  <link rel="stylesheet" href="http://code.jquery.com/ui/1.11.4/themes/smoothness/jquery-ui.css">
  <script src="http://code.jquery.com/jquery-1.10.2.js"></script>
  <script src="http://code.jquery.com/ui/1.11.4/jquery-ui.js"></script>
  <link rel="stylesheet" href="http://jqueryui.com/resources/demos/style.css">
  <script>
  $(function() {
    var availableTags = [
      "ActionScript",
      "AppleScript",
      "Scheme"
    ];
    $( "#tags" ).autocomplete({
      source: availableTags
    });
  });
  </script>
</head>
<body>
<div class="ui-widget">
  <label for="tags">Tags: </label>
  <input id="tags">
</div>
</body>
</html>

Из каких элементов состоит критический путь этой страницы?
  • Сама страница (HTML).
  • 2 СSS-файла.
  • 2 JS-файла и JS-код в head страницы.

При условии параллельной загрузки JS-файлов получаем 3 RTT (минимально).
Сокращаем критический путь рендеринга. Что можно сделать в этом случае:
  • Объединить два CSS в один файл.
  • Объединить JS в один файл.
  • Поместить вызов JS-файла и встроенный JS-код в конец страницы до /body.
  • Отложить загрузку CSS для элемента autocomplete.

Нужно заметить, что первые две оптимизации актуальны только при использовании обычного HTTP без SPDY или HTTP/2, в которых количество запросов не имеет значения. Так как светлое будущее (HTTP/2) уже не за горами, а SPDY уже настоящее, склейкой файлов заниматься не будем.

Остановимся подробнее на третьей и четвертой оптимизации. Перемещение вызова JS-файла в конец документа позволит браузеру раньше начать рендеринг страницы. Отложенная загрузка CSS от jQuery UI возможна из-за того, что стили из этого файла нужны только для отображения элемента autocomplete (после набора текста в поле).

Вот так будет выглядеть конечный вариант страницы.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>jQuery UI Autocomplete - Default functionality</title>
  <link rel="stylesheet" href="http://jqueryui.com/resources/demos/style.css">
</head>
<body>
 
<div class="ui-widget">
  <label for="tags">Tags: </label>
  <input id="tags">
</div>
 
<script>document.addEventListener("DOMContentLoaded", function(event) {
    var availableTags = [
      "ActionScript",
      "AppleScript",
      "Scheme"
    ];
    $( "#tags" ).autocomplete({
      source: availableTags
    });
  });
</script> 
<script>document.addEventListener("DOMContentLoaded", function(event) { 
$('head').append('<link rel="stylesheet" href="http://code.jquery.com/ui/1.11.4/themes/smoothness/jquery-ui.css" type="text/css">');
});</script>
<script src="http://code.jquery.com/jquery-1.10.2.js"></script>
<script src="http://code.jquery.com/ui/1.11.4/jquery-ui.js"></script>
</body>
</html>

Обратите внимание: вызовы плагина jQuery UI обёрнуты в конструкцию:

document.addEventListener("DOMContentLoaded", function(event) { 
// plugin code
 });

Это позволяет размещать код, зависимый от jQuery и его плагинов в любом месте страницы. Таким же методом выполнена отложенная загрузка CSS-файла.

Теперь критический путь сокращен на 1 запрос (CSS) и не требуется его загрузка (трафик) для начала отрисовки. За счет того, что весь JS-код перемещен в конец документа, браузер может начать отрисовку еще до выполнения этого кода.
Если на странице есть скрипты, которые можно выполнить потом (например, скрипты-счетчики, социальные плагины и т. д.), то выкинуть их из критического пути рендеринга можно атрибутом async:

<script src=1.js async></script>

Однако, с jQuery и его плагинами так поступить не получается (есть способ асинхронной загрузки, но он достаточно муторный). Поэтому используем решение, описанное выше.

Результат


Проверяем, что получилось. Загружаем оба файла в Chrome. Открываем Developer tools, включаем «Toggle device mode», ограничиваем сеть до 450 Kbps 150 ms RT, загружаем без кеша. При загрузке находимся на закладке «Timeline».

Нас интересует момент начала отрисовки страницы (первые события Paint, они отображаются зелёным цветом). Для неоптимизированной версии: 5000 мс, для оптимизированной 500 мс.

Используя описанные приёмы можно значительно ускорить рендеринг ваших страниц, особенно если они насыщены JS-функциональностью и имеют большой объем CSS-кода. При внесении оптимизаций будьте осторожны: тестируйте каждое изменение – изменение порядка загрузки JS-библиотек может сломать функциональность.

Что почитать ещё


Комментарии (18)


  1. jemali_m
    10.07.2015 12:16

    Вы забыли упомянуть показатель TTFB (Time To First Byte), ведь оптимизация загрузки страницы это только определенный шаг. Да, это уже по части серверной оптимизации,… но все же.


    1. Nickmob Автор
      10.07.2015 12:36

      Да, в статье далеко не все рекомендации по ускорению. Такой задачи и не стояло. Кстати, важное дополнение: идеально, если размер критического пути в килобайтах укладывался в 14 кб. Это связано с TCP congestion window размером в 10 пакетов (примерно 14-15 кб).


    1. Aingis
      10.07.2015 16:34

      Time To First что-либо — не очень хорошие метрики. Потому что тот же TTFB легко накрутить послав клиенту первую часть HTML-кода с тайтлом ещё до какой-либо работы бэкенда, что реально даст немногое. Хотя это может помочь тому же критическому пути, если там есть ссылки на нужные CSS и JS. Но в таком случае надо уже собственно критическим путём и заниматься.

      Также и Time To First Paint ни о чём говорит: нарисовался один пиксель или белый фон, всё, метрика сработала. Хотя на экране может ничего не быть. А даже если и есть, интерфейс может не отзываться до загрузки необходимых частей. Поэтому от такой метрики отказываются, а Speed Index — интегральный показатель.


      1. Nickmob Автор
        10.07.2015 16:48

        Согласен, лучше глазами смотреть при хорошо тормознутой сети. В этом плане даже Speed Index не спасает — он тоже считает пиксели и не учитывает работоспособность интерфейса.


  1. pragmadash
    10.07.2015 12:32
    +1

    Посмотрим основные шаги, которые включает в себя критический путь:
    • Получить HTML-документ.
    • Провести парсинг HTML на предмет включенных ресурсов.
    • Построить DOM tree (document object model).
    • Отправить запросы критических элементов.
    • Получить весь CSS-код (также запустить запросы на JS-файлы).
    • Построить CSSOM tree.
    • Выполнить весь полученный JS-код.
    • Перестроить DOM tree (при необходимости).
    • Построить Render tree и начать отрисовку страницы.

    Тут есть некая неточность, процесс далеко не последовательный а итерационный.

    Современные браузеры поддерживают chunked encoding. Это означает, что они получают и парсят HTML-документ частями и сразу же отображают его пользователю браузера тоже частями, на этом, например, основан прогрессивный рендеринг во фреймворке Catberry. Все встречаемые ресурсы страницы, включая CSS и JavaScript загружаются параллельно HTML, что можно увидеть в таймлайне приложения с прогрессивным рендерингом, например, на том же сайте фреймворка здесь. CSS и JavaScript применяются/исполняются синхронно если не указан атрибут async, но как правило JavaScript-код в таком случае, в основном, просто парсится виртуальной машиной, а исполнение основного кода обычно отложено до DOMContentLoaded, что значит рендеринг обычно не сильно задерживается на скриптах и это полностью во власти скрипта.

    В общем, самое важное, что рендеринг не дожидается всей загрузки HTML, достаточно куска HTML и загруженного CSS, после чего пользователь уже видит часть страницы и может с ней взаимодействовать.


  1. Nickmob Автор
    10.07.2015 12:49

    Насчет параллельно загрузки согласен, браузер находит критические ресурсы и пытается загрузить их как можно раньше.
    А вот по поводу рендеринга — нет синхронный (обычный) JS блокирует рендеринг страницы, если его включение находится в начале кода страницы (все, что ниже блокируется до получения кода и его выполнения). Чтобы посмотреть это на практике, можете использовать примеры из статьи.


    1. pragmadash
      10.07.2015 13:01

      А как это противоречит тому, что я сказал выше?

      Я же написал, что парсинг кода, объявление переменных и функций – работает синхронно, но это очень быстрый процесс (который кстати еще больше ускорили в последних версиях Chrome), а основная логика приложения, которая уже начинает работать с DOM, в любом адекватном коде начинается после события DOMContentLoaded, которое уже происходит после полной загрузки страницы, поэтому не блокирует ее загрузку.

      К слову, про ваше решение с добавлением style-элементов после загрузки страницы разве не приводит к полному repaint всей страницы и повторному вычислению всех стилей?


      1. Nickmob Автор
        10.07.2015 13:37
        +1

        Основной прирост от переноса JS вниз страницы даже не из-за отложенного парсинга, а из-за сетевых расходов на скачивание ресурса. В примере статьи два JS это около 240 кб сжатого трафика.
        По парсингу: здесь важно разделять парсинг кода JS и CSS. Пока парсинг именно JS не очень быстрый: в примере из статьи jquery сам по себе выполняется у меня (десктоп) 20 мс, а jquery-ui 56 мс. А теперь представим мобильного пользователя: время парсинга JS легко может выйти за 100-200 мс, а это уже заметно.
        По пересчету стилей при подгрузке: да, она происходит, есть даже перерисовка. Но так, как видимых изменений именно этот CSS не вносит, пользователь даже не видит этих действий. А рабочую страницу получает раньше (экономит 10 кб и один запрос).


        1. pragmadash
          10.07.2015 13:43

          Основной прирост от переноса JS вниз страницы даже не из-за отложенного парсинга, а из-за сетевых расходов на скачивание ресурса. В примере статьи два JS это около 240 кб сжатого трафика.

          Объясните пожалуйста как перенос скрипта вниз страницы уменьшит скачиваемый трафик?

          А теперь представим мобильного пользователя: время парсинга JS легко может выйти за 100-200 мс, а это уже заметно.

          По пересчету стилей при подгрузке: да, она происходит, есть даже перерисовка. Но так, как видимых изменений именно этот CSS не вносит, пользователь даже не видит этих действий.

          Вам не кажется, что вы себе противоречите?
          Для мобильного пользователя как раз перерисовка и повторное вычисление всех стилей будет очень даже ощутимо.


          1. Nickmob Автор
            10.07.2015 13:48

            Объясните пожалуйста как перенос скрипта вниз страницы уменьшит скачиваемые трафик?


            Общий трафик никак, трафик до начала рендеринга страницы: на размер ресурса.

            Вам не кажется, что вы себе противоречите?
            Для мобильного пользователя как раз перерисовка и повторное вычисление всех стилей будет очень даже ощутимо.


            Я думаю, что перерисовка и вычисление стилей будет быстрее выполнения JS-кода и тем более быстрее сети (особенно мобильной). Это как общее правило. А на самом деле нужно тестировать в реальных условиях (на устройстве, с ограничением скорости сети).


  1. lavkasnov
    10.07.2015 13:04
    +2

    По моему в стаьтье допущена серьезная ошибка — отложенная загрузка. В большинстве случаев (в приведенных примерах точно) не имеет смысла, а скорее всего приведет как раз к задержкам при повторном открытии страницы. Вот смотрите: при первом открытии страницы, когда файлы еще не закешированы, отложенная загрузка даст некоторый прирост. При последующих открытиях страницы все js и css будут уже в кеше браузера и никакие запросы к серверу не уйдут. Если у вас настроены кеш заголовки иначе, то я вам сочуствую и не может идти речи ни о какой оптимизации. При повторных открытиях будут выполнятся скрипты для загрузки стилей и скриптов, которые уже и так в браузере и могли бы уже к этому времени распарситься. По моему имеет смысл откладывать загрузку css со шрифтами и то указывая другой media типв теге, чтобы не блокироваться.


    1. Nickmob Автор
      10.07.2015 13:43

      Специально для вас посмотрел: выполенние JS-инструкции о подгрузке CSS-файла занимает 0.8 мс. По-моему не страшная задержка. Если файл в кеше, то и здесь он не будет загружаться снова. Пересчет стилей после подгрузки CSS 2 мс.
      Заметьте, что я отложил загрузку CSS именно для элементов, которые не показываются при начальном рендеринге страницы. Откладывать загрузку CSS с важными стилями (например, шрифтами): плохая идея, получится flash of unstyled content. По крайней мере, это нужно подробно тестировать.


  1. gogolinsky
    10.07.2015 23:50
    -2

    Можно весь CSS поместить в style в HTML


    1. Nickmob Автор
      11.07.2015 06:32
      +3

      Не надо так делать. Убивается весь эффект от кеширования, раздувается HTML.


    1. megahertz
      13.07.2015 10:08
      -1

      Допустимо только разместить критический CSS при условии что он очень мал


      1. karena
        15.07.2015 11:28

        В smashingmagazine так и делают (посмотрие лекцию Виталя Фридмана) — с помощью phantomjs, если я правильно помню, выявляются стили, которые необходимы для отображения первых 1000px по высоте и кладут их в html.


        1. Nickmob Автор
          15.07.2015 11:49

          Inline CSS — это уже очень тонкая оптимизация. По-моему, так стоит делать только, если основной CSS в сжатом виде весит больше 10 кб. Если меньше, то скорее всего HTML и этот CSS уложатся в первые 10 TCP-пакетов (15 кб), то есть загрузка этой части будет достаточно быстрая.


  1. onthefly
    18.07.2015 22:52

    Посмотрим основные шаги, которые включает в себя критический путь:
    1. Получить HTML-документ.
    2. Провести парсинг HTML на предмет включенных ресурсов.
    3. Построить DOM tree (document object model).
    4. Отправить запросы критических элементов.
    5. Получить весь CSS-код (также запустить запросы на JS-файлы).
    6. Построить CSSOM tree.
    7. Выполнить весь полученный JS-код.
    8. Перестроить DOM tree (при необходимости).
    9. Построить Render tree и начать отрисовку страницы.

    Из этого утверждения может сложиться ложное впечатление, будто браузер никогда не отрисовывает страницы до момента завершения исполнения JS. Представленный алгоритм справедлив только для частных случаев, при которых внешние скрипты подключаются в теге head либо в начале тега body и не имеют атрибутов async.