CSS и производительность сети

Перевод статьи CSS and Network Performance с сайта csswizardry.com, опубликовано на css-live.ru с разрешения автора — Гарри Робертса.

Несмотря на то, что сайт уже больше десяти лет называется «CSS-волшебство», за последнее время на нём не было ни одной статьи, связанной с CSS. Давайте я это исправлю, совместив две мои любимые темы: CSS и производительность.

CSS критически важен для отображения страницы — браузер не начнет рендеринг, пока не найдет, загрузит и распарсит весь CSS — поэтому крайне важно как можно скорее получить его на устройстве пользователя. Любая задержка на критическом пути скажется на нашей начальной отрисовке, заставив пользователя видеть пустой экран.

В чём главная проблема?

Собственно, вот почему CSS так важен для производительности:

  1. Браузер не может отобразить страницу до построения дерева отрисовки;
  2. дерево отрисовки получается из DOM и CSSOM вместе взятых;
  3. DOM — это HTML плюс любой блокирующий JavaScript, который на него влияет;
  4. CSSOM — все CSS-правила, применённые к DOM;
  5. с помощью атрибутов async и defer можно легко сделать JavaScript неблокирующим;
  6. сделать CSS асинхронным намного сложнее;
  7. поэтому важно помнить, что скорость загрузки страницы определяется самой медленной таблицей стилей.

Учитывая это, нам нужно максимально быстро построить DOM и CSSOM. DOM по большей части строится относительно быстро: первый же ответ сервера на запрос браузером HTML-страницы – это и есть DOM. Однако, поскольку CSS почти всегда отдельный ресурс от HTML, на построение CSSOM обычно уходит гораздо больше времени.

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

Используйте минимально необходимый CSS

Если есть такая возможность, один из эффективнейших способов снизить время до первой отрисовки – воспользоваться паттерном «минимально необходимый CSS»: определить все стили, необходимые для начальной отрисовки (обычно это стили для всего, что попадает на первый экран), вставить их прямо в теги <style> в <head> документа, а остальные стили подгружать асинхронно, отдельно от критического пути.

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

Разделяйте свои медиавыражения по типам

Итак, если критический CSS нам не по силам – как скорее всего и окажется – есть вариант попроще, разделить основной CSS-файл на отдельные медиавыражения в нем. Практический результат в том, что браузер будет…

  • загружать любой CSS, нужный для текущего контекста (тип устройства, размер экрана, разрешение, ориентация, и т.д) с крайне высоким приоритетом, блокирующим критический путь, и;
  • загружать любой CSS, ненужный для текущего контекста с очень низким приоритетом, никак не затрагивая критический путь.

По сути, любой CSS, ненужный для отображения текущего представления, фактически загружается браузером отложенно.

<link rel="stylesheet" href="all.css" />

Если положить весь CSS в один файл, то вот как сеть поступит с ним:

Заметьте, что у единственного CSS-файла наивысший приоритет.

Если можно разделить один файл, полностью блокирующий всю отрисовку, на отдельные медиавыражения из него:

<link rel="stylesheet" href="all.css" media="all" />
<link rel="stylesheet" href="small.css" media="(min-width: 20em)" />
<link rel="stylesheet" href="medium.css" media="(min-width: 64em)" />
<link rel="stylesheet" href="large.css" media="(min-width: 90em)" />
<link rel="stylesheet" href="extra-large.css" media="(min-width: 120em)" />
<link rel="stylesheet" href="print.css" media="print" />

То мы увидим, что сеть ведет себя с файлами по-разному

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

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

Избегайте @import в CSS-файлах

Следующее, чем мы можем помочь начальной отрисовке, гораздо, гораздо проще. Не используйте @import в своих CSS-файлах.

@import по своей природе медленный. Это крайне плохо для производительности начальной отрисовки. Это потому, что мы создаем больше запросов к серверу во время критической начальной загрузки.

  1. Скачиваем HTML;
  2. HTML запрашивает CSS;
    • (К этому моменту хорошо бы уже начать строить дерево отображения, но;)
  3. CSS запрашивает ещё CSS;
  4. строим дерево отображения.

Если взять следующий HTML:

<link rel="stylesheet" href="all.css" media="all" />

… и содержимое all.css:

@import url(imported.css);

… каскадная диаграмма в итоге будет такой:

Явное отсутствие распараллеливания во время критической начальной загрузки

Если просто превратить это в плоскую структуру из двух <link rel="stylesheet" /> и нуля директив @import:

<link rel="stylesheet" href="all.css" />
<link rel="stylesheet" href="imported.css" />

… то мы получим гораздо разумную каскадную диаграмму:

Критический CSS начинает загружаться параллельно.

Примечание. Хочу кратко обсудить одно нетипичное исключение. Если вам вдруг выпадет такой случай, что к CSS-файлу с @import нет доступа (то есть удалить его оттуда нельзя), можно без вреда оставить его там же в CSS, но также дополнить разметку соответствующим <link rel="stylesheet" /> в вашем HTML. Это значит, что браузер будет инициировать загрузку импортируемого CSS из HTML, пропустив @import: двойной загрузки не будет.

Остерегайтесь @import в HTML

Это странный раздел. Очень странный. Я провалился в настолько глубокую кроличью нору, исследуя эту тему… В Blink и WebKit всё поломано, потому что в них баг; в Firefox и IE/Edge только кажется, что поломано. Я завел баги про это в их багтрекерах.

Чтобы полностью понять этот раздел, сначала нужно знать о сканере предварительной загрузки в браузере: во всех основных браузерах реализован вспомогательный, облегченный парсер, называемый обычно «Сканер предварительной загрузки» (Preload Scanner). Основной парсер в браузере отвечает за создание DOM, CSSOM, запуск JavaScript и так далее, и он постоянно приостанавливается по мере того, как он блокируется разными частями документа. Сканер предварительной загрузки может спокойно забегать вперед основного, сканируя остальной HTML в поисках ссылок на другие подресурсы (такие как CSS-файлы, JS и изображения). После их обнаружения сканер предварительной загрузки начинает загружать их, готовый к тому, чтобы основной парсер потом мог подхватить их уже готовыми для использования. Внедрение сканера предварительной загрузки улучшило производительность веб-страниц примерно на 19%, причём разработчикам даже не пришлось и пальцем пошевелить. Это отличная новость для пользователей!

Единственное, за чем нам, разработчикам, нужно следить — чтобы нечаянно не скрыть чего-нибудь от сканера предварительной загрузки, как иногда бывает. Подробнее об этом позже.

В данном разделе рассматриваются баги в сканере предварительной загрузки в WebKit и Blink, а также его неэффективность в Firefox’s и IE/Edge.

Firefox и IE/Edge: поместите @import перед JS и CSS в HTML

В Firefox и IE/Edge сканер предварительной загрузки, похоже, не подхватывает какие-либо директивы @import, определённые после <script src=""> или <link rel="stylesheet" />

Поэтому этот HTML:

<script src="app.js"></script>

<style>
  @import url(app.css);
</style>

… даст вот такую каскадную диаграмму:

Потеря распараллеливания в Firefox из-за неработающего сканера предварительной загрузки (примечание: точно такая же каскадная диаграмма получается в IE/Edge).

Здесь хорошо видно, что таблица стилей из @import не начинает загружаться до завершения JavaScript-файла.

Эта проблема – не что-то уникальное для JavaScript. С таким HTML всё так же:

<link rel="stylesheet" href="style.css" />

<style>
  @import url(app.css);
</style>

Потеря распараллеливания в Firefox из-за неэффективного сканера предварительной загрузки (примечание: точно такая же каскадная диаграмма получается в IE/Edge).

Быстрое решение этой проблемы — поменять местами блоки <script> или <link rel="stylesheet" /> и <style>. Однако, из-за этого, что-то наверняка может сломаться, поскольку мы меняем порядок зависимостей (читай, каскад).

Правильное решение этой проблемы — вообще обходиться без @import и использовать второй <link rel="stylesheet" />

<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="app.css" />

Гораздо лучше:

Два <link rel="stylesheet" /> восстанавливают параллельность. (примечание: точно такая же каскадная диаграмма получается в IE/Edge).

Blink и WebKit: оборачивайте адреса ссылок в @import внутри HTML в кавычки

В WebKit и Blink та же картина, что в Firefox и IE/Edge, получается только если у ссылок в @import нет кавычек. Это значит, что в сканере предварительной загрузки в WebKit и Blink есть баг.

Если просто добавить кавычки, проблема решится, и не нужно будет ничего переупорядочивать. И всё же, как и ранее, мой совет здесь — вообще обойтись без @import, а вместо него поставить второй <link rel="stylesheet" />.

До:

<link rel="stylesheet" href="style.css" />

<style>
  @import url(app.css);
</style>

… даёт:

Без кавычек в ссылках в @import сканер предварительной загрузки в Chrome у нас поломается (примечание: точно такая же каскадная диаграмма получается в Opera и Safari.)

После:

<link rel="stylesheet" href="style.css" />

<style>
  @import url("app.css");
</style>

Если добавить кавычки в ссылки в @import, то это починит сканер предварительной загрузки в Chrome (примечание: точно такая же каскадная диаграмма получается в Opera и Safari.)

Это наверняка ошибка в WebKit/Blink — пропущенные кавычки не должны скрывать подключенную через @import таблицу стилей от сканера предварительной загрузки.

Огромное спасибо Йоаву за помощь в этом расследовании.

Теперь фикс Йоава на очереди в Chromium.

Не размещайте <link rel="stylesheet" /> перед асинхронными сниппетами

Предыдущий раздел был про то, как другие ресурсы могут тормозить CSS (из-за глюков, как оказалось), а здесь речь пойдет о том, как CSS может нечаянно задержать загрузку последующих ресурсов, прежде всего JavaScript, асинхронно подгружаемого кодом наподобие такого:

<script>
  var script = document.createElement('script');
  script.src = "analytics.js";
  document.getElementsByTagName('head')[0].appendChild(script);
</script>

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

Браузер не выполнит <script>, если он в это время еще работает с каким-то CSS-кодом

<link rel="stylesheet" href="slow-loading-stylesheet.css" />
<script>
console.log("пока грузится оочеень-мееедлееенный-файл.css");
</script>

Это специально. Так задумано. Ни один синхронный элемент <script> в вашем HTML не выполнится, пока грузится какой-либо CSS. Это простая защитная стратегия для особого случая, когда <script> может запросить что-то о стилях страницы: если скрипт запрашивает цвет страницы до того, как загружен и разобран CSS, то ответ JavaScript может оказаться неверным и неактуальным. Чтобы избежать этого, браузер не выполняет <script>, пока CSSOM не будет готова.

Как результат — любые задержки во время загрузки CSS косвенно скажутся на вещах вроде асинхронных сниппетов. Лучше всего это видно на примере.

Если поместить <link rel="stylesheet" /> перед нашим асинхронным сниппетом, тот не сработает, пока CSS-файл не загрузится и не распарсится. Следовательно, ваш CSS всё тормозит.

<link rel="stylesheet" href="app.css" />

<script>
  var script = document.createElement('script');
  script.src = "analytics.js";
  document.getElementsByTagName('head')[0].appendChild(script);
</script>

При таком порядке очевидно, что JavaScript-файл не начинает грузиться, пока создаётся CSSOM. Любое распараллеливание полностью потеряно.

Из-за таблицы стилей перед асинхронным сниппетом теряется возможность распараллеливания.

Интересно, что сканер предварительной загрузки хотел бы уже подхватить ссылку на analytics.js заранее, но мы непроизвольно скрыли её: "analytics.js" — строка, и не становится атрибутом src, который можно разобрать на токены, пока элемент не появится в DOM. Именно это я имел ввиду ранее, говоря «Подробнее об этом позже».

Сторонние сервисы довольно часто предоставляют такие асинхронные сниппеты для более безопасной загрузки своих скриптов. Также разработчики часто с подозрением относятся к таким сторонним ресурсам, размещая свои асинхронные сниппеты позже на странице. Пусть намерения здесь и благие — «Я не хочу размещать сторонние теги <script> раньше моих собственных ресурсов!» — от этого часто бывает лишь вред. На самом деле, Google Analytics даже говорит нам, что делать, и они правы:

Скопируйте и вставьте этот код первым элементом в <HEAD> на каждой странице, которую нужно отслеживать.

Поэтому посоветую вот что:

Если блоки <script>…</script> не зависят от CSS, размещайте их выше ваших таблиц стилей.

Вот что получается, если следовать этому паттерну:

<script>
  var script = document.createElement('script');
  script.src = "analytics.js";
  document.getElementsByTagName('head')[0].appendChild(script);
</script>

<link rel="stylesheet" href="app.css" />

Если поменять местами таблицу стилей и асинхронный сниппет, то распараллеливание восстановится.

Теперь можно видеть, что мы полностью восстановили распараллеливание и страница загружается почти вдвое быстрее.

Размещайте любой JavaScript без обращения к CSSOM перед CSS; размещайте любой JavaScript с обращением к CSSOM после CSS

Чёрт. Эта статья становится дотошнее, чем я рассчитывал.

Если пойти дальше, выйдя за рамки только асинхронной загрузки сниппетов, то как лучше загружать CSS и JavaScript в целом? Чтобы решить это, я задал себе следующий вопрос, и стал отталкиваться от него.

Если

  • синхронный JS, определённый после CSS, блокируется, пока строится CSSOM;
  • синхронный JS блокирует построение DOM…

то при условии, что они не зависят друг от друга — что быстрее/предпочтительнее?

  • Скрипт после стилей;
  • стили после скрипта?

И вот ответ:

Если между файлами нет зависимости, то вам лучше размещать свои блокирующие скрипты выше блокирующих стилей — нет смысла откладывать выполнение JavaScript ради CSS, от которого этот JavaScript не зависит.

(Сканер предварительной загрузки гарантирует, что, хотя построение DOM блокируется скриптами, CSS по-прежнему загружается в параллельном потоке.)

Если какой-то ваш JavaScript зависит от CSS, а какой-то нет, тогда самый оптимальный порядок для загрузки синхронных JavaScript и CSS — разделить этот JavaScript на две части и загружать их по разные стороны вашего CSS:

<!-- Этот JavaScript выполнится сразу же после загрузки. -->
<script src="Мне-надо-блокировать-dom-но-НЕ-НАДО-обращаться-к-cssom.js"></script>

<link rel="stylesheet" href="app.css" />

<!-- Этот JavaScript выполнится сразу же после построения CSSOM. -->
<script src="Мне-надо-блокировать-dom-но-НАДО-обращаться-к-cssom.js"></script>

С таким паттерном загрузки у нас и загрузка, и выполнение происходят в самом оптимальном порядке. Прошу прощения за крошечные детали на скриншоте ниже, но надеюсь, вы заметили маленькие розовые метки, представляющие выполнение JavaScript. Запись (1) — HTML, в котором запланировано выполнение какого-то JavaScript при загрузке и/или выполнении других файлов; запись (2) выполняется в момент загрузки; запись (3) — CSS, поэтому он вообще не выполняет JavaScript; запись (4) не выполняется, пока CSS не будет завершён.

Как CSS может повлиять на то, в какой момент выполнится JavaScript.

Примечание: крайне важно протестировать этот паттерн в вашем конкретном случае: результаты могут отличаться в зависимости от того, сильно ли различаются по весу и ресурсоёмкости тот JavaScript, что грузится до CSS, и сам CSS. Тестируйте, тестируйте и еще раз тестируйте.

Размещайте <link rel="stylesheet" /> в <body>

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

В HTTP/1.1 все наши стили обычно собраны в один большой главный файл. Назовём его app.css:

<!DOCTYPE html>
<html>
<head>

  <link rel="stylesheet" href="app.css" />

</head>
<body>

  <header class="site-header">

    <nav class="site-nav">...</nav>

  </header>

  <main class="content">

    <section class="content-primary">

      <h1>...</h1>

      <div class="date-picker">...</div>

    </section>

    <aside class="content-secondary">

      <div class="ads">...</div>

    </aside>

  </main>

  <footer class="site-footer">
  </footer>

</body>

Здесь есть три основных недостатка:

  1. Любая отдельно взятая страница применяет лишь небольшую часть стилей из app.css: мы почти наверняка загружаем больше CSS, чем надо.
  2. Нам навязана неэффективная стратегия кеширования: при изменении, допустим, цвета фона выбранного текущего дня в выпадающем календарике, который есть только на одной странице, потребовалось бы обновить кеш для всего app.css.
  3. Весь app.css блокирует отображение: даже, если текущей странице требуется только 17% app.css, нам по-прежнему нужно ждать загрузки остальных 83%, прежде чем мы начнем что-либо рендерить.

С HTTP/2 можно начать решать пункты 1 и 2

<!DOCTYPE html>
<html>
<head>

  <link rel="stylesheet" href="core.css" />
  <link rel="stylesheet" href="site-header.css" />
  <link rel="stylesheet" href="site-nav.css" />
  <link rel="stylesheet" href="content.css" />
  <link rel="stylesheet" href="content-primary.css" />
  <link rel="stylesheet" href="date-picker.css" />
  <link rel="stylesheet" href="content-secondary.css" />
  <link rel="stylesheet" href="ads.css" />
  <link rel="stylesheet" href="site-footer.css" />

</head>
<body>

  <header class="site-header">

    <nav class="site-nav">...</nav>

  </header>

  <main class="content">

    <section class="content-primary">

      <h1>...</h1>

      <div class="date-picker">...</div>

    </section>

    <aside class="content-secondary">

      <div class="ads">...</div>

    </aside>

  </main>

  <footer class="site-footer">
  </footer>

</body>

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

Мы также можем принять более продуманную стратегию кеширования, обновляя кеш только для нужных файлов, и не трогать остальные.

Что мы не решили, так это то, что всё это по-прежнему блокирует отображение — скорость загрузки у нас по-прежнему ограничивается самой медленной таблицей стилей. Это значит, что, если по какой-либо причине загрузка файла page-footer.css будет долгой, браузер не сможет даже начать отрисовку .page-header.

Однако, из-за недавних изменений в Chrome (версия 69, кажется) и поведения, которое уже есть в Firefox и IE/Edge, <link rel="stylesheet" /> будут блокировать отображение только последующего контента, а не всей страницы. Это значит, что теперь можно выстраивать наши страницы так:

<!DOCTYPE html>
<html>
<head>

  <link rel="stylesheet" href="core.css" />

</head>
<body>

  <link rel="stylesheet" href="site-header.css" />
  <header class="site-header">

    <link rel="stylesheet" href="site-nav.css" />
    <nav class="site-nav">...</nav>

  </header>

  <link rel="stylesheet" href="content.css" />
  <main class="content">

    <link rel="stylesheet" href="content-primary.css" />
    <section class="content-primary">

      <h1>...</h1>

      <link rel="stylesheet" href="date-picker.css" />
      <div class="date-picker">...</div>

    </section>

    <link rel="stylesheet" href="content-secondary.css" />
    <aside class="content-secondary">

      <link rel="stylesheet" href="ads.css" />
      <div class="ads">...</div>

    </aside>

  </main>

  <link rel="stylesheet" href="site-footer.css" />
  <footer class="site-footer">
  </footer>

</body>

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

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

Чтобы подробнее узнать об этом методе подключения CSS, рекомендую прочитать статью Джейка на эту тему.

Заключение

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

  • Загружайте любой CSS «лениво» (отложенно):
    • Это может быть минимально необходимый CSS;
    • или разделяйте ваш CSS на медиавыражения.
  • Избегайте @import:
    • В HTML;
    • но особенно в CSS;
    • и не забывайте о странностях со сканером предварительной загрузки.
  • Будьте внимательны с синхронным порядком CSS и JavaScript:
    • JavaScript, определённый после CSS, не сработает до завершения загрузки CSSOM;
    • поэтому, если ваш JavaScript не зависит от вашего CSS:
      • загрузите его перед вашим CSS;
    • но если он зависит от вашего CSS:
      • загрузите его после CSS.
  • Загружайте CSS, как только он нужен DOM:
    • Это разблокирует начальный рендер и позволит рендерить страницу прогрессивно.

Предупреждение

Всё вышеизложенное соответствует спецификациям или известному/ожидаемому поведению, но проверяйте всё самостоятельно, как всегда. Хотя это всё верно в теории, на практике всегда что-то работает иначе. Тестируйте и измеряйте.

Благодарности

Благодарю Йоава, Энди и Райана за их подсказки и вычитку за последние пару дней.

P.S. Это тоже может быть интересно:

3

Комментарии

  1. Мария Наг

    Имеется в виду не использовать @import в ванильном цсс, с ванильным подключением в хтмл через линк. Использовать импорт с системами сборки можно, потому что итоговый файл не будет содержать директивы импорт.

  2. demimurych

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

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

  3. ilya m

    Последний пассаж про разбивку одного CSS на множество мелких как-то прямо противоречит тому факту, что порой цена ЗАПРОСА куда выше, чем объём, собственно, файла.

    Гугловский PAGERANK, емнип, даже понижает рейтинг сайтов, где ресурсы распылены на множество мелких именно из-за их итоговой более медленной загрузки.

Оставить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Ваш E-mail не будет опубликован

Получать новые комментарии по электронной почте. Вы можете подписаться без комментирования.