Как ускорить веб-приложение: Core Web Vitals, бандл, изображения и сервер

Практический порядок оптимизации скорости: метрики LCP, INP и CLS, лабораторные и полевые данные, изображения и шрифты, долгие задачи, разбиение бандла, кэширование, работа с API и базой, RUM-мониторинг и бюджет производительности в CI.

Медленное веб-приложение редко ломается громко. Оно просто теряет людей: часть уходит, не дождавшись первого экрана, часть бросает форму, потому что кнопка откликается с задержкой. Хорошая новость в том, что скорость — измеримая величина, и у большинства проблем известные причины: тяжёлый JavaScript, неоптимизированные изображения, медленный ответ сервера, отсутствие кэширования. В этой статье — практический порядок работ: какие метрики смотреть, где искать узкие места и какие приёмы дают эффект в первую очередь. Без обещаний «ускорить в N раз»: насколько вырастет скорость, зависит от того, с чего вы начинаете, и честно ответить на этот вопрос можно только после замеров. Зачем бизнесу скорость: что известно из исследований Связь скорости и денег изучали много раз, но цифры часто пересказывают с ошибками. Два источника, на которые можно ссылаться. Deloitte, «Milliseconds Make Millions», 2020. Исследование по заказу Google на данных 37 брендов из ритейла, туризма, люкса и лидогенерации. Улучшение скорости мобильного сайта на 0,1 секунды сопровождалось ростом конверсии в ритейле на 8,4% и среднего чека на 9,2%, в туризме — ростом конверсии на 10,1%. Amazon, эксперимент середины 2000-х. Бывший инженер Amazon Грег Линден рассказывал, что в A/B-тестах искусственная задержка страницы на каждые 100 миллисекунд снижала продажи примерно на 1%. Цифре почти двадцать лет, и переносить её на любой сайт напрямую нельзя, но направление зависимости с тех пор никто не опроверг. Кроме того, Google учитывает Core Web Vitals в оценке качества страниц для поиска. Решающим фактором ранжирования скорость не является — содержание важнее, — но при прочих равных медленная страница проигрывает. Какие метрики измерять Core Web Vitals Google оценивает пользовательский опыт по трём основным метрикам. Страница считается хорошей, если порог выполняется для 75% визитов. Метрика Что показывает Хорошо Плохо LCP (Largest Contentful Paint) Когда отрисован самый крупный элемент первого экрана до 2,5 с более 4 с INP (Interaction to Next Paint) Сколько проходит от клика или нажатия до обновления экрана до 200 мс более 500 мс CLS (Cumulative Layout Shift) Насколько элементы «прыгают» при загрузке до 0,1 более 0,25 Если вы встречаете в материалах метрику FID (First Input Delay), это устаревшая информация. 12 марта 2024 года INP официально заменил FID в составе Core Web Vitals. FID учитывал только задержку первого взаимодействия, INP — отзывчивость на протяжении всего визита, поэтому многие сайты, у которых с FID всё было в порядке, по INP оказались в жёлтой или красной зоне. Вспомогательные метрики TTFB — время до первого байта ответа сервера. Хорошее значение — до 0,8 секунды. Если TTFB плохой, хорошего LCP не будет. FCP — время появления первого содержимого. Хорошее значение — до 1,8 секунды. TBT — суммарное время блокировки главного потока в лабораторном тесте. Косвенно указывает на проблемы с INP. Лабораторные и полевые данные Это разные вещи, и путать их — частая ошибка. Lighthouse в Chrome DevTools даёт лабораторный замер: страница загружается один раз в эмулированных условиях — по умолчанию это смартфон среднего класса на медленном 4G с замедлением процессора. Такой замер удобен для отладки, но не показывает, что видят реальные пользователи. Полевые данные — это замеры у настоящих посетителей. Их показывает PageSpeed Insights на основе Chrome UX Report для сайтов с достаточным трафиком, а также ваш собственный RUM-мониторинг. Решения принимаются по полевым данным, а лаборатория помогает найти причину. Порядок работ Зафиксировать исходные значения LCP, INP, CLS и TTFB по ключевым шаблонам страниц: главная, каталог, карточка, форма заявки, личный кабинет. Для каждой проблемной метрики найти конкретную причину в DevTools: панель Performance, вкладка Network, отчёт Lighthouse. Начать с правок, которые дают эффект на всех страницах сразу: кэширование, CDN, изображения, шрифты, разбиение бандла. Затем переходить к точечной работе: тяжёлые компоненты, медленные запросы API, долгие обработчики событий. После каждого изменения замерять заново и закрепить результат бюджетом производительности в CI. Как улучшить LCP LCP складывается из четырёх частей: ответ сервера, время до начала загрузки главного ресурса, сама загрузка и отрисовка. Оптимизировать нужно ту часть, которая занимает больше всего времени. Сделайте главный элемент обнаруживаемым сразу Если главное изображение первого экрана подставляется скриптом или задано фоном в CSS, браузер узнаёт о нём поздно. Лучше, чтобы оно было обычным img в HTML с высоким приоритетом загрузки. Атрибут fetchpriority с октября 2024 года поддерживается всеми основными браузерами. !-- Главное изображение: без ленивой загрузки, с высоким приоритетом -- img src="/img/hero-1200.avif" width="1200" height="600" fetchpriority="high" alt="Панель управления заказами" !-- Изображения ниже первого экрана: ленивая загрузка -- img src="/img/feature.avif" width="600" height="400" loading="lazy" alt="Отчёт по продажам" Главная ошибка — loading="lazy" на изображении первого экрана. Так делают, когда ленивую загрузку включают для всех картинок сразу, и LCP заметно ухудшается. Современные форматы и правильные размеры AVIF и WebP при том же визуальном качестве обычно заметно легче JPEG и PNG, а оба формата поддерживаются всеми актуальными браузерами. Не менее важно не отдавать телефону изображение, рассчитанное на широкий монитор. picture source type="image/avif" srcset="/img/card-400.avif 400w, /img/card-800.avif 800w" sizes="(max-width: 600px) 100vw, 400px" img src="/img/card-800.jpg" srcset="/img/card-400.jpg 400w, /img/card-800.jpg 800w" sizes="(max-width: 600px) 100vw, 400px" width="800" height="600" loading="lazy" alt="Карточка товара" /picture Генерацию форматов и размеров стоит автоматизировать: библиотека sharp на этапе сборки или загрузки, плагины сборщика, CDN с преобразованием изображений на лету. Ранние соединения и шрифты link rel="preconnect" href="https://cdn.example.ru" link rel="preload" href="/fonts/inter-var-cyr.woff2" as="font" type="font/woff2" crossorigin @font-face { font-family: 'Inter'; src: url('/fonts/inter-var-cyr.woff2') format('woff2'); font-weight: 100 900; font-display: swap; unicode-range: U+0000-00FF, U+0400-04FF; } Шрифты лучше размещать на своём домене или CDN, подключать в формате WOFF2 и урезать до нужных наборов символов — для русскоязычного сайта это латиница и кириллица. font-display: swap показывает текст системным шрифтом, пока загружается фирменный, вместо невидимого текста. Как улучшить INP Плохой INP почти всегда означает, что главный поток браузера занят долгой задачей в момент, когда пользователь нажимает кнопку. Причины: тяжёлые обработчики событий, массовые перерисовки интерфейса, сторонние скрипты — аналитика, чаты, виджеты. Дробите долгие задачи Если обработчику нужно выполнить много работы, сначала обновите интерфейс — покажите индикатор, отметьте нажатие, — а тяжёлую часть выполните после, уступив поток браузеру. function yieldToMain() { if (globalThis.scheduler?.yield) { return scheduler.yield(); } return new Promise((resolve) = setTimeout(resolve, 0)); } async function onApplyFilters(filters) { showSpinner(); // быстрый визуальный отклик await yieldToMain(); // даём браузеру отрисовать кадр const rows = applyFilters(allRows, filters); await yieldToMain(); renderTable(rows); } scheduler.yield() поддерживается не во всех браузерах — в Safari его нет, — поэтому нужен запасной вариант через setTimeout . Тяжёлые вычисления — в Web Worker Разбор больших файлов, сортировка десятков тысяч строк, построение отчётов на клиенте выносятся в Web Worker, чтобы не блокировать интерфейс. Если вычисления действительно тяжёлые, их можно реализовать на WebAssembly — когда это оправдано, разобрано в статье о WebAssembly . Лишние перерисовки в React В React-приложениях INP часто портят перерисовки больших частей дерева при каждом изменении состояния. Профилировщик React DevTools показывает, какие компоненты перерисовываются и почему. Основные лекарства: хранить состояние ближе к месту использования, подписываться на части глобального хранилища через селекторы, мемоизировать тяжёлые компоненты. React Compiler, вышедший в стабильной версии 1.0, выполняет мемоизацию автоматически. Подробно об устройстве состояния и перерисовках — в статье «State Management в React» . Длинные списки — только с виртуализацией Таблица на несколько тысяч строк, целиком отрисованная в DOM, тормозит и при загрузке, и при прокрутке. Виртуализация отрисовывает только видимые строки. Пример на TanStack Virtual: import { useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; function OrdersList({ orders }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: orders.length, getScrollElement: () = parentRef.current, estimateSize: () = 56, }); return ( div ref={parentRef} className="list" div style={{ height: virtualizer.getTotalSize(), position: 'relative' }} {virtualizer.getVirtualItems().map((item) = ( div key={item.key} style={{ position: 'absolute', top: 0, width: '100%', transform: `translateY(${item.start}px)` }} {orders[item.index].number} /div ))} /div /div ); } У контейнера .list должны быть фиксированная высота и overflow: auto . Как убрать сдвиги макета (CLS) Указывайте width и height у изображений и видео или задавайте aspect-ratio в CSS — браузер зарезервирует место заранее. Резервируйте место под баннеры, виджеты и блоки, которые подгружаются позже: фиксированная минимальная высота контейнера. Не вставляйте содержимое над уже показанным текстом — уведомления о cookies и промо-плашки лучше показывать поверх страницы. Подбирайте запасной шрифт близким по метрикам к основному, чтобы подмена при загрузке не сдвигала строки. Анимируйте transform и opacity , а не top , height и margin . JavaScript-бандл Каждый килобайт JavaScript нужно скачать, разобрать и выполнить, и на недорогих телефонах это стоит заметно дороже, чем на ноутбуке разработчика. Порядок действий такой. Разбейте код по маршрутам import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; const Reports = lazy(() = import('./pages/Reports')); const Settings = lazy(() = import('./pages/Settings')); export function App() { return ( Suspense fallback={ PageSkeleton / } Routes Route path="/reports" element={ Reports / } / Route path="/settings" element={ Settings / } / /Routes /Suspense ); } Пользователь, открывший главную страницу, не должен скачивать код административной панели и редактора отчётов. Проверьте, что лежит в бандле Визуализаторы вроде rollup-plugin-visualizer для Vite или webpack-bundle-analyzer показывают вклад каждой зависимости. Типичные находки: устаревшие тяжёлые библиотеки там, где хватает встроенных средств: Intl.DateTimeFormat и Intl.NumberFormat вместо библиотек форматирования дат и чисел; импорт всей библиотеки утилит ради пары функций; несколько версий одной библиотеки из-за конфликтов зависимостей; тяжёлые компоненты — редакторы, графики, карты, — которые попали в общий бандл вместо отдельного чанка; полифилы для браузеров, которые вы давно не поддерживаете. Сторонние скрипты Счётчики, пиксели, чаты и A/B-тесты нередко весят больше собственного кода. Проведите инвентаризацию: что реально используется, что можно загружать после взаимодействия или с задержкой, что можно перенести на серверную сторону. Кэширование и CDN Правильные заголовки кэширования ускоряют повторные визиты без изменения кода. # Статика с хешем в имени файла: кэшировать надолго Cache-Control: public, max-age=31536000, immutable # HTML: всегда проверять актуальность Cache-Control: no-cache # Публичные ответы API: короткий кэш с фоновым обновлением Cache-Control: public, max-age=60, stale-while-revalidate=300 # Персональные данные: не кэшировать на промежуточных узлах Cache-Control: private, no-store CDN сокращает сетевую задержку для пользователей, которые далеко от вашего сервера, — для России с её расстояниями это особенно заметно. Включите сжатие Br