Модуль 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 для не критичных вычислений
tsximport { 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
Установка
bashnpm 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
bashnpm 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 | Блокирующий JS | next/dynamic для тяжёлых компонентов |
| CLS > 0.1 | <img> без width/height | Фикс. размеры или Skeleton |
| CLS из-за шрифта | @font-face без font-display: swap | font-display: swap + preload |
| INP > 200ms | Долгий обработчик onChange | useDeferredValue, виртуализация |
| Lighthouse показывает 0 для CLS | Это хорошо — нет layout shift | — |
10.11 Артефакты сдачи М10
| Артефакт | Критерий |
|---|---|
| Lighthouse-отчёт (HTML) | LCP < 2.5s, CLS < 0.1 на /ui-gallery |
| size-limit вывод | Все компоненты в пределах лимитов |
| Скриншот Profiler | VirtualList с 1000 сообщениями — < 16ms на скролл |
| CI workflow | Lighthouse 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 и конфликт сборок.
🧩 Проверь себя
🔗 Мостик к Модулю 11
Метрики в норме, Lighthouse зелёный. Но мессенджер должен работать офлайн — нужен Service Worker. В Модуле 11 превратим приложение в PWA и решим конфликт сборок Vite/Next.js.