Блокирующие ресурсы и как их убрать

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

Блокирующий ресурс (render-blocking resource) — файл, который браузер обязан скачать и обработать до первой отрисовки. Задача оптимизации — не удалить его, а вывести из критического пути: оставить в пути минимум, необходимый для первого экрана, а остальное догрузить потом.

Зачем это делать

Отчёт Lighthouse почти на любом сайте начинается одинаково. Выглядит это примерно так:

Eliminate render-blocking resources                    — Potential savings: 1 180 ms

  /css/main.css                    186 KB              720 ms
  /css/vendor/slider.css            34 KB              210 ms
  https://fonts.googleapis.com/…    12 KB              250 ms

Reduce the impact of third-party code                 — Main-thread blocking: 1 240 ms

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

Рычаг 1: критический CSS инлайном

Идея простая: стили, нужные для видимой части страницы («above the fold»), кладём прямо в <head> тегом <style>. Всё остальное — модалки, футер, слайдер, страница профиля — грузим отдельным файлом, который рендер не блокирует.

<head>
  <meta charset="utf-8">

  <!-- 1. Критические стили: 6–14 КБ прямо в документе. Ноль сетевых запросов. -->
  <style>
    :root{--bg:#fff;--fg:#111}
    body{margin:0;font:16px/1.5 Inter,Arial,sans-serif;color:var(--fg);background:var(--bg)}
    .header{height:64px;display:flex;align-items:center}
    .hero{aspect-ratio:16/9;width:100%;background:#eee}
  </style>

  <!-- 2. Остальное — асинхронно. Приём с media="print": для экрана файл
       не нужен, значит рендер он не блокирует; onload переключает его на all. -->
  <link rel="stylesheet" href="/css/main.css" media="print" onload="this.media='all'">
  <noscript><link rel="stylesheet" href="/css/main.css"></noscript>
</head>

Второй вариант того же трюка — через rel="preload":

<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>

Что здесь важно. Оба приёма опираются на обработчик onload, поэтому <noscript>-дублёр обязателен — иначе при отключённом JS страница останется голой. Критический CSS обычно не пишут руками, его извлекают инструментами (critical, critters, beasties, встроенные плагины Next/Nuxt) на этапе сборки, иначе он мгновенно расходится с реальными стилями. И держите его в пределах ~14 КБ: это классический размер первого окна TCP, а на практике — просто разумная граница, за которой инлайн начинает раздувать HTML.

Рычаг 2: шрифты, FOIT и FOUT

Веб-шрифт — коварный ресурс. Браузер начинает качать его только тогда, когда обнаружит, что правило @font-face реально применяется к видимому тексту, то есть уже после парсинга CSS. А до этого момента текст либо не показывается вообще, либо показывается «не тем» шрифтом.

ТерминЧто видит пользовательЧем плохо
FOIT (Flash of Invisible Text)Место под текст есть, самого текста нетПустой экран при живом контенте. Убивает LCP: невидимый текст не считается отрисованным
FOUT (Flash of Unstyled Text)Текст сразу виден запасным шрифтом, затем перерисовываетсяМигание и — если метрики шрифтов разные — сдвиг вёрстки (CLS)

Управляет этим свойство font-display:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;      /* вариативный шрифт: один файл на все начертания */
  font-style: normal;
  font-display: swap;        /* показать текст сразу запасным шрифтом */
  unicode-range: U+0000-00FF, U+0400-04FF; /* латиница + кириллица, лишнее не качаем */
}
  • auto — на усмотрение браузера, чаще всего ~3 с FOIT. Худший вариант по умолчанию.
  • block — до 3 с невидимого текста. Только для иконочных шрифтов, где «квадратик вместо иконки» хуже пустоты.
  • swap — рабочая лошадка: текст виден с первого кадра.
  • fallback — 100 мс невидимости, потом запасной шрифт навсегда, если основной не успел. Компромисс без поздних перерисовок.
  • optional — браузер сам решает; на медленной сети шрифт просто не применится в этом визите. Нулевой CLS от шрифтов, но дизайн «плавает» между визитами.

Второй шаг — не ждать парсинга CSS, а начать качать шрифт сразу:

<!-- crossorigin ОБЯЗАТЕЛЕН: шрифты качаются в анонимном CORS-режиме,
     без него браузер скачает файл дважды -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>

Preload имеет смысл только для 1–2 шрифтов первого экрана: обычный текст и, может быть, заголовочное начертание. Preload всех восьми файлов семейства — это не оптимизация, а конкуренция за канал с картинкой LCP.

И третье: чем ближе метрики запасного шрифта к основному, тем незаметнее подмена и тем меньше сдвиг. Это настраивается — подробно разберём в следующем уроке.

Рычаг 3: сторонние скрипты — главный тормоз

Свой код вы контролируете. Чужой — нет. При этом именно он обычно и съедает главный поток. Типичный набор на коммерческом сайте:

Third-Party Summary            Transfer Size    Main-Thread Blocking Time
─────────────────────────────────────────────────────────────────────────
Google Tag Manager                   112 KB               430 ms
Виджет онлайн-чата                   268 KB               510 ms
Счётчик веб-аналитики                 64 KB               180 ms
Пиксель рекламной сети                52 KB               120 ms
A/B-тесты                             41 KB               190 ms
─────────────────────────────────────────────────────────────────────────
Итого                                537 KB             1 430 ms

Полторы секунды заблокированного главного потока — это провал по INP и отложенный LCP. Что с этим делать, по возрастанию усилий:

  1. Инвентаризация. Откройте вкладку «Третьи стороны» в Lighthouse и спросите про каждый пункт: кто владелец, какую бизнес-задачу решает, что сломается, если убрать. Половина скриптов на живых сайтах — это следы давно закрытых экспериментов.
  2. Всё оставшееся — async или defer. Никаких синхронных счётчиков в <head>.
  3. Ранние подключения. Для доменов, к которым точно пойдём: <link rel="preconnect" href="https://analytics.example.com" crossorigin>. Экономит DNS + TLS, обычно 100–300 мс. Больше 2–4 preconnect — вредно.
  4. Фасады (facade). Тяжёлый виджет заменяем лёгкой картинкой-заглушкой и грузим настоящий скрипт только по действию пользователя. Классика — чаты, карты, встроенные видео.
// Фасад чата: вместо 268 КБ скрипта на старте — кнопка-заглушка.
// Настоящий виджет подгружается по первому клику.
const facade = document.querySelector('#chat-facade');

function loadChat() {
  const s = document.createElement('script');
  s.src = 'https://widget.example.com/chat.js';
  s.async = true;
  document.head.appendChild(s);
  facade.hidden = true; // прячем заглушку — настоящий виджет отрисуется сам
}

facade.addEventListener('click', loadChat, { once: true });

// Подстраховка: если пользователь просто сидит на странице и ничего не делает,
// догружаем виджет в простое — когда главный поток свободен.
const whenIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 4000));
whenIdle(() => {
  if (!facade.hidden) loadChat();
});

Фасад — самый недооценённый приём в списке. Он не «ускоряет» скрипт, он убирает его из момента загрузки, где скорость и измеряется. По опыту, один фасад на виджет чата даёт больше, чем неделя микрооптимизаций своего бандла.

Как это работает

За кулисами всё решает приоритет запроса. Браузер раздаёт ресурсам приоритеты сам: блокирующий CSS и шрифты первого экрана — Highest, картинки во вьюпорте — High, async-скрипты и картинки за экраном — Low. Наши приёмы, по сути, — способы вручную подвинуть ресурс по этой шкале:

  • media="print" опускает CSS с Highest до Low — рендер он больше не держит;
  • rel="preload" поднимает шрифт или картинку до High/Highest;
  • fetchpriority="high" на LCP-картинке говорит браузеру, что она важнее остальных изображений;
  • fetchpriority="low" на стороннем скрипте — наоборот, отодвигает его в конец очереди.

Проверить, что получилось, можно во вкладке Network DevTools: включите колонку Priority и посмотрите порядок. Если LCP-картинка идёт после виджета чата — вы нашли свою проблему.

Частые ошибки

  • Инлайн критического CSS вручную. Через два спринта он расходится с реальными стилями, и первый экран рендерится сломанным. Только генерация на сборке.
  • Забыть <noscript>-дублёр при асинхронном CSS. При отключённом JS сайт превращается в набор строк без стилей.
  • preload шрифта без crossorigin. Браузер скачивает файл дважды: один раз как preload, второй — как шрифт. Классика, которую видно только в Network.
  • font-display: swap без выравнивания метрик. Убрали FOIT — получили сдвиг вёрстки и просадку CLS.
  • «Скрипт же async, значит бесплатный». Скачивание — да, параллельное. Но выполнение всё равно занимает главный поток и прерывает парсинг.
  • Preconnect ко всем доменам подряд. Каждое соединение — это TLS-рукопожатие и память. Больше нескольких — только вред.

Итоги

  • Критический CSS — инлайном в <head>, остальное — асинхронно через media="print"/preload + обязательный <noscript>.
  • font-display: swap убирает невидимый текст; preload с crossorigin — 1–2 шрифта первого экрана, не больше.
  • Сторонние скрипты почти всегда дороже собственного кода. Порядок действий: выкинуть лишнее → async/deferpreconnect → фасады.
  • Все приёмы сводятся к одному: подвинуть ресурс по шкале приоритетов браузера. Проверяйте результат в колонке Priority во вкладке Network.
Проверьте себя
1. Что делает font-display: swap?
AЗаставляет браузер скачать шрифт раньше остальных ресурсов
BПоказывает текст запасным шрифтом сразу, а когда основной шрифт загрузится — подменяет его (то есть меняет FOIT на FOUT)
CПолностью отключает загрузку веб-шрифта на медленных соединениях
DСжимает шрифт на лету, оставляя только используемые глифы
2. Почему инлайновый критический CSS в <head> помогает LCP, даже если общий объём переданного CSS вырастает?
AИнлайновый CSS не участвует в каскаде и потому применяется мгновенно
BБраузер кэширует инлайновый CSS отдельно от HTML
CОн приходит вместе с HTML в первом же ответе, поэтому браузеру не нужен дополнительный сетевой запрос, чтобы построить CSSOM и отрисовать первый экран
DИнлайновый CSS автоматически минифицируется браузером