14 мин

Производительность фронтенда

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 imagessrcset с размерами под разные экраны: 400w для мобильных, 800w для десктопа, 1200w для retina.
  • Lazy loadingloading="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-страниц на CDN
  • Cache-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 при наведении. Это даёт максимальный эффект при минимальных изменениях.