Производительность — 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:

ts
import { 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):

ts
function 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.
tsx
const 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 (не перерисовывать все сообщения при новом):
tsx
const 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:
tsx
import { 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)Лишний запрос, если шрифт не используется

🧩 Проверь себя

1. LCP (Largest Contentful Paint) — good когда...
2. CLS (Cumulative Layout Shift) измеряет...
3. Core Web Vitals (Google) — три главные метрики?
4. Layout shift (CLS > 0.1) — частые причины?
5. INP (Interaction to Next Paint) — измеряет...

🔗 Мостик к Модулю 11

Метрики в норме. Теперь — UI Kit и дизайн-токены.