Производительность — Core Web Vitals
Из M9: Effector — путь масштабирования стейта. Теперь — производительность UI: измеряем и оптимизируем Core Web Vitals до зелёных значений.
Перед началом: Модули 1–9. Инструменты: Lighthouse, Web Vitals. Время: ~4 ч.
🔥 Инцидент. Google PageSpeed — 45/100. LCP = 5.2 секунды (норма < 2.5). CLS = 0.35 (норма < 0.1). JS-бандл — 1.8 МБ. Изображения без размеров дёргают layout, шрифты блокируют рендер. Пользователи уходят: каждая секунда задержки = −7% конверсии.
📖 Теория
Что такое Core Web Vitals
Три ключевые метрики от Google, влияющие на ранжирование и UX:
| Метрика | Что измеряет | Хорошо | Плохо |
|---|---|---|---|
| LCP (Largest Contentful Paint) | время до отрисовки самого большого элемента | < 2.5 с | > 4 с |
| CLS (Cumulative Layout Shift) | сумма «скачков» layout за время загрузки | < 0.1 | > 0.25 |
| INP (Interaction to Next Paint) | задержка ответа на действие пользователя | < 200 мс | > 500 мс |
INP заменил FID в 2024 году. FCP (First Contentful Paint) — вспомогательная метрика.
Как измерять
Библиотека web-vitals:
tsimport { onLCP, onCLS, onINP, onFCP, onTTFB } from "web-vitals" onLCP(console.log) // LCP — самый большой элемент отрисован onCLS(console.log) // CLS — кумулятивный сдвиг layout onINP(console.log) // INP — задержка взаимодействия
Lighthouse — полный аудит (Performance, Accessibility, Best Practices, SEO) в Chrome DevTools.
Production: отправлять метрики в аналитику (Yandex Metrika, Google Analytics, кастомный endpoint):
tsfunction sendToAnalytics(metric: any) { fetch("/api/metrics", { method: "POST", body: JSON.stringify({ name: metric.name, value: metric.value, id: metric.id }), keepalive: true, // не блокировать unload }) } onLCP(sendToAnalytics) onCLS(sendToAnalytics) onINP(sendToAnalytics)
LCP: оптимизация загрузки
Проблема в чате: история сообщений (сотни элементов) загружается после API-запроса → LCP высокий.
Решения:
- Skeleton / placeholder фиксированной высоты → LCP фиксируется на skeleton, а не на пустом экране.
- Preload критических ресурсов (шрифт, основной CSS) через
<link rel="preload">. - Code splitting — главный бандл только со стартовым экраном (login), чат — lazy-load.
tsxconst ChatRoom = lazy(() => import("./features/chat-room/ChatRoom")) <Suspense fallback={<ChatSkeleton />}> <ChatRoom /> </Suspense>
- Server-Side Rendering / Static — для начального HTML (если есть SSR; для PWA — prefetch данных в Service Worker).
CLS: предотвращение скачков layout
Проблема в чате: новые сообщения «сдвигают» весь список; изображения/аватары неизвестной высоты.
Решения:
- Фиксированные размеры для аватаров, изображений, контейнеров сообщений (
aspect-ratio,min-height). - Skeleton правильной высоты (не
height: 100pxдля всего, а реалистичный placeholder). - Резервирование места для входящих сообщений ( spacer в конце списка, который не «прыгает»).
content-visibility: autoдля длинных списков (браузер пропускает рендер невидимого).
css.message-row { min-height: 56px; /* фиксируем минимальную высоту */ content-visibility: auto; /* пропускаем невидимые */ contain-intrinsic-size: 56px; } .avatar { width: 40px; height: 40px; aspect-ratio: 1; /* не схлопывается при загрузке */ }
INP: отзывчивость взаимодействий
Проблема: отправка сообщения → полный ре-рендер списка → задержка.
Решения:
React.memoдляMessageBubble(не перерисовывать все сообщения при новом):
tsxconst MessageBubble = memo(({ msg }: { msg: Message }) => ( <div className="msg-bubble">{msg.text}</div> ), (prev, next) => prev.msg.id === next.msg.id && prev.msg.text === next.msg.text)
useCallback/useMemoдля стабильных ссылок (handlers, derived data).- Виртуализация списка (
react-window) — рендерить только видимые ~20 элементов из 1000:
tsximport { FixedSizeList } from "react-window" <FixedSizeList height={600} itemCount={messages.length} itemSize={56}> {({ index, style }) => ( <div style={style}><MessageBubble msg={messages[index]} /></div> )} </FixedSizeList>
- Не блокировать main thread — тяжёлые вычисления (дедупликация, фильтрация) в Web Worker.
Практика
- Установить
web-vitals, замерить LCP/CLS/INP на ChatRoom → записать значения. - Добавить
ChatSkeletonфиксированной высоты → проверить, что CLS уменьшился. - Обернуть
MessageBubbleвReact.memo→ проверить, что INP при отправке уменьшился. - Включить виртуализацию (
react-window) на списке из 1000 сообщений → сравнить FPS. - Прогнать Lighthouse → сфотографировать результат → цель: все метрики в зелёной зоне.
💱 Компромиссы (Trade-offs)
| Решение | Плюсы | Минусы |
|---|---|---|
Code-splitting (lazy) | Меньше initial bundle, ниже LCP | Лишний round-trip при первом открытии чата |
| Skeleton фиксированной высоты | LCP фиксируется раньше, нулевой CLS | Заглушка вместо реального контента на старте |
React.memo / useMemo | Меньше перерисовок, ниже INP | Стабильные ссылки требуют дисциплины |
Виртуализация (react-window) | Рендер O(visible) — быстрый скролл | Сложнее sticky-headers и измерение высоты |
| Preload шрифтов | Раньше отрисовка текста (LCP) | Лишний запрос, если шрифт не используется |
🧩 Проверь себя
🔗 Мостик к Модулю 11
Метрики в норме. Теперь — UI Kit и дизайн-токены.