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, но также дополнить разметку соответствующим &lt;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-кодом




Это специально. Так задумано. Ни один синхронный элемент <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.j заранее, но мы непроизвольно скрыли её: "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" /&gt будут блокировать отображение только последующего контента, а не всей страницы. Это значит, что теперь можно выстраивать наши страницы так:

<!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. Это тоже может быть интересно: