Этот сайт (тот, который ты читаешь) набирает 98 баллов мобильного Lighthouse в продакшне, на трёх локалях, с реальным блогом, реальным роутингом и реальной контактной формой. Я не отгрузил статичный HTML-клон ради этого. Вот что реально сдвинуло число, что не сдвинуло, и что бы я пропустил в следующий раз.
Перед чем-либо: балл Lighthouse — это прокси, а не цель. Цель — чтобы сайт грузился быстро для человека на 4G-соединении в лобби отеля. Если оптимизация ради балла делает сайт медленнее для него — я игнорирую балл. Упоминаю, потому что большинство гайдов «как добить до 100» оптимизируют ради скриншота, а не ради пользователя.
Коротко — что сдвинуло число
По порядку влияния на моих собственных аудитах:
- Самохостинг шрифтов, подрезка до глифов, которые реально использую
- Замена каждого декоративного PNG/JPG на inline SVG
- Измерение какой клиентский JS отгружается и убийство половины
- Оплата i18n-налога ровно один раз, на этапе сборки
- Tailwind 4 + tree-shaken CSS, без глобального reset-кухонного мусора
Это стек-ранкинг. Последние два часто пропускают в гайдах, и они стоят гораздо больше, чем большинство советов по оптимизации картинок.
1. Шрифты — самый большой бесплатный выигрыш
Google Fonts через <link> — единственная самая частая перф-ошибка, которую я вижу на современных сайтах. Даже с font-display: swap цепочка запросов такая:
- HTML загружается
- Браузер парсит, находит
<link>на fonts.googleapis.com - DNS, TLS, фетч CSS
- Парс, нахождение реальных URL
.woff2(другой домен) - DNS, TLS, фетч шрифтов
- Рендер
Пять round-trip'ов до отрисовки текста. На медленном 4G это 800ms+ пустого экрана до LCP.
Что делаю я: next/font/local, файлы .woff2 в /public/fonts/, подрезанные до Latin + Latin Extended + Cyrillic. font-display: swap. Один запрос, тот же origin, агрессивно кешировано.
Код:
// app/fonts.ts
import localFont from 'next/font/local'
export const inter = localFont({
src: [
{
path: './fonts/Inter-Variable.woff2',
weight: '100 900',
style: 'normal',
},
],
variable: '--font-inter',
display: 'swap',
preload: true,
})
Подрезать .woff2 с помощью pyftsubset или glyphhanger, чтобы выбросить CJK, арабские и математические глифы, которые никогда не используешь. Вариативный шрифт, который был 480KB, стал 86KB после подрезки до трёх скриптов, которые мне реально нужны.
Дельта баллов: ~+8 на мобильном.
2. SVG для каждого декоративного изображения
Каждая иконка, каждая иллюстрация, каждый акцент на этом сайте — inline SVG или SVG из /public. Ноль декоративных PNG/JPG. Фото подаются через next/image с AVIF + responsive srcset; просто у меня мало фотографий.
Почему это bigger deal, чем признают гайды по сжатию картинок: SVG — часть HTML-потока. Они рисуются вместе с остальной страницей. Растровые изображения — это второй запрос, второй декод, и они блокируют LCP, пока не приземлятся.
Цена: вечер в Figma на экспорт чистых SVG и ещё час на оптимизацию с SVGO. Стоило каждой минуты.
Дельта баллов: ~+5 на мобильном, но больший выигрыш — в ощущаемой отзывчивости: страница визуально завершается за один paint вместо двух.
3. Честный аудит клиентского JS
Штука, которая смиряет: открой DevTools Coverage tab, загрузи свой сайт, поскролль немного и посмотри, сколько JS ты отгрузил vs сколько реально выполнилось.
Мой первый аудит на этом сайте: 180KB JS отгружено клиенту, ~40KB реально выполнено на first paint. 140KB неиспользованных байтов висят в network tab просто так.
Откуда это шло:
- Импорт framer-motion, который рендерил три анимации, но импортировал всю библиотеку
- Syntax highlighter, загруженный eagerly для блоков кода — ни одного из которых не было видно на главной
- Библиотека иконок, импортированная на уровне модуля, когда я использовал четыре иконки (та же проблема, что со шрифтами)
Паттерн фикса для каждого:
// framer-motion — использовать lazy-loaded вход
import { LazyMotion, domAnimation, m } from 'framer-motion'
// syntax highlighter — грузить только когда блок кода виден
const SyntaxHighlighter = dynamic(() => import('react-syntax-highlighter'), {
ssr: false,
})
// иконки — импортировать четыре, которые использую, а не бочку
import ShieldIcon from '@/components/icons/ShieldIcon' // inline SVG, 200 байт
Снизил до ~55KB отгруженных после аудита, ~40KB выполненных. Оставшиеся 15KB — роутинг + гидратация, неизбежные без отказа от Next.
Дельта баллов: ~+6, но важнее — ~400ms снижения Time to Interactive.
4. i18n-налог, оплаченный один раз на этапе сборки
Этот сайт рендерится на английском, русском и украинском. Наивный подход — отгружать все три словаря переводов на каждую страницу и выбирать один в рантайме. Это ~90KB JSON ради фичи, которая обслуживает один язык за раз.
next-intl (библиотека, которую я использую) поддерживает статическую загрузку по локали на этапе сборки с App Router. Каждая локаль компилирует свой собственный статический HTML только со своими сообщениями. JSON на локаль в клиентском бандле: ~8KB, не 90KB.
// i18n/request.ts
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default,
}))
Трюк в шаблонном литерале. Если написать await import('../messages/en.json') статически — бандлер отгружает только этот. Если с динамической переменной — Next инлайнит по-локально на этапе сборки, потому что роутинг — по-локальный. Одна локаль, один файл сообщений, нет переключения на клиенте.
Дельта баллов: не огромное число на Lighthouse (может +2 из-за уменьшения бандла), но это разница между сайтом, который ощущается быстрым, и сайтом, который ощущается раздутым для пользователей, открывающих DevTools.
5. Tailwind 4, без глобального reset-мусора
Tailwind 4 с @theme и @utility отгружает долю CSS Tailwind 3, при правильном использовании. Продакшн-бандл этого сайта — ~9KB CSS (gzipped), и включает небольшую кастомную тему, полную библиотеку компонентов и типографику для MDX.
Две конкретные вещи:
- Нет глобального reset в стиле
@tailwind base, когда он мне не нужен. Использую тесный кастомный reset (~200 байт), который обрабатывает мои реальные краевые случаи, а не 40-селекторный Normalize-стиль дамп. @utilityдля кастомных утилит, которые использую 5+ раз. Держит arbitrary-value fallback вне моего JSX.
Дельта баллов: маленькая на Lighthouse напрямую, но каждый KB CSS — это KB, задерживающий first paint на медленных соединениях.
Что не помогло
Две вещи, на которые я потратил время и которые сдвинули балл меньше чем на пункт:
Edge-рендеринг всего. Попробовал переместить серверные компоненты на edge runtime на Vercel. Реальное улучшение латенси: 30ms в среднем, редко попадая на критический путь. И потерял доступ к некоторым Node-only API, которые не хотел рефакторить. Откатил.
Ручная тюнинг critical CSS extraction. Попробовал ручной critical-CSS экстрактор, который инлайнил above-the-fold стили в <head> и откладывал остальное. Технически работало — но с Tailwind 4, уже производящим 9KB CSS, critical fold по сути был всем файлом. Добавил сложность ради 0.2 очка улучшения.
Если CSS 80KB+ на странице, critical extraction вероятно стоит того. Ниже 15KB — пропускай.
Что бы я сделал по-другому
Если бы стартовал этот сайт с нуля завтра, одна вещь, которую бы сделал раньше: замерять с первого дня. Настроить Lighthouse CI проверку в GitHub Actions, ронять сборку ниже порога, смотреть трейс на каждом PR. Поздние выигрыши на этом сайте пришли после того, как у меня появилась инфраструктура, чтобы видеть регрессии — которую я настроил на два месяца позже, чем стоило.
Остальные оптимизации — маленькие выигрыши, аккуратно сложенные. Нет одного трюка. Если ты ниже 90 — начни со шрифтов. Если застрял на 92–94 — аудить свой JS. Если на 95+ и гонишься за последними тремя очками — отдача на усилие падает жёстко.
Если хочешь перф-аудит своего Next.js или React Native приложения — я делаю разовые аудиты (€500, оборот 3 дня, письменный отчёт). Укажи в контактной форме, и я пришлю дату.