Производительность фронтенда
Core Web Vitals, code splitting, lazy loading, виртуализация и кэширование
- Web Vitals
- Lazy Loading
- Виртуализация
- Кэширование
Введение
Производительность — не абстрактная цель, а измеримая метрика, которая напрямую влияет на бизнес. Исследования показывают: увеличение времени загрузки на 1 секунду снижает конверсию на 7%. Google учитывает Core Web Vitals при ранжировании. На System Design интервью от вас ждут конкретных решений с числами и инструментами.
Core Web Vitals
Три метрики, которые Google считает ключевыми для UX:
LCP — Largest Contentful Paint
Время до рендеринга самого крупного видимого элемента (обычно hero-изображение или заголовок). Цель: ≤ 2.5 секунд.
Что влияет: медленный TTFB, блокирующие ресурсы (CSS, шрифты), тяжёлые изображения без оптимизации, медленный серверный рендеринг.
Как улучшить:
- SSR/SSG для HTML выше fold
fetchpriority="high"на LCP-изображение- Preload критических ресурсов:
<link rel="preload" as="image" href="..."> - Шрифты с
font-display: swap+ preload
INP — Interaction to Next Paint
Задержка между действием пользователя (клик, тап, ввод) и визуальным обновлением. Цель: ≤ 200ms. Заменил FID в 2024.
Что влияет: тяжёлые JavaScript-обработчики, длинные задачи в main thread, синхронные layout/paint операции.
Как улучшить:
- Разбивать длинные задачи с
requestIdleCallbackилиscheduler.yield() - Debounce/throttle обработчиков ввода
- Использовать
useTransitionдля низкоприоритетных обновлений - Избегать синхронного чтения layout (offsetHeight, getBoundingClientRect) внутри event handlers
CLS — Cumulative Layout Shift
Суммарный сдвиг layout за время жизни страницы. Цель: ≤ 0.1. Происходит когда элементы меняют размер или появляются после рендера.
Как предотвратить:
- Указывать
width/heightдля изображений и видео, или использоватьaspect-ratio - Резервировать пространство для шрифтов:
font-display: optionalилиsize-adjust - Не вставлять контент выше видимой области (баннеры, уведомления)
- Skeleton-плейсхолдеры вместо спиннеров
Code Splitting
Разделение бандла на части, которые загружаются по требованию. Цель — сократить initial bundle и ускорить TTI.
Route-based splitting
Next.js делает это автоматически: каждый маршрут — отдельный чанк. Пользователь загружает только код текущей страницы.
Component-level splitting
Для тяжёлых компонентов, которые не нужны при первом рендере:
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("@/components/Chart"), {
loading: () => <ChartSkeleton />,
ssr: false, // не рендерить на сервере — бессмысленно для Canvas/SVG-чартов
});
const RichEditor = dynamic(() => import("@/components/RichEditor"), {
loading: () => <EditorPlaceholder />,
});
Conditional splitting
Загружать модуль по действию пользователя:
async function handleExport() {
const { exportToPDF } = await import("@/lib/pdf-export");
await exportToPDF(data);
}
Lazy Loading компонентов
Компоненты ниже fold не нужны при начальном рендере. Загружайте их при приближении к viewport:
import { useInView } from "react-intersection-observer";
function LazySection({ children }: { children: React.ReactNode }) {
const { ref, inView } = useInView({
triggerOnce: true,
rootMargin: "200px", // начать загрузку за 200px до видимости
});
return <div ref={ref}>{inView ? children : <Skeleton />}</div>;
}
Виртуализация списков
При рендеринге 1000+ элементов (лента, таблица, каталог) DOM содержит тысячи узлов, потребляя память и замедляя paint. Виртуализация рендерит только видимые элементы (обычно 10–20 из тысяч).
Библиотеки:
- react-virtuoso — поддерживает динамическую высоту, идеален для лент и чатов
- react-window — фиксированная высота, легче и проще
- @tanstack/react-virtual — headless-подход, максимальная гибкость
import { Virtuoso } from "react-virtuoso";
<Virtuoso
data={items}
overscan={5} // рендерить 5 элементов вне viewport для плавного скролла
endReached={loadMore}
itemContent={(index, item) => <ListItem item={item} />}
components={{ Footer: () => isLoading ? <Spinner /> : null }}
/>
Оптимизация изображений
Изображения — обычно самый тяжёлый ресурс на странице. Стратегии оптимизации:
- Современные форматы — WebP (на 25–35% легче JPEG), AVIF (на 50% легче). CDN с auto-negotiation по заголовку Accept.
- Responsive images —
srcsetс размерами под разные экраны: 400w для мобильных, 800w для десктопа, 1200w для retina. - Lazy loading —
loading="lazy"для изображений ниже fold,loading="eager"+fetchpriority="high"для LCP-изображения. - Blur placeholder (LQIP) — base64-превью 20×20 px отображается мгновенно, пока загружается полное изображение. Устраняет CLS.
- Aspect ratio — CSS
aspect-ratio: 16/9резервирует место, предотвращая layout shift.
Next.js <Image> делает всё это из коробки: автоматическая оптимизация, srcset, lazy loading, blur placeholder.
Кэширование
HTTP-кэширование
Cache-Control: public, max-age=31536000, immutable— для статики с хэшем в имени (JS, CSS, шрифты)Cache-Control: s-maxage=60, stale-while-revalidate=300— для ISR-страниц на CDNCache-Control: no-store— для персонализированных ответов API
Service Worker кэширование
Стратегии: Cache First для статики (JS, CSS, шрифты, постеры), Stale While Revalidate для API-ответов каталога, Network First для критичных данных (корзина, профиль).
In-memory кэширование
React Query кэширует ответы API в памяти с настраиваемым staleTime и gcTime. Навигация между страницами мгновенна, если данные ещё свежие.
Bundle-анализ и Tree Shaking
Регулярно анализируйте состав бандла:
# Next.js: визуальный анализ бандла
ANALYZE=true next build
# Или используйте @next/bundle-analyzer
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
Типичные находки: полный lodash (70KB) вместо lodash-es (tree-shakeable), moment.js (300KB) вместо date-fns (tree-shakeable), тяжёлые icon-пакеты без tree shaking.
Tree shaking работает только с ES-модулями (import/export). Убедитесь, что зависимости поддерживают ESM — проверьте поле "module" или "exports" в package.json библиотеки.
Checklist для интервью
- Называйте конкретные числа: LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1
- Для каждой оптимизации объясняйте trade-off: code splitting уменьшает initial bundle, но добавляет задержку при навигации
- Упоминайте инструменты измерения: Lighthouse, Chrome DevTools Performance, Web Vitals JS library, CrUX (реальные данные)
- Всегда начинайте с измерения, а не с оптимизации — «premature optimization is the root of all evil»
Ключевой принцип: оптимизируйте критический путь рендеринга. Всё, что выше fold — загружайте eagerly и приоритетно. Всё, что ниже — lazy load, defer, prefetch при наведении. Это даёт максимальный эффект при минимальных изменениях.