Шрифты

Шрифт весит немного, но стоит дорого: он умеет прятать текст на секунды и двигать вёрстку в момент подмены. Разберём, как этого не допустить.

FOIT (Flash of Invisible Text) — период, когда текст уже свёрстан, но не показан, потому что браузер ждёт шрифт. FOUT (Flash of Unstyled Text) — текст показан системным шрифтом и позже перерисовывается вашим.

Зачем этим заниматься

Веб-шрифт — единственный ресурс, который по умолчанию имеет право спрятать контент. Браузер натыкается на @font-face, начинает качать файл и до трёх секунд держит текст невидимым. Это прямое попадание в LCP: если ваш заголовок — самый крупный элемент экрана, метрика фиксируется не тогда, когда посчитан layout, а тогда, когда приехал шрифт.

Вторая беда приходит следом. Текст всё-таки показали фолбэком, шрифт догрузился, буквы поменяли ширину — и весь блок под ними подпрыгнул. Это CLS, причём тот его вид, который замечают даже люди, никогда не слышавшие про Core Web Vitals.

Плюс банальный вес: полный набор Inter в четырёх начертаниях с кириллицей и латиницей легко даёт 400–600 КБ. Урезать это до 60–80 КБ — работа на полчаса.

Формат: только woff2

Не надо коллекционировать форматы. WOFF2 — это шрифт, сжатый Brotli, он на 20–30% легче старого WOFF и поддерживается всеми браузерами, которые вообще способны открыть современный сайт. TTF и OTF в вебе не нужны: это несжатые исходники, отдавать их пользователю — то же самое, что отдавать BMP вместо JPEG.

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-400-cyrillic.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-400-latin.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+2000-206F, U+2122;
}

Что здесь важно. Одно семейство Inter описано двумя правилами с разными unicode-range. Браузер скачает только тот файл, символы из которого реально встретились на странице: англоязычная страница не получит кириллицу, и наоборот. Это встроенный механизм, никакого JS.

Подмножества: выкидываем то, чего нет на сайте

Шрифт вроде Roboto или Inter содержит тысячи глифов: греческий, вьетнамский, знаки валют, стрелки, полный набор диакритики. На вашем сайте из них используются, дай бог, три сотни. Обрезка (subsetting) выкидывает лишнее из самого файла.

pip install fonttools brotli

pyftsubset Inter-Regular.ttf \
  --unicodes="U+0000-00FF,U+0400-045F,U+2116" \
  --layout-features="kern,liga" \
  --flavor=woff2 \
  --output-file=inter-400-subset.woff2

Типичный результат: 320 КБ TTF превращаются в 35–45 КБ WOFF2. Ключ --layout-features оставляет только кернинг и лигатуры, всё остальное OpenType-хозяйство удаляется.

Отдельно про Google Fonts. Их CSS-эндпоинт уже отдаёт нарезанные по unicode-range подмножества, так что вес там приличный. Проблема в другом: это внешний домен, а значит лишний DNS + TLS-хендшейк на критическом пути, и надежда на «общий кэш» давно мертва — браузеры разделяют кэш по сайтам. Скачайте файлы и положите к себе. Одной третьей стороной меньше.

Preload: сокращаем цепочку

Чтобы браузер узнал о шрифте, ему нужно: получить HTML, найти ссылку на CSS, скачать CSS, распарсить его, применить правила и обнаружить, что текст на странице подпадает под @font-face. Только тогда стартует загрузка шрифта. Это три круга по сети подряд.

preload сокращает цепочку до одного круга — запрос уходит сразу из <head>, параллельно с CSS:

<link rel="preload"
      href="/fonts/inter-400-cyrillic.woff2"
      as="font"
      type="font/woff2"
      crossorigin>

Атрибут crossorigin здесь обязателен, даже если файл лежит на вашем же домене. Причина в том, что шрифты грузятся в анонимном CORS-режиме; без crossorigin предзагруженный файл окажется в другом «отсеке» кэша, и браузер скачает его второй раз. В консоли вы увидите предупреждение о неиспользованном preload — это ровно оно.

И мера: предзагружайте один-два файла, не больше. Preload — это очередь без очереди, он отбирает полосу у картинок и CSS. Обычно достаточно основного начертания (400) для того языка, на котором написан контент.

font-display: кто кого ждёт

Жизнь шрифта делится на три периода: block (текст невидим, ждём), swap (показан фолбэк, готовы подменить) и failure (шрифт объявлен неудавшимся и больше не применяется в этой загрузке).

ЗначениеПериод блокировкиПериод подменыКогда брать
block~3 с (текст невидим)бесконечныйпрактически никогда
swapоколо 0бесконечныйосновной текст, заголовки
fallback~100 мс~3 скомпромисс, когда FOUT раздражает дизайнера
optional~100 мснетвторостепенный шрифт, который не жалко не показать

По умолчанию (auto) браузеры ведут себя как block — то есть прячут текст. Поэтому font-display надо ставить осознанно всегда.

swap — рабочая лошадка: текст виден сразу, LCP не ждёт шрифт. Цена — прыжок вёрстки при подмене. optional — самый жёсткий и самый честный к пользователю вариант: на медленной сети браузер просто оставит системный шрифт, а ваш докачает в фон и применит при следующем заходе. Ноль CLS, ноль задержки. Для шрифта в теле статьи это часто лучше, чем кажется дизайнеру.

Как убрать прыжок при подмене

FOUT прыгает потому, что у фолбэка другие метрики: другая высота строчных, другая ширина символов. Это чинится описанием фолбэка как отдельного @font-face с подкрученными метриками:

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}

Подобрав size-adjust, вы заставляете Arial занимать ровно столько же места, сколько займёт Inter. Подмена происходит — прыжка нет. Цифры для популярных пар шрифтов считает утилита fontaine, а Next.js и Nuxt делают это автоматически.

Системный шрифт как честный вариант

Ноль килобайт, ноль запросов, ноль FOIT, ноль CLS. Текст рисуется тем шрифтом, который пользователь и так видит во всех приложениях на своём устройстве.

body {
  font-family: system-ui, -apple-system, "Segoe UI", Roboto,
               "Helvetica Neue", Arial, sans-serif;
}

Так живут GitHub и админки половины SaaS-сервисов. Если брендинг не держится на конкретной гарнитуре — а он обычно не держится, — это лучший тюнинг производительности из возможных: вы просто удаляете проблему вместо того, чтобы её оптимизировать.

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

Шрифт — ленивый ресурс по природе. Правило @font-face само по себе ничего не качает: файл запрашивается, только когда браузер находит на странице текст, который под это правило попадает (нужное семейство, начертание, диапазон символов). Отсюда два неочевидных следствия.

Первое: если вы подключили шесть начертаний, а в вёрстке используются два, скачаются два — и это хорошо. Но если где-то в глубине CSS кто-то поставил font-weight: 300 одному элементу, приедет и третий файл. Проверять надо не CSS, а вкладку Network.

Второе: браузер умеет синтезировать отсутствующие начертания. Просите font-weight: 500, а подключены только 400 и 700 — браузер не скачает ничего лишнего, он просто искусственно утолщит 400. Выглядит это грязно, а на глаз ошибку почти не поймаешь, пока не сравнишь с настоящим Medium.

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

  • Подключены Regular, Italic, Medium, SemiBold, Bold, Black — «на всякий случай». Два начертания закрывают 95% макетов.
  • preload без crossorigin: файл качается дважды, в консоли висит предупреждение.
  • @import url(...) в CSS вместо <link> в <head> — добавляет ещё один последовательный круг по сети.
  • Шрифт иконок с font-display: swap: пока он едет, вместо иконок пляшут квадратики и буквы. Иконки давно принято отдавать как SVG.
  • Оставили font-display по умолчанию — получили трёхсекундный FOIT на плохом 3G.
  • Отдали TTF на 300 КБ, потому что «конвертер не поставился».
  • Предзагрузили все шесть файлов: они отобрали полосу у LCP-картинки, и стало хуже, чем было.

Итоги

  • Только woff2. Остальные форматы — исторический балласт.
  • Режьте подмножества: pyftsubset и unicode-range уменьшают файл в 5–8 раз.
  • font-display: swap для контентных шрифтов, optional — там, где важнее нулевой CLS.
  • preload с обязательным crossorigin и только на один-два ключевых файла.
  • Прыжок при подмене лечится метриками фолбэка (size-adjust, ascent-override), а не отказом от swap.
  • Системный стек — не поражение, а самый быстрый шрифт в мире.
Проверьте себя
1. Зачем в <link rel="preload" as="font"> нужен атрибут crossorigin, даже если шрифт лежит на вашем собственном домене?
AОн разрешает браузеру кэшировать шрифт дольше суток
BШрифты запрашиваются в анонимном CORS-режиме, и без crossorigin предзагруженный файл не совпадёт с последующим запросом — браузер скачает его дважды
CБез него не сработает font-display
DОн нужен только для Google Fonts и других внешних доменов
2. Какое значение font-display гарантирует, что текст никогда не будет невидимым и при этом вёрстка не прыгнет от подмены шрифта на медленной сети?
Ablock
Bswap
Coptional
Dauto