Перейти к содержимому

23. Производительность

Привет! Сегодня поговорим о производительности — теме, которая отличает хорошего разработчика от отличного. Можно написать красивый, рабочий код, но если он загружается 5 секунд — пользователи уйдут. По статистике Google, каждая дополнительная секунда загрузки снижает конверсию на 7%. Это деньги! 💸

Представь: ты открываешь сайт, а он грузится как Windows XP… В 2024 году это неприемлемо. Наша задача — сделать сайт молниеносным. Поехали! 🚀


Google измеряет качество пользовательского опыта через три метрики — Core Web Vitals. Они напрямую влияют на SEO-ранжирование с 2021 года:

LCP — Largest Contentful Paint 🖼️

  • Время до отрисовки самого крупного элемента на экране (обычно главное изображение или заголовок)
  • ✅ Хорошо: < 2.5 сек
  • ⚠️ Нужно улучшить: 2.5 — 4.0 сек
  • ❌ Плохо: > 4.0 сек

INP — Interaction to Next Paint 👆

  • Время от взаимодействия пользователя до следующего кадра (заменил FID с 2024)
  • ✅ Хорошо: < 200 мс
  • ⚠️ Нужно улучшить: 200 — 500 мс
  • ❌ Плохо: > 500 мс

CLS — Cumulative Layout Shift 📐

  • Суммарный сдвиг макета во время загрузки (когда элементы «прыгают»)
  • ✅ Хорошо: < 0.1
  • ⚠️ Нужно улучшить: 0.1 — 0.25
  • ❌ Плохо: > 0.25
До оптимизации:
─────────────────────────────
LCP: 4.2s ❌ Изображение загружается долго
INP: 380ms ⚠️ Тяжёлый JS блокирует поток
CLS: 0.18 ⚠️ Изображения без размеров прыгают
После оптимизации:
─────────────────────────────
LCP: 1.8s ✅ next/image + preload
INP: 95ms ✅ Code splitting + defer
CLS: 0.02 ✅ width/height у всех изображений

Прежде чем оптимизировать — нужно измерить. Без замеров ты стреляешь вслепую!

1. Lighthouse в Chrome DevTools:

Окно терминала
# Или через CLI:
npm install -g lighthouse
lighthouse https://example.com --view

2. PageSpeed Insights: Заходишь на pagespeed.web.dev, вводишь URL — получаешь реальные данные с телефонов пользователей (CrUX данные).

3. Chrome DevTools → Performance:

  • Запись реального взаимодействия
  • Flame chart: видишь что тормозит
  • Long Tasks (> 50мс) — враги INP

4. web-vitals пакет:

// app/layout.tsx или отдельный компонент
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric.name, metric.value);
// Отправляем в аналитику
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', metric.name, {
value: Math.round(
metric.name === 'CLS' ? metric.value * 1000 : metric.value
),
event_label: metric.id,
non_interaction: true,
});
}
});
return null;
}
// app/layout.tsx
import { WebVitals } from './web-vitals';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
<WebVitals />
{children}
</body>
</html>
);
}

Первый шаг оптимизации — понять, что занимает место в бандле:

Окно терминала
npm install --save-dev @next/bundle-analyzer
next.config.ts
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
const nextConfig: NextConfig = {
// ... твои настройки
};
export default withBundleAnalyzer(nextConfig);
package.json
{
"scripts": {
"analyze": "ANALYZE=true npm run build"
}
}
Окно терминала
npm run analyze
# Откроет интерактивную карту бандла в браузере
# Ищи большие блоки — это кандидаты на оптимизацию

Типичные находки при анализе:

  • moment.js — 230kb! Замени на date-fns (15kb нужных функций)
  • lodash — 70kb! Используй lodash-es или нативные методы
  • faker или devtools попали в прод-бандл

Code splitting — это разбивка кода на части, которые загружаются только когда нужны. Как доставка еды: зачем везти всё меню, если клиент заказал один бургер?

next/dynamic для компонентов:

app/dashboard/page.tsx
import dynamic from 'next/dynamic';
// Тяжёлый чарт грузится только на клиенте, когда нужен
const HeavyChart = dynamic(
() => import('@/components/HeavyChart'),
{
loading: () => <div>Загрузка графика...</div>,
ssr: false, // Не рендерить на сервере (если компонент Browser-only)
}
);
// Редактор кода — очень тяжёлый, грузим по требованию
const CodeEditor = dynamic(
() => import('@/components/CodeEditor'),
{
loading: () => <div className="skeleton h-64" />,
ssr: false,
}
);
// Модальное окно — грузим только после взаимодействия
const ConfirmModal = dynamic(() => import('@/components/ConfirmModal'));
export default function DashboardPage() {
const [showChart, setShowChart] = useState(false);
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Показать статистику</button>
{showChart && <HeavyChart data={data} />}
<button onClick={() => setShowEditor(true)}>Открыть редактор</button>
{showEditor && <CodeEditor />}
</div>
);
}

Именованные экспорты:

// Если компонент — именованный экспорт (не default)
const { NamedComponent } = dynamic(
() => import('@/components/Bundle').then(mod => mod),
{ ssr: false }
);
// Или через деструктуризацию:
const HeavyComponent = dynamic(
async () => {
const mod = await import('@/components/HeavyBundle');
return mod.HeavyComponent;
}
);

Нативный React способ (работает в Client Components):

components/LazySection.tsx
'use client';
import { lazy, Suspense, useState } from 'react';
// lazy загружает компонент только при первом рендере
const HeavyAnalytics = lazy(() => import('./HeavyAnalytics'));
const VideoPlayer = lazy(() => import('./VideoPlayer'));
function LoadingSkeleton({ height = 200 }: { height?: number }) {
return (
<div
style={{ height, background: '#1e293b', borderRadius: 8, animation: 'pulse 1.5s infinite' }}
/>
);
}
export function LazySection() {
const [showAnalytics, setShowAnalytics] = useState(false);
return (
<div>
{/* Suspense показывает fallback пока компонент грузится */}
<Suspense fallback={<LoadingSkeleton height={300} />}>
<VideoPlayer src="/intro.mp4" />
</Suspense>
<button onClick={() => setShowAnalytics(true)}>
Показать аналитику
</button>
{showAnalytics && (
<Suspense fallback={<LoadingSkeleton height={400} />}>
<HeavyAnalytics />
</Suspense>
)}
</div>
);
}

Разница next/dynamic vs React.lazy:

next/dynamic │ React.lazy
────────────────────────────┼──────────────────────────────
Работает в Server Components│ Только в Client Components
ssr: false опция │ Всегда клиентский
Встроенный loading пропс │ Нужен Suspense снаружи
Рекомендован в Next.js │ Нативный React подход

Next.js автоматически prefetch’ит ссылки, когда они в viewport. Но можно управлять этим вручную:

components/NavBar.tsx
import Link from 'next/link';
export function NavBar() {
return (
<nav>
{/* По умолчанию: prefetch при попадании в viewport */}
<Link href="/about">О нас</Link>
{/* Отключить prefetch для редко посещаемых страниц */}
<Link href="/legal/privacy" prefetch={false}>
Политика конфиденциальности
</Link>
{/* Принудительный prefetch всегда */}
<Link href="/dashboard" prefetch={true}>
Дашборд
</Link>
</nav>
);
}

Программный prefetch с useRouter:

'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export function ProductCard({ id }: { id: string }) {
const router = useRouter();
// Prefetch при hover — пользователь наводит мышь → мы уже грузим страницу
function handleMouseEnter() {
router.prefetch(\`/products/\${id}\`);
}
return (
<div onMouseEnter={handleMouseEnter}>
{/* ... */}
</div>
);
}

Напоминание о главных принципах:

import Image from 'next/image';
// ✅ Всегда указывай width и height — предотвращает CLS
<Image
src="/hero.jpg"
alt="Главный баннер"
width={1200}
height={600}
priority // LCP-изображение? Добавь priority!
/>
// ✅ fill для адаптивных контейнеров
<div style={{ position: 'relative', aspectRatio: '16/9' }}>
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ objectFit: 'cover' }}
/>
</div>
// ✅ Для изображений «ниже сгиба» — lazy (по умолчанию)
<Image
src="/gallery/photo-10.jpg"
alt="Фото"
width={400}
height={300}
loading="lazy" // по умолчанию, можно не писать
/>

Что делает next/image автоматически:

  • Конвертирует в WebP/AVIF (меньше размер)
  • Ресайзит под нужный viewport
  • Lazy loading out of the box
  • Prevents CLS (резервирует место)
  • Обслуживает через CDN (при Vercel)

Шрифты — частая причина CLS и медленного LCP:

app/layout.tsx
import { Inter, Roboto_Mono, Playfair_Display } from 'next/font/google';
import localFont from 'next/font/local';
// Google Font
const inter = Inter({
subsets: ['latin', 'cyrillic'],
variable: '--font-inter',
display: 'swap', // Показывает fallback шрифт до загрузки
preload: true,
});
// Моноширинный для кода
const robotoMono = Roboto_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
});
// Локальный шрифт
const customFont = localFont({
src: [
{ path: '../public/fonts/custom-regular.woff2', weight: '400' },
{ path: '../public/fonts/custom-bold.woff2', weight: '700' },
],
variable: '--font-custom',
display: 'swap',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="ru"
className={\`\${inter.variable} \${robotoMono.variable} \${customFont.variable}\`}
>
<body className="font-sans">{children}</body>
</html>
);
}
globals.css
:root {
--font-inter: 'Inter', sans-serif;
--font-mono: 'Roboto Mono', monospace;
}
body {
font-family: var(--font-inter);
}
code, pre {
font-family: var(--font-mono);
}

Почему next/font лучше ручного подключения:

  • Шрифты хостятся локально (нет DNS lookup к Google)
  • Автоматический preload нужных начертаний
  • CSS size-adjust предотвращает CLS при смене шрифтов
  • Zero layout shift — гарантировано!

Сторонние скрипты (аналитика, чат-боты, A/B тесты) — главные виновники медленного INP:

app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
{children}
{/* beforeInteractive: загружается до гидрации (для критичных скриптов) */}
<Script
src="/critical-polyfill.js"
strategy="beforeInteractive"
/>
{/* afterInteractive: после гидрации (Google Analytics, Яндекс.Метрика) */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_ID');
`}
</Script>
{/* lazyOnload: загружается в idle (чат-боты, соц. кнопки) */}
<Script
src="https://cdn.intercom.io/intercom.js"
strategy="lazyOnload"
onLoad={() => console.log('Intercom loaded')}
onError={() => console.error('Intercom failed')}
/>
</body>
</html>
);
}

Стратегии загрузки:

СтратегияКогда грузитсяБлокирует?Используй для
beforeInteractiveДо гидрации⚠️ ДаКритичные полифилы
afterInteractiveПосле гидрацииНетАналитика, реклама
lazyOnloadВ idle timeНетЧаты, соц. виджеты

Server Components не попадают в JS-бандл браузера! Это огромная победа:

app/blog/[slug]/page.tsx
// Весь этот код выполняется НА СЕРВЕРЕ и НЕ отправляется клиенту
import { MDXRemote } from 'next-mdx-remote/rsc';
import { SyntaxHighlighter } from '@/components/SyntaxHighlighter'; // тяжёлая библиотека
import { prisma } from '@/lib/prisma'; // секретный ключ не утечёт в браузер
interface Props {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
// Запрос к БД прямо в компоненте — нет waterfall запросов!
const post = await prisma.post.findUnique({ where: { slug } });
if (!post) return <div>Пост не найден</div>;
return (
<article>
<h1>{post.title}</h1>
{/* SyntaxHighlighter (200kb) НЕ попадёт в бандл браузера */}
<MDXRemote source={post.content} />
</article>
);
}

Правило минимальных Client Components:

// ✅ Правильно: только интерактивная часть — клиентская
// app/blog/[slug]/page.tsx (Server Component)
import { LikeButton } from '@/components/LikeButton'; // это 'use client'
export default async function BlogPost({ params }: Props) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Только кнопка лайка — клиентская, весь остальной текст — серверный */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
);
}

Streaming позволяет отправлять HTML частями — пользователь видит контент быстрее, не дожидаясь всей страницы:

app/dashboard/page.tsx
import { Suspense } from 'react';
// Эти компоненты загружают данные параллельно и стримятся по готовности
async function UserStats() {
const stats = await fetchUserStats(); // медленный запрос
return <StatsCard data={stats} />;
}
async function RecentOrders() {
const orders = await fetchOrders(); // ещё один медленный запрос
return <OrdersTable data={orders} />;
}
async function Notifications() {
const notifications = await fetchNotifications();
return <NotificationsList data={notifications} />;
}
export default function DashboardPage() {
return (
<div>
{/* Заголовок рендерится мгновенно */}
<h1>Дашборд</h1>
{/* Три секции загружаются параллельно, стримятся по готовности */}
<Suspense fallback={<Skeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Notifications />
</Suspense>
</div>
);
}

Loading UI с loading.tsx:

app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div>
<div className="skeleton h-8 w-48 mb-6" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="skeleton h-32 rounded-xl" />
))}
</div>
</div>
);
}

Edge Runtime — выполняется на серверах ближе к пользователю (CDN edge nodes). Меньше задержка, молниеносный ответ:

app/api/geo/route.ts
export const runtime = 'edge'; // 🔥 Включаем Edge Runtime
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
// Геолокация из заголовков (доступна на Vercel)
const country = request.geo?.country ?? 'RU';
const city = request.geo?.city ?? 'Moscow';
// Персонализированный ответ без холодного старта сервера
return Response.json({
message: \`Привет из \${city}, \${country}!\`,
latency: 'edge-fast',
});
}
// middleware.ts — всегда выполняется на Edge
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Auth проверка на Edge — молниеносно
const token = request.cookies.get('auth-token')?.value;
if (pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// A/B тест на Edge
const variant = request.cookies.get('ab-variant')?.value ?? (Math.random() > 0.5 ? 'a' : 'b');
const response = NextResponse.next();
response.cookies.set('ab-variant', variant);
return response;
}
export const config = {
matcher: ['/dashboard/:path*', '/((?!api|_next/static|favicon.ico).*)'],
};

Ограничения Edge Runtime:

  • Нет Node.js API (нет fs, path, и т.д.)
  • Нет некоторых npm пакетов
  • Нет WebSockets напрямую

app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
{children}
{/* Реальные данные Core Web Vitals от реальных пользователей */}
<Analytics />
{/* LCP, FID, CLS, TTFB, FCP прямо в Vercel Dashboard */}
<SpeedInsights />
</body>
</html>
);
}

lib/vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
type MetricName = 'CLS' | 'INP' | 'LCP' | 'FCP' | 'TTFB';
interface MetricRating {
good: number;
needsImprovement: number;
}
const thresholds: Record<MetricName, MetricRating> = {
CLS: { good: 0.1, needsImprovement: 0.25 },
INP: { good: 200, needsImprovement: 500 },
LCP: { good: 2500, needsImprovement: 4000 },
FCP: { good: 1800, needsImprovement: 3000 },
TTFB: { good: 800, needsImprovement: 1800 },
};
function getRating(name: MetricName, value: number): 'good' | 'needs-improvement' | 'poor' {
const t = thresholds[name];
if (value <= t.good) return 'good';
if (value <= t.needsImprovement) return 'needs-improvement';
return 'poor';
}
export function reportWebVitals() {
function sendToAnalytics({ name, value, id }: { name: string; value: number; id: string }) {
const rating = getRating(name as MetricName, value);
// Логируем локально
console.log(\`[\${name}] \${value.toFixed(0)} — \${rating}\`);
// Отправляем в собственный API
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({ name, value, rating, id, url: location.href }),
headers: { 'Content-Type': 'application/json' },
});
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}

ИЗОБРАЖЕНИЯ
□ next/image для всех изображений
□ priority для LCP изображения (первый экран)
□ Указаны width/height или fill+sizes
□ Нет GIF — используй видео или Lottie
ШРИФТЫ
□ next/font/google вместо ручного подключения
□ display: 'swap' для всех шрифтов
□ Только нужные subsets
СКРИПТЫ
□ next/script для всех сторонних скриптов
□ Стратегии: afterInteractive или lazyOnload
□ Аналитика не блокирует рендер
JAVASCRIPT
□ next/dynamic для тяжёлых компонентов
□ Server Components где возможно
□ Нет moment.js — используй date-fns
□ Tree-shaking работает (ESM imports)
СЕТЬ
□ Suspense + streaming для медленных данных
□ Link prefetch для важных страниц
□ HTTP/2 (Vercel включает автоматически)
ИЗМЕРЕНИЯ
□ Lighthouse > 90 для Performance
□ CLS < 0.1
□ LCP < 2.5s
□ INP < 200ms

Производительность — это не разовая задача, а постоянный процесс:

  1. Измеряй — Lighthouse, PageSpeed, web-vitals
  2. Анализируй — bundle analyzer, Chrome DevTools
  3. Оптимизируй — lazy loading, code splitting, images
  4. Мониторь — Vercel Analytics, собственные метрики
  5. Повторяй — с каждым релизом

Помни: преждевременная оптимизация — корень всех зол (Кнут). Сначала измерь, потом оптимизируй. Не оптимизируй то, что не тормозит! 🎯