135 килобайт на три гарнитуры: как подрезаны шрифты этого сайта
Две независимые оси лишнего веса в переменном шрифте, охват по роли гарнитуры вместо охвата по семейству, и почему знак рубля лежит не в том поддиапазоне, где его ищут.
Три гарнитуры, три файла, 135,5 КБ на всё. Ниже — что с ними сделано, какие решения там неочевидны и где в этой задаче лежит ловушка, из-за которой на странице с ценами вместо знака рубля появляется квадратик.
Почему self-host, а не ссылка на Google Fonts
Первая причина не про производительность. fonts.googleapis.com получает IP посетителя при каждом просмотре страницы — это сторонняя передача данных без основания и без упоминания в политике. Дефект тот же, из-за которого из проекта убран Google Analytics, просто радиус поражения меньше. Самостоятельная раздача убирает сторонний запрос целиком, а не прячет его за баннером согласия.
Вторая причина реальна, но вторична: один same-origin woff2 на семейство, с immutable-заголовком и preload, выигрывает у кросс-доменного запроса CSS плюс непредсказуемого числа фрагментов по unicode-range. У самостоятельной раздачи есть и цена — обновлять версии гарнитур теперь надо руками. Для трёх файлов это приемлемо.
Две независимые оси лишнего веса
В переменном шрифте лишний вес набирается по двум осям сразу, и обычно режут только одну.
Первая — сама переменная ось wght. Файл несёт весь спроектированный диапазон, а дизайн-система использует три-четыре значения. fontTools.varLib.instancer сужает диапазон до нужного:
python -m fontTools.varLib.instancer Unbounded.ttf wght=400:800 -o sliced.ttf --update-name-table
--update-name-table здесь не косметика: без него файл продолжает объявлять оси, которых в нём больше нет, и браузер имеет право пообещать вес, который отрисовать не сможет.
Вторая ось — набор символов. Тут работает fontTools.subset с явным перечислением диапазонов. Обе операции нужны: сужение wght без подрезки charset оставляет весь юникод, подрезка charset без сужения wght оставляет все дельты вариаций.
Ловушка: знак рубля лежит не там
Кодпоинт ₽ — это U+20BD, и он находится в latin-ext, а не в latin. Определение latin у Google Fonts доходит до U+20AC (знак евро) и на валютах дальше не идёт.
Практический вывод неприятный. Вы подрезаете шрифт до latin + cyrillic — совершенно разумный выбор для русско-английского сайта, — открываете страницу и видите квадратик на месте цены. Причём заметите вы это не сразу: любой тест, написанный по-английски, покажет, что всё в порядке, потому что в английской версии цены в долларах.
Поэтому в скрипте сборки есть отдельный явный диапазон:
const CURRENCY = "U+20BD,U+20AC,U+0024,U+2116";
Четыре кодпоинта, три из которых уже входят в большие диапазоны. Дублирование намеренное: несколько байт против ситуации, когда кто-то сузит основной диапазон и молча потеряет символ валюты. Явное перечисление здесь работает как утверждение — эти четыре знака обязаны быть в файле, что бы ни случилось с остальным списком.
Охват по роли, а не по семейству
Главное решение в этом скрипте — что набор символов задаётся не для гарнитуры, а для её работы на сайте.
Unbounded — только дисплейная: герой, заголовки H1–H3, начертание логотипа. Она никогда не набирает прозу. Значит latin-ext и cyrillic-ext в ней — мёртвый груз: заголовкам на русском и английском нужны latin и cyrillic, и больше ничего. Именно отказ от расширенных диапазонов уложил её в бюджет.
Onest набирает все текстовые строки в обоих языках, включая юридические документы. Расширенные диапазоны остаются: украинское или казахское имя в заявке, ₴, ў, ґ. Это не гипотеза — поле имени принимает то, что человек в него ввёл, и квадратик в чьём-то собственном имени плохой способ поздороваться.
JetBrains Mono набирает терминальный слой: ASCII, рамки (U+2500–257F) и достаточно кириллицы для русских строк в терминале.
Разница между «подрезать шрифт» и «подрезать шрифт под его роль» — примерно двукратная, и она бесплатная: роль гарнитуры вы и так знаете, иначе она бы не попала в проект.
Числа
Прочитано с диска, ровно то, что закоммичено:
public/fonts/Unbounded-Variable.woff2 60 436 B 59,0 КБ бюджет 90
public/fonts/Onest-Variable.woff2 43 204 B 42,2 КБ бюджет 80
public/fonts/JetBrainsMono-Variable.woff2 35 116 B 34,3 КБ бюджет 60
138 756 B 135,5 КБ
Бюджет проверяется в самом скрипте: превышение — ненулевой код выхода. Бюджет, который существует только в code review, бюджетом не является.
А вот чего здесь нет — цифры «до и после», и это осознанно. Естественное сравнение — исходный TTF против подрезанного woff2, и оно смешивает два совершенно разных эффекта: сжатие brotli и удаление глифов. Такое сравнение льстит вашей работе по подрезке в два-три раза. Если хочется знать, что дала именно подрезка, сожмите неподрезанный файл в woff2 и сравнивайте с ним. Я этого измерения не делал, поэтому и числа не привожу.
Флаги, о которых стоит знать заранее
--layout-features — это allowlist, а не список исключений. Перечисляете аккуратно, иначе теряется кернинг. В скрипте остаются kern, liga, clig, calt, rlig, locl, ccmp, mark, mkmk, а росчерки, альтернаты и исторические формы уходят. locl в этом списке важен именно для кириллицы: болгарские и сербские локализованные формы букв — это не декорация, это правильное написание.
--no-hinting уместен для woff2 на современных рендерерах и неуместен, если вы всё ещё поддерживаете старый GDI на Windows. --notdef-outline оставляет видимый прямоугольник на месте отсутствующего глифа вместо пустоты нулевой ширины: отказ нужно видеть, иначе он превращается в «кажется, тут что-то не то с пробелами». --recalc-bounds нужен потому, что сужение оси делает границы в таблице head недействительными.
Старший футарк, которого нет ни в одной гарнитуре
Ни одна из трёх не содержит рун (U+16A0–16F8). Это выяснилось не в проде: рунический слой — часть идентичности, и на него сразу заведены SVG-пути в components/brand/Rune.tsx.
Получилось не обходное решение, а лучший ответ: точный контроль над геометрией штриха, отсутствие зависимости от загрузки шрифта для знака, который является частью логотипа, и никакого четвёртого файла в критическом пути.
Загрузка
Preload у дисплейной и текстовой гарнитур, у монospace — нет: терминальный слой появляется только ниже первого экрана, и платить за него на критическом пути незачем. display: swap у всех трёх — вспышка системного шрифта строго лучше невидимого заголовка. Стеки фолбэков перечислены явно, /fonts/* отдаётся с immutable-заголовком.
Последняя деталь — разделение двух пространств имён. app/fonts.ts объявляет переменные по имени гарнитуры (--font-unbounded), а @theme в globals.css отображает их в роли (--font-display: var(--font-unbounded)). Компонент просит font-display и никогда не знает имени гарнитуры. Помимо очевидного (замена Unbounded — правка одной строки), это спасает от самореференции --font-display: var(--font-display), в которую упираются все, кто назвал переменную по роли на обоих уровнях.
Итог
Размер файлов сам по себе не аргумент — 135 КБ можно получить и случайно. Аргумент в том, что каждый байт в этих трёх файлах присутствует по названной причине, и скрипт падает, если это перестаёт быть правдой.