Посетитель открывает главную страницу и несколько секунд смотрит на пустой экран. Разметка давно приехала, картинки качаются, шрифты на подходе, а браузер не рисует ничего. Причина проста и обидна: в секции head стоит большая таблица стилей, и до её полной загрузки отрисовка запрещена. Браузер не умеет показывать страницу наполовину оформленной, он ждёт весь CSS до последнего байта. Метод critical CSS разбивает этот затор на две части: стили первого экрана встраиваются прямо в HTML, а всё остальное грузится в фоне и не мешает показу. Ниже разобрана рабочая процедура извлечения через пакет critical, альтернативный путь через penthouse, два паттерна асинхронной загрузки, ошибки внедрения и цифры до и после.

Полная таблица стилей держит браузер в ожидании первой краски

Чтобы показать страницу, браузер строит два дерева: DOM из разметки и CSSOM из стилей. Отрисовка стартует только когда оба готовы. Тег link rel="stylesheet" в head объявляет внешний CSS, и на время его загрузки и разбора конвейер стоит. Браузер не знает заранее, какие правила понадобятся для первого экрана, поэтому перестраховывается и ждёт всё целиком. Если стилей несколько, ходки к серверу выстраиваются в очередь, и каждый круг по мобильной сети стоит сотни миллисекунд.

Прикинем порядок величин. Таблица стилей типичного лендинга разрастается до 100-150 КБ исходного кода и 20-30 КБ после сжатия gzip. На соединении 1.6 Мбит/с передача 23 КБ занимает около 120 мс, но добавляются поиск DNS, рукопожатие TLS и задержка туда-обратно, ещё 150-450 мс. На слабом мобильном интернете со скоростью 0.4 Мбит/с те же 23 КБ качаются почти полсекунды. Итог: пустой экран на одну-две секунды при давно загруженной разметке.

Метрики фиксируют эту задержку напрямую. FCP, первая отрисовка содержимого, считается хорошей до 1.8 с. LCP, момент появления крупнейшего элемента, укладывается в норму до 2.5 с. Блокирующий CSS отодвигает обе: пока таблица стилей не разобрана, браузер не покажет ни пикселя текста, ни баннер. Диагностика Lighthouse пишет об этом прямо: устраните ресурсы, блокирующие рендеринг, и называет число байтов. Именно это число метод critical CSS призван обнулить.

Логика метода critical CSS и граница видимой области экрана

Идея метода строится на простом наблюдении: посетитель сначала видит только первый экран, и стилей для него обычно хватает 10-15% от всей таблицы. Остальные 85-90% правил нужны футеру, модальным окнам, аккордеонам в подвале и внутренним виджетам, которые откроются после прокрутки. Значит, стили можно разрезать на две части по границе видимой области.

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

Граница первого экрана в этом методе не метафора, а конкретный прямоугольник в пикселях. Десктопный вьюпорт чаще всего берут 1440 на 900 или 1300 на 900, смартфонный 390 на 844. Для извлечения эти размеры задают явно, от них зависит объём фрагмента.

У метода есть цена. HTML растёт на 10-20 КБ, и встроенные стили кешируются только вместе со страницей, а не отдельным файлом. После изменения дизайна набор приходится пересобирать, иначе он начнёт врать. Поэтому максимум пользы метод даёт на главной странице и лендингах, куда аудитория приходит с холодным кешем.

Извлечение нужных правил пакетом critical и настройки вьюпорта

Рабочий инструмент для автоматического извлечения в экосистеме Node.js это пакет critical. Он запускает браузер без графического интерфейса на движке Chromium, открывает готовую страницу, вычисляет правила для видимой области, минифицирует фрагмент и встраивает его в head.

Установка добавляет пакет в зависимости проекта:

npm install --save-dev critical

Классический вызов из командной строки:

npx critical dist/index.html --base dist/ --width 1440 --height 900 --minify --inline > dist/index-ready.html

Параметры width и height задают прямоугольник первого экрана, minify сжимает вырезанный фрагмент, inline встраивает результат прямо в HTML вместо отдельного файла. Если нужен именно CSS-файл, флаг inline убирают, и тогда в поток вывода идёт чистый фрагмент стилей. По умолчанию пакет считает вьюпорт 1300 на 900, для смартфона запускают второй прогон с шириной 390 и высотой 844.

Для сборочных сценариев удобнее программный интерфейс:

const critical = require('critical');

critical.generate({
  base: 'dist/',
  src: 'index.html',
  dest: 'index.html',
  inline: true,
  extract: true,
  minify: true,
  width: 1440,
  height: 900
});

Параметр extract удаляет из основной таблицы стилей правила, которые уже встроены, чтобы браузер не качал их дважды. Вызов встраивают в npm-скрипт и выполняют на каждом деплое.

Полный цикл внедрения метода на главной странице выглядит так:

  1. установка пакета в зависимости проекта командой npm install --save-dev critical;
  2. сборка чистой версии страницы и её стилей в рабочую папку dist;
  3. выбор размеров вьюпорта, для которых считается первый экран;
  4. запуск извлечения с флагами inline, minify и нужными шириной и высотой;
  5. замена исходного тега link на асинхронный паттерн загрузки стилей;
  6. повторный прогон Lighthouse и сравнение метрик до и после внедрения.

Пакет penthouse как альтернатива с ручным контролем результата

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

Установка и программный вызов:

npm install --save-dev penthouse
const penthouse = require('penthouse');
const fs = require('fs');

penthouse({
  url: 'http://localhost:3000/',
  cssString: fs.readFileSync('dist/css/main.css', 'utf8'),
  width: 1440,
  height: 900,
  timeout: 30000
}).then(css => {
  fs.writeFileSync('dist/css/critical.css', css);
});

Командная строка устроена ещё проще:

npx penthouse http://localhost:3000/ dist/css/main.css > dist/css/critical.css

У пакета три полезные настройки. Опция forceInclude принудительно оставляет селекторы, которых нет в стартовом состоянии страницы, например классы, которые добавляет JavaScript при взаимодействии. Опция propertiesToRemove выкидывает тяжёлые декоративные свойства из вырезанного фрагмента. Опция keepLargerMediaQueries управляет судьбой широких медиазапросов. Внутри penthouse тоже запускает Chromium, поэтому в сборочном контейнере нужны его зависимости.

Выбор сводится к стилю работы: критический фрагмент под ключ за один прогон даёт critical, тонкий контроль и собственный пайплайн даёт penthouse.

Асинхронная загрузка остального CSS без блокировки рендера

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

<style>
  .header{display:flex;align-items:center;height:64px;background:#1a1a2e;color:#fff}
  .hero{padding:96px 24px;font-size:32px;line-height:1.3}
  .btn-primary{display:inline-block;padding:12px 24px;background:#e63946;border-radius:6px}
</style>
<link rel="preload" href="/css/main.css" as="style"
      onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/main.css"></noscript>

Атрибут preload с типом style говорит браузеру скачать файл с высоким приоритетом, но не применять. Когда загрузка завершается, обработчик onload меняет значение rel на stylesheet, и стили применяются. Обнуление onload на входе страхует от повторного срабатывания обработчика в старых браузерах. Вложенный тег внутри noscript возвращает обычное поведение, если JavaScript выключен.

Второй паттерн использует переключатель media:

<link rel="stylesheet" href="/css/main.css" media="print"
      onload="this.media='all'">
<noscript><link rel="stylesheet" href="/css/main.css"></noscript>

Значение media="print" вводит планировщик браузера в заблуждение: файл считается нужным только для печати, поэтому он качается с низким приоритетом и не блокирует экран. После загрузки обработчик меняет media на all, и таблица применяется к странице.

Оба паттерна рабочие, и выбор между ними дело вкуса: preload качает файл раньше и виднее в мониторах приоритетов, вариант с media не требует атрибута as. Нюанс один: если асинхронно грузятся два файла с конфликтующими правилами, порядок их применения после onload может отличаться, и страница мигнёт.

Ошибки при вырезании, из-за которых мигает неоформленный контент

Технология описывается за пять минут, а реальные трудности начинаются при внедрении. Самая частая из них имеет собственное имя FOUC, вспышка неоформленного контента: страница уже показана, а стили к ней приезжают позже, и содержимое перестраивается на глазах.

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

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

Третий камень, медиазапросы. Прогон на десктопном вьюпорте оставляет без внимания правила из блока @media (max-width: 768px), и на смартфоне первый экран отрисуется не полностью. Лечится вторым прогоном с мобильными размерами: мобильный фрагмент встраивают как есть, а десктопный оборачивают в @media (min-width: 768px), чтобы наборы не ссорились между собой.

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

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

Проверить себя помогает вкладка Coverage в DevTools: она показывает, какая доля каждого файла стилей реально использовалась при первой отрисовке. Ориентир для фрагмента, 10-20 КБ сжатого кода. Если вырезалось сильно больше, сперва упрощают сам макет, а потом режут стили.

Цифры до и после на модельной главной странице и практические выводы

Порядок выигрыша понятнее всего на модельном примере. Возьмём главную страницу магазина с одной таблицей стилей main.css размером 118 КБ исходного кода и 23 КБ после сжатия gzip. Замер в Lighthouse на мобильном профиле с имитацией сети 1.6 Мбит/с и задержкой 150 мс до оптимизации даёт FCP 2.8 с и LCP 4.4 с, а в отчёте горит строка про ресурсы, блокирующие рендеринг, с числом 23 КБ.

После встраивания критического фрагмента на 12 КБ исходного кода, это 4.4 КБ после gzip, и перевода основной таблицы на асинхронную загрузку картина меняется: блокирующих стилевых байтов ноль, FCP 1.4 с, LCP 3.1 с. HTML вырос с 36 до 50 КБ, но на метриках это отразилось слабее снятой блокировки. Отдельный прогон под смартфонный вьюпорт 390 на 844 вырезал фрагмент всего в 9 КБ, потому что мобильная шапка проще десктопной.

Выводы из этих цифр честные. Метод снимает только ту задержку, которую создавал сам CSS. Если LCP держит баннер, параллельно добавляют ему атрибут fetchpriority="high" и предзагрузку, иначе отрисовка станет быстрой, а главный элемент всё равно опоздает. Выигрыш растёт вместе с размером таблицы стилей и ухудшением сети: на оптоволокне разница едва заметна, на мобильном интернете экономятся целые секунды.

Последнее наблюдение практиков: сама технология внедряется за пару вечеров. Слабое место метода не техника, а дисциплина. Кто будет пересобирать фрагмент после смены шапки? Если ответ "никто", встроенные стили начнут спорить с основной таблицей, и быстрая страница снова начнёт мигать. Инструмент пересборки обязан запускаться сам при каждом изменении дизайна, тогда метод годами остаётся самым дешёвым способом ускорить первую отрисовку главной страницы.