Модуль 10. Производительность и Core Web Vitals

Цель модуля: оптимизировать приложение под ключевые метрики (LCP < 2.5s, CLS < 0.1, INP < 200ms) и настроить Lighthouse CI + size-limit для автоматической проверки.

К концу модуля у вас:

  • Виртуализированный список сообщений через VirtualList (из М9).
  • ImageLazy с фиксированными размерами (нулевой CLS).
  • Готовый Lighthouse CI workflow.
  • size-limit на компоненты UI Kit — CI падает при превышении.

Перед началом: Модули 1-9. Инструменты: Lighthouse, size-limit. Время: ~5 ч.


🔥 Инцидент. Google понижает сайт в выдаче: LCP = 4.2 секунды (норма < 2.5), CLS = 0.3 (норма < 0.1). Пользователи уходят ещё до первого взаимодействия. Причина — массивный JS-бандл блокирует первый рендер, а картинки без размеров дёргают layout при загрузке.


📖 Теория

10.1 Core Web Vitals: что и зачем

МетрикаЦельЧто измеряетТипичная причина проблемы
LCP (Largest Contentful Paint)< 2.5sВремя отрисовки самого крупного элементаБлокирующий JS, тяжёлые шрифты, большой hero-image
CLS (Cumulative Layout Shift)< 0.1Сумма «прыжков» контента<img> без размеров, динамические баннеры, шрифты
INP (Interaction to Next Paint)< 200msЗадержка реакции на вводДолгие обработчики, большие ререндеры

10.2 CLS — фиксированные размеры

Проблема: аватары без размеров

tsx
// ❌ CLS при загрузке аватара
<img src={user.avatar} alt={user.name} />
// Место под картинку занимает 0px → при загрузке контент прыгает
tsx
// ✅ Фиксированные размеры (уже в Avatar из М5)
<Avatar src={user.avatar} alt={user.name} size="md" />
// size="md" → width={40}, height={40} → место зарезервировано → CLS = 0

Проблема: сообщения без skeleton

tsx
// ❌ Перед загрузкой $messages список пуст → при загрузке появляется → прыжок
{messages.map((m) => <MessageRow key={m.id} message={m} />)}
tsx
// ✅ Skeleton того же размера (из М6)
{status === 'loading' && <MessageSkeleton />}
{status === 'done' && messages.map((m) => <MessageRow key={m.id} message={m} />)}

Проблема: шрифты

css
/* ❘l next.config.js — preload критического шрифта и font-display: swap */
{ /* Шрифт загружается асинхронно, не блокирует LCP */ }

10.3 LCP — минимизация блокирующего JS

Code-splitting тяжёлых пикеров

tsx
// app/app/ui-gallery/page.tsx — ленивая загрузка EmojiPicker
import { lazy, Suspense } from 'react'
const EmojiPicker = lazy(() => import('@myorg/ui-kit/emoji-picker').then(m => ({ default: m.EmojiPicker })))

// Открывается только при клике → не входит в initial bundle
{showPicker && (
  <Suspense fallback={<Loader />}>
    <EmojiPicker onSelect={handleSelect} />
  </Suspense>
)}

Lazy-load изображений

tsx
// ✅ loading="lazy" + width/height
<img src={src} alt={alt} width={40} height={40} loading="lazy" />

10.4 INP — оптимизация обработчиков

Проблема: синхронный ререндер всего списка

tsx
// ❘l При каждом нажатии клавиши ререндерится весь список сообщений
<input onChange={(e) => setMessage(e.target.value)} />
{messages.map(m => <MessageRow message={m} highlighted={m.text.includes(e.target.value)} />)}
tsx
// ✅ Виртуализация (из М9) — ререндерится только видимая часть
<VirtualList
  items={filteredMessages}
  renderItem={(m) => <MessageRow message={m} />}
  estimateSize={72}
  height={600}
/>

useDeferredValue для не критичных вычислений

tsx
import { useDeferredValue } from 'react'
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
// messages фильтруются по deferredQuery — не блокируют ввод
const filtered = messages.filter(m => m.text.includes(deferredQuery))

10.5 Виртуализация списка сообщений

В М9 мы создали VirtualList. Теперь применяем его к списку сообщений:

tsx
// app/widgets/message-list/MessageList.tsx
'use client'
import { VirtualList } from '@myorg/ui-kit'
import { MessageRow } from './MessageRow'
import type { Message } from '@/entities/message/model'

export function MessageList({ messages }: { messages: Message[] }) {
  return (
    <VirtualList
      items={messages}
      renderItem={(m) => <MessageRow message={m} />}
      estimateSize={72}
      height={600}
      overscan={5}
    />
  )
}

10.6 Оптимизация API-ответов

Кэширование Route Handler

ts
// app/app/api/messages/route.ts
// Статические сообщения — кэшируются на уровне маршрута
export const revalidate = 60  // секунды

export async function GET() {
  // ... логика
  return NextResponse.json(data, {
    headers: { 'Cache-Control': 's-maxage=60, stale-while-revalidate=300' },
  })
}

Compression

Next.js автоматически сжимает ответы через gzip/brotli в production (next build).


10.7 Lighthouse CI

Установка

bash
npm install -D @lhci/cli

Конфиг

json
// lighthouserc.json
{
  "ci": {
    "collect": {
      "startServerCommand": "npm run build:app && npm run start:app",
      "url": ["http://localhost:3000/ui-gallery"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "categories:performance": ["warn", { "minScore": 0.8 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["warn", { "maxNumericValue": 300 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

CI workflow (обновляем существующий)

yaml
# .github/workflows/ci.yml — добавляем job
lighthouse:
  name: Lighthouse CI
  runs-on: ubuntu-latest
  needs: quality
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with: { node-version: '22', cache: 'npm' }
    - run: npm ci
    - run: npm run build:ui-kit && npm run build:app
    - run: npx @lhci/cli autorun
      env:
        LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_TOKEN }}

10.8 size-limit на компоненты UI Kit

bash
npm install -D size-limit @size-limit/file
json
// ui-kit/package.json — добавляем
{
  "size-limit": [
    { "name": "Box", "path": "dist/components/Box/index.js", "limit": "2 KB" },
    { "name": "Text", "path": "dist/components/Text/index.js", "limit": "2 KB" },
    { "name": "Button", "path": "dist/components/Button/index.js", "limit": "3 KB" },
    { "name": "Modal", "path": "dist/components/Modal/index.js", "limit": "5 KB" },
    { "name": "Total index", "path": "dist/index.js", "limit": "15 KB" }
  ],
  "scripts": {
    "size": "size-limit"
  }
}

CI падает, если любой компонент превышает лимит:

yaml
# .github/workflows/ci.yml — добавляем step в quality job
- name: Check bundle size
  run: npm run size --workspace ui-kit

Компромиссы (Trade-offs)

РешениеПлюсыМинусы
Code-splitting (lazy)Меньше initial bundle, быстрее LCPДополнительный round-trip при первом открытии
loading="lazy" + размерыНулевой CLS, экономия трафикаПервый рендер картинки — задержка
useDeferredValueВвод не блокируется, отзывчивый INPПользователь видит отложенный результат
Виртуализация спискаРендер O(visible) — низкий INPСложнее измерение и sticky-headers
Lighthouse CIЛовит регрессии метрик в PRМедленный CI, нужен production-билд

💻 10.9 Практика

Шаг 1. Виртуализация MessageList

Замените прямой .map() на VirtualList в виджете MessageList.

Шаг 2. Skeleton при загрузке

Добавьте MessageSkeleton (из М6) при $status === 'loading'.

Шаг 3. Lighthouse CI

Установите @lhci/cli, создайте lighthouserc.json, добавьте job в CI.

Шаг 4. size-limit

Настройте size-limit в ui-kit/package.json, добавьте в CI.

Шаг 5. Измерить «до/после»

bash
# Без оптимизации: закомментируйте VirtualList → прямой .map()
npm run build:app && npx @lhci/cli collect --url=http://localhost:3000/ui-gallery

# С оптимизацией: раскомментируйте
npm run build:app && npx @lhci/cli collect --url=http://localhost:3000/ui-gallery

Сравните метрики. Приложите отчёт как артефакт.


10.10 Мини-чек-лист типичных проблем

СимптомПричинаРешение
LCP > 2.5sБлокирующий JSnext/dynamic для тяжёлых компонентов
CLS > 0.1<img> без width/heightФикс. размеры или Skeleton
CLS из-за шрифта@font-face без font-display: swapfont-display: swap + preload
INP > 200msДолгий обработчик onChangeuseDeferredValue, виртуализация
Lighthouse показывает 0 для CLSЭто хорошо — нет layout shift—

10.11 Артефакты сдачи М10

АртефактКритерий
Lighthouse-отчёт (HTML)LCP < 2.5s, CLS < 0.1 на /ui-gallery
size-limit выводВсе компоненты в пределах лимитов
Скриншот ProfilerVirtualList с 1000 сообщениями — < 16ms на скролл
CI workflowLighthouse job зелёный

10.12 Чек-лист перед Модулем 11

  • MessageList виртуализирован через VirtualList.
  • Skeleton занимает точное место контента при загрузке.
  • LCP < 2.5s, CLS < 0.1 (Lighthouse на /ui-gallery).
  • size-limit настроен, CI падает при превышении.
  • Lighthouse CI workflow добавлен в .github/workflows/ci.yml.
  • Я понимаю три метрики (LCP/CLS/INP) и типичные причины их деградации.

Вперёд → Модуль 11: базовый PWA и конфликт сборок.


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

1. Core Web Vitals — три главные метрики?
2. CLS измеряет...
3. LCP — good когда...
4. LCP optimize — что чаще всего помогает?
5. CLS от шрифтов (FOIT/FOUT) — как убрать?

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

Метрики в норме, Lighthouse зелёный. Но мессенджер должен работать офлайн — нужен Service Worker. В Модуле 11 превратим приложение в PWA и решим конфликт сборок Vite/Next.js.