Перевод статьи CSS and Network Performance с сайта csswizardry.com, опубликовано на css-live.ru с разрешения автора — Гарри Робертса.
Несмотря на то, что сайт уже больше десяти лет называется «CSS-волшебство», за последнее время на нём не было ни одной статьи, связанной с CSS. Давайте я это исправлю, совместив две мои любимые темы: CSS и производительность.
CSS критически важен для отображения страницы — браузер не начнет рендеринг, пока не найдет, загрузит и распарсит весь CSS — поэтому крайне важно как можно скорее получить его на устройстве пользователя. Любая задержка на критическом пути скажется на нашей начальной отрисовке, заставив пользователя видеть пустой экран.
В чём главная проблема?
Собственно, вот почему CSS так важен для производительности:
- Браузер не может отобразить страницу до построения дерева отрисовки;
- дерево отрисовки получается из DOM и CSSOM вместе взятых;
- DOM — это HTML плюс любой блокирующий JavaScript, который на него влияет;
- CSSOM — все CSS-правила, применённые к DOM;
- с помощью атрибутов
async
иdefer
можно легко сделать JavaScript неблокирующим; - сделать CSS асинхронным намного сложнее;
- поэтому важно помнить, что скорость загрузки страницы определяется самой медленной таблицей стилей.
Учитывая это, нам нужно максимально быстро построить 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
по своей природе медленный. Это крайне плохо для производительности начальной отрисовки. Это потому, что мы создаем больше запросов к серверу во время критической начальной загрузки.
- Скачиваем HTML;
- HTML запрашивает CSS;
- (К этому моменту хорошо бы уже начать строить дерево отображения, но;)
- CSS запрашивает ещё CSS;
- строим дерево отображения.
Если взять следующий 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-кодом
Это специально. Так задумано. Ни один синхронный элемент <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>
Здесь есть три основных недостатка:
- Любая отдельно взятая страница применяет лишь небольшую часть стилей из
app.css
: мы почти наверняка загружаем больше CSS, чем надо. - Нам навязана неэффективная стратегия кеширования: при изменении, допустим, цвета фона выбранного текущего дня в выпадающем календарике, который есть только на одной странице, потребовалось бы обновить кеш для всего
app.css
. - Весь
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. Это тоже может быть интересно: