Frontend-разработка Роутинг и стратегии рендеринга: SPA, SSR, SSG, ISR, гидратация
0%

Роутинг и стратегии рендеринга: SPA, SSR, SSG, ISR, гидратация

Роутинг и стратегии рендеринга: SPA, SSR, SSG, ISR, гидратация

В формах мы работали внутри одного экрана. Теперь — про то, как экранов становится много и откуда берётся HTML для каждого из них. Это самая заболтанная и самая плохо понятая тема во фронтенде: аббревиатуры SSR, SSG, ISR, RSC, PPR произносят как заклинания, а на собеседовании выясняется, что человек не может объяснить, почему кнопка на отрендеренной сервером странице секунду не реагирует на клик.

Разберём с нуля. Договоримся сразу: здесь два независимых вопроса, и путаница почти всегда рождается из их смешения.

  1. Роутинг — кто решает, что показать по данному URL: сервер (новый документ на каждый переход) или JavaScript в уже загруженной странице.
  2. Рендеринг — где и когда собирается HTML: в браузере, на сервере по запросу, на сборке, заранее с фоновым обновлением.

Эти оси перпендикулярны. Бывает SSR с клиентским роутингом (Next.js: первый экран с сервера, дальше переходы без перезагрузки). Бывает SSG с серверным роутингом (сайт на Hugo — как этот). Бывает CSR c серверным роутингом (набор независимых страниц, каждая — свой SPA). Держите эти оси раздельно, и половина вопросов исчезнет.

Маятник качался тридцать лет и вернулся почти туда же — но с важным отличием: теперь мы умеем выбирать стратегию для каждого куска страницы отдельно, а не для приложения целиком.

Часть I. Роутинг

Что браузер делает сам и что вы теряете, когда перехватываете навигацию

Обычный переход по ссылке — это полный цикл: запрос, новый документ, уничтожение старого JS-контекста, новый парсинг CSS и скриптов. За это браузер бесплатно даёт целый список вещей, о которых в SPA приходится думать руками.

Что происходит при переходе MPA (браузер сам) SPA (пишете вы)
История, назад/вперёд из коробки pushState + слушатель popstate
Восстановление скролла из коробки ручной Map<key, scrollY>
Перенос фокуса в начало документа из коробки focus() на заголовок с tabindex="-1"
Объявление новой страницы скринридером из коробки aria-live-регион с заголовком
Отмена «висящих» запросов старой страницы из коробки AbortController
Индикатор загрузки нативный, в адресной строке свой скелет/прогресс-бар
Утечки памяти между экранами невозможны вполне возможны
Сохранение состояния при переходе невозможно ради этого всё и затевалось
Стоимость перехода новый документ, повторный парсинг JSON + перерисовка поддерева

Последние две строки — весь смысл клиентского роутинга. Если ваши экраны не делят состояние (открытый плеер, наполовину заполненная форма, длинный скролл ленты) и переход между ними редок — MPA честно быстрее и дешевле в поддержке. Не стройте SPA по привычке.

History API — весь фундамент

// Меняем URL, не трогая документ. Первый аргумент — сериализуемое состояние,
// оно переживёт перезагрузку и вернётся в event.state при back/forward.
history.pushState({ scrollY: 0 }, '', '/projects/42');
history.replaceState({ scrollY: 120 }, '', location.href); // без новой записи в истории

// ВАЖНО: pushState и replaceState НЕ генерируют событие popstate.
// popstate прилетает только на back/forward и на history.go().
window.addEventListener('popstate', (e) => console.log(e.state));

// Браузер по умолчанию сам восстанавливает скролл — и делает это ДО того,
// как ваш роутер отрисовал новый экран. В SPA это почти всегда мешает.
history.scrollRestoration = 'manual';

Три ограничения, о которые спотыкаются: объект состояния сериализуется структурным клонированием (функции и DOM-узлы туда не положить), его размер ограничен (Firefox — 16 МиБ, поэтому не храните там данные, только ключи), и частые pushState браузер тротлит как защиту от спама историей.

Минимальный роутер целиком

Роутер — это подписка на URL плюс таблица соответствий. Напишем его на useSyncExternalStore: это ровно тот случай, для которого хук создан — внешний источник, живущий вне React.

// src/router/store.ts — источник истины о текущем URL, ноль зависимостей
type Listener = () => void;
const listeners = new Set<Listener>();

const read = () => location.pathname + location.search + location.hash;
let snapshot = read();

function emit() {
  const next = read();
  if (next === snapshot) return;   // снимок сравнивается по ссылке: строка идеальна
  snapshot = next;
  listeners.forEach((l) => l());
}

export function subscribe(l: Listener) {
  listeners.add(l);
  // addEventListener с той же функцией идемпотентен — дублей не будет
  window.addEventListener('popstate', emit);
  return () => { listeners.delete(l); };
}

export const getSnapshot = () => snapshot;
export const getServerSnapshot = () => '/';  // на сервере URL берётся из запроса

export function navigate(to: string, { replace = false } = {}) {
  history[replace ? 'replaceState' : 'pushState']({}, '', to);
  emit();                                    // pushState молчит — зовём вручную
}
// src/router/Link.tsx — правильная ссылка: не ломает привычки пользователя
import type { AnchorHTMLAttributes, MouseEvent } from 'react';
import { navigate } from './store';

type Props = AnchorHTMLAttributes<HTMLAnchorElement> & { to: string; replace?: boolean };

export function Link({ to, replace, onClick, ...rest }: Props) {
  function handle(e: MouseEvent<HTMLAnchorElement>) {
    onClick?.(e);
    if (e.defaultPrevented) return;
    if (e.button !== 0) return;                                   // не левая кнопка
    if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; // «открыть в новой вкладке»
    if (rest.target && rest.target !== '_self') return;
    if (new URL(to, location.href).origin !== location.origin) return; // внешний домен
    e.preventDefault();
    navigate(to, { replace });
  }
  // href обязателен: без него нет курсора, нет «копировать адрес», нет средней кнопки
  return <a href={to} onClick={handle} {...rest} />;
}

Каждая из этих проверок — реальный баг из продакшена. Роутеры, написанные «за пять минут», ломают Ctrl+клик, и пользователь неделю ругается молча.

Матчинг: почему /users/new должен побеждать /users/:id

Порядок объявления маршрутов — плохой контракт: стоит переставить строки, и приложение сломается. Роутеры считают вес маршрута и сортируют сами.

type Route = { path: string; element: React.ReactNode; lazy?: () => Promise<unknown> };

// "/users/:id/posts/*" -> регулярка + вес. Статический сегмент дороже
// динамического, динамический — дороже catch-all.
function compile(path: string) {
  const segments = path.split('/').filter(Boolean);
  let score = 0;
  const source = segments
    .map((seg) => {
      if (seg === '*') { score += 1; return '(?<wildcard>.*)'; }
      if (seg.startsWith(':')) { score += 4; return `(?<${seg.slice(1)}>[^/]+)`; }
      score += 10;
      return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    })
    .join('/');
  return { re: new RegExp(`^/${source}/?$`), score, depth: segments.length };
}

// Компилируем ОДИН раз на модуле, а не на каждый рендер
const table = routes
  .map((r) => ({ ...r, ...compile(r.path) }))
  .sort((a, b) => b.score - a.score || b.depth - a.depth);

export function match(pathname: string) {
  for (const r of table) {
    const m = r.re.exec(pathname);
    if (m) return { route: r, params: (m.groups ?? {}) as Record<string, string> };
  }
  return null;
}

Сложность: O(R) регулярок на навигацию, где R — число маршрутов. При R < 200 это десятки микросекунд, оптимизировать нечего. Серверные фреймворки, где R измеряется тысячами и матчинг делается на каждый запрос, используют radix-trie и получают O(L) по длине пути (find-my-way в Fastify, httprouter в Go — см. трек Go). На клиенте это оверинжиниринг.

Вложенные маршруты: layout, который не размонтируется

Ключевая идея современных роутеров — URL описывает стек вложенных UI, а не одну страницу. /projects/42/tasks = корневой layout → layout проекта → экран задач. При переходе на /projects/42/settings перерисовывается только последний уровень: шапка, боковое меню и — что важнее — их состояние (скролл списка, открытые аккордеоны) сохраняются.

import { createBrowserRouter, RouterProvider, Outlet } from 'react-router';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,          // <header/> + <Outlet/> + <footer/>
    errorElement: <RootError />,      // ловит ошибки любого потомка
    children: [
      { index: true, element: <Home />, loader: homeLoader },
      {
        path: 'projects/:projectId',
        element: <ProjectLayout />,
        loader: projectLoader,
        children: [
          { index: true, element: <Overview />, loader: overviewLoader },
          { path: 'tasks', element: <Tasks />, loader: tasksLoader },
          { path: 'settings', element: <Settings /> },
        ],
      },
      { path: '*', element: <NotFound /> },
    ],
  },
]);

Главный перф-баг роутинга: каскад запросов

Наивный подход — «компонент смонтировался, компонент запросил данные» — порождает водопад. Родитель рендерится, идёт за проектом, ждёт 200 мс; только потом рендерится ребёнок и идёт за задачами, ещё 200 мс. Итог 400 мс вместо 200, и так на каждом уровне вложенности.

Лечение — render-as-you-fetch: запрос стартует в момент навигации, из данных маршрута, а не из тела компонента. Именно это делают loader’ы React Router/Remix и beforeLoad/loader в TanStack Router: роутер знает всё дерево совпавших маршрутов заранее и запускает их загрузчики одновременно.

import type { LoaderFunctionArgs } from 'react-router';

export async function projectLoader({ params, request }: LoaderFunctionArgs) {
  // signal привязан к навигации: ушли с экрана — запрос отменится сам
  const res = await fetch(`/api/projects/${params.projectId}`, { signal: request.signal });
  if (res.status === 404) throw new Response('Проект не найден', { status: 404 });

  return {
    project: await res.json(),                  // критично: ждём
    // Промис отдаём НЕ дожидаясь — роутер отстримит его после первой отрисовки
    activity: fetch(`/api/projects/${params.projectId}/activity`).then((r) => r.json()),
  };
}
function Project() {
  const { project, activity } = useLoaderData<typeof projectLoader>();
  return (
    <>
      <h1>{project.title}</h1>
      <Suspense fallback={<ActivitySkeleton />}>
        <Await resolve={activity}>{(items) => <Activity items={items} />}</Await>
      </Suspense>
    </>
  );
}

Разделение на «критичное» и «желательное» — самый дешёвый способ ускорить переход: экран появляется по самому быстрому запросу, а не по самому медленному. Подробности про кэш и инвалидацию — в статье про работу с данными.

Code splitting по маршрутам и prefetch по намерению

Разбивать бандл по маршрутам — единственный сплит, который окупается всегда: пользователь не платит за экраны, куда не зашёл. Механика сборки разобрана в статье про сборку, здесь — как этим управляет роутер.

// Чанк маршрута + его данные греем ЗАРАНЕЕ, по признаку намерения:
// наведение мышью даёт примерно 200-300 мс форы до клика.
const warmed = new Set<string>();

function warm(to: string) {
  if (warmed.has(to)) return;
  // Уважаем экономию трафика: на 2G и с Data Saver ничего не греем
  const c = (navigator as any).connection;
  if (c?.saveData || /(^|-)2g$/.test(c?.effectiveType ?? '')) return;
  warmed.add(to);
  const m = match(to);
  m?.route.lazy?.();                     // подтягиваем JS-чанк
  queryClient.prefetchQuery(queryFor(m)); // и данные
}

export const prefetchProps = (to: string) => ({
  onMouseEnter: () => warm(to),
  onFocus: () => warm(to),      // клавиатура: намерение выражается табом
  onTouchStart: () => warm(to), // тач: между touchstart и click ~90 мс
});

Три уровня агрессивности, по возрастанию риска: intent (наведение/фокус) — почти всегда правильный выбор; viewport (IntersectionObserver на ссылки в зоне видимости) — хорош для лент и списков; render (грузим всё сразу) — годится только для двух-трёх ключевых экранов, иначе вы просто вернули себе большой бандл. На уровне платформы то же самое делает Speculation Rules API — декларативный prefetch/prerender без единой строчки JS.

То, что SPA ломает: скролл, фокус, объявление

function useRouteChrome(pathname: string, title: string) {
  const h1 = useRef<HTMLHeadingElement>(null);
  const positions = useRef(new Map<string, number>());

  // Сохраняем позицию перед уходом
  useEffect(() => () => { positions.current.set(pathname, window.scrollY); }, [pathname]);

  useEffect(() => {
    document.title = title;                       // вкладка и история
    // Фокус в начало нового экрана: иначе Tab продолжит с места старой страницы,
    // а скринридер не заметит, что контент сменился
    h1.current?.focus();
    const y = positions.current.get(pathname);
    window.scrollTo(0, y ?? 0);                   // назад — на старое место, вперёд — вверх
  }, [pathname, title]);

  return h1;                                       // <h1 tabIndex={-1} ref={h1}>
}

Плюс живой регион, который читает заголовок нового экрана:

<div aria-live="polite" aria-atomic="true" className="visually-hidden">{title}</div>

Это не «дополнительная фича для особых пользователей» — это восстановление того, что вы отобрали, перехватив клик. Подробный разбор — в статье про доступность.

Отдельная ловушка безопасности: параметр вроде ?next=/dashboard после логина. Никогда не редиректьте по нему без проверки — ?next=https://evil.example даёт открытый редирект. Разрешайте только пути, начинающиеся с одного / и не с //.

View Transitions: анимация перехода без библиотек

function navigateWithTransition(to: string) {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!document.startViewTransition || reduce) return navigate(to);
  // Браузер делает снимок старого состояния, применяет callback,
  // снимает новое и анимирует между ними
  document.startViewTransition(() => flushSync(() => navigate(to)));
}
/* Для MPA переходы включаются вообще без JS */
@view-transition { navigation: auto; }

/* Элементы с одинаковым именем «перелетают» между экранами */
.card__cover { view-transition-name: hero; }
::view-transition-old(hero),
::view-transition-new(hero) { animation-duration: 220ms; }

view-transition-name должно быть уникальным на странице в момент перехода — два элемента с одним именем отменят анимацию целиком.

Файловые роутеры

Ручные таблицы маршрутов вытеснены файловыми конвенциями: структура каталогов и есть структура URL, а сборщик заодно делает code splitting.

Файл в Next.js App Router Роль
page.tsx экран по этому URL
layout.tsx обёртка, переживающая переходы внутри сегмента
template.tsx то же, но пересоздаётся на каждом переходе
loading.tsx автоматический <Suspense fallback> для сегмента
error.tsx границa ошибок сегмента, клиентский компонент
not-found.tsx ответ на notFound()
route.ts HTTP-эндпоинт вместо экрана
[id] / [...slug] / (group) динамический сегмент / catch-all / группировка без влияния на URL

Аналоги: pages/ в Nuxt и SvelteKit, app/routes/ в React Router framework mode. Отдельно стоит TanStack Router: там маршруты типизированы, и navigate({ to: '/projects/$id', params: { id } }) проверяется компилятором — опечатка в пути становится ошибкой сборки, а не белым экраном. Про типы см. трек TypeScript.

Часть II. Стратегии рендеринга

Что каждая стратегия реально двигает

Стратегия влияет не на «скорость» вообще, а на конкретные метрики. Держите эту таблицу в голове, когда кто-то говорит «давайте сделаем SSR, будет быстрее».

TTFB FCP / LCP Готовность к вводу (TTI, INP) Стоимость инфраструктуры
CSR отличный (статика с CDN) плохой: три круга — HTML, JS, API средняя минимальная
SSR хуже: сервер думает хороший не улучшается — гидратация та же нужен живой сервер под нагрузкой
SSR + стриминг как у статики лучший улучшается за счёт selective hydration та же, плюс требования к прокси
SSG лучший лучший не улучшается почти нулевая
ISR / SWR-кэш лучший (при попадании) лучший не улучшается CDN + фоновые рендеры
Острова / RSC как у базовой стратегии лучший резко лучше: JS меньше в разы зависит от базовой

Ключевая строка — третий столбец. SSR сам по себе не делает страницу интерактивной быстрее. Он ускоряет появление пикселей. Между «пиксели есть» и «кнопка работает» лежит гидратация, и её стоимость от SSR не зависит — она зависит от количества JS.

CSR: пустой div и три сетевых круга

<body><div id="root"></div><script type="module" src="/assets/index.js"></script></body>

Что видит пользователь на медленном 4G: 0.2 с — белый экран (пришёл HTML), 1.4 с — всё ещё белый (качается 200 КБ JS), 1.9 с — скелет (React смонтировался, ушёл за данными), 2.6 с — контент. Три последовательных круга: HTML → JS → API. Каждый круг нельзя начать, не закончив предыдущий, — это водопад по определению.

Когда CSR — правильный выбор: всё за логином (SEO не нужен, первый экран смягчается сплэшем), инструменты с длинной сессией (редакторы, дашборды, IDE), приложения, где первый заход раз в неделю, а взаимодействий — тысячи. Что можно выжать, не меняя стратегию: пререндерить оболочку статикой (шапка и скелет в index.html), поднять критический API-запрос из компонента в модульную область (стартует одновременно с парсингом JS), добавить <link rel="preconnect"> к домену API и modulepreload для главного чанка.

SSR: HTML собирается на каждый запрос

// server.tsx — продакшн-минимум на React 19 + Express
import { renderToPipeableStream } from 'react-dom/server';

const ABORT_MS = 10_000;

app.get('*', (req, res) => {
  let didError = false;
  const isCrawler = /bot|crawler|spider|googlebot|yandex/i.test(req.get('user-agent') ?? '');

  const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
    bootstrapModules: ['/assets/client.js'],   // React сам вставит <script type="module">
    // Людям отдаём оболочку как можно раньше; краулерам — целиком,
    // чтобы содержимое Suspense попало в исходный HTML
    [isCrawler ? 'onAllReady' : 'onShellReady']() {
      res.status(didError ? 500 : 200);
      res.setHeader('Content-Type', 'text/html; charset=utf-8');
      res.setHeader('Cache-Control', 'no-transform'); // просим прокси не буферизовать
      pipe(res);
    },
    onShellError() {
      // Оболочка не собралась — стримить нечего, отдаём статический fallback
      res.status(500).type('html').send('<!doctype html><p>Ошибка</p>');
    },
    onError(err) { didError = true; logger.error(err); },
  });

  // Один зависший запрос не должен держать соединение вечно
  setTimeout(abort, ABORT_MS);
});

Что здесь важно понимать про стоимость. renderToString — синхронная функция: пока она работает, event loop Node заблокирован, никакие другие запросы не обслуживаются. Сложность O(n) по числу узлов, на практике 5–40 мс на реальную страницу. При 100 rps и 30 мс на рендер вам нужно три ядра только на HTML — и это до всяких запросов к БД. Стриминговый вариант отдаёт управление между чанками, но суммарную работу не уменьшает.

Второе: состояние надо сериализовать в страницу, иначе клиент запросит те же данные повторно и вы получите SSR со всеми минусами CSR.

// Экранируем ОБЯЗАТЕЛЬНО: строка "</script>" внутри JSON закрывает тег
const safe = JSON.stringify(data).replace(/</g, '\\u003c');
// <script>window.__DATA__ = ${safe}</script>

Третье: SSR отлично кэшируется, если страница не персональная.

Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600
Vary: Accept-Encoding

Читается так: браузер не кэширует (max-age=0), CDN считает ответ свежим 60 секунд, а следующие 10 минут отдаёт устаревшую копию мгновенно и обновляет её в фоне. Один этот заголовок часто даёт больше, чем месяц оптимизации React-кода. Ловушка: не забудьте Vary по всему, от чего зависит ответ (язык, тема, флаги A/B) — иначе пользователи получат чужие страницы. Персонализация и кэш — прямые враги; типичное решение — кэшировать общий каркас, а личное вставлять отдельным запросом или через edge-подстановку.

Стриминг с Suspense: FCP как у статики при живых данных

Анатомия стримингового ответа: чанки, заглушки Suspense и подстановка готовых блоков

Суть: <Suspense> на сервере — это точка разреза потока. Всё, что снаружи границ, обязано попасть в оболочку и определяет TTFB. Всё, что внутри, уезжает заглушкой и досылается по готовности, в порядке готовности данных, а не разметки.

export default function Feed() {
  return (
    <Shell>                                   {/* попадёт в оболочку, TTFB зависит от него */}
      <Suspense fallback={<PostsSkeleton />}>
        <Posts />                               {/* внутри await — уедет вторым чанком */}
      </Suspense>
      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations />
      </Suspense>
    </Shell>
  );
}

Правило проектирования: держите вне Suspense только то, что зависит от быстрых данных. Один медленный запрос в оболочке убивает весь выигрыш от стриминга. И проверьте прокси — proxy_buffering on в nginx склеит чанки, и стриминг превратится в обычный SSR без единой ошибки в логах.

SSG: HTML собирается на билде

// app/blog/[slug]/page.tsx (Next.js App Router)
export const revalidate = 3600;      // фоновое обновление раз в час
export const dynamicParams = true;   // новый slug отрендерится по первому запросу

export async function generateStaticParams() {
  // На билде — только «горячие» страницы: остальные подъедут лениво
  const posts = await getTopPosts(500);
  return posts.map((p) => ({ slug: p.slug }));
}

Единственная проблема SSG — масштаб. 50 000 страниц по 40 мс = 33 минуты билда, и это при каждой правке опечатки. Отсюда все ухищрения: рендерить на билде только топ и добирать остальное по запросу, кэшировать результаты между билдами, инкрементальные сборки. Если контента много и он часто меняется — чистый SSG вам не подходит, нужен ISR.

ISR: свежесть без пересборки

ISR — это stale-while-revalidate, поднятый на уровень фреймворка. Страница живёт в кэше с TTL; после истечения первый пришедший запрос получает устаревшую копию мгновенно, а в фоне запускается новый рендер.

// app/api/revalidate/route.ts — вебхук из CMS: точечная инвалидация вместо TTL
import { revalidateTag } from 'next/cache';

export async function POST(req: Request) {
  const { secret, tag } = await req.json();
  if (secret !== process.env.REVALIDATE_SECRET) return new Response(null, { status: 401 });
  revalidateTag(tag);                       // помечает устаревшими все записи с этим тегом
  return Response.json({ revalidated: true });
}

// А теги ставятся на месте запроса данных:
const post = await fetch(url, { next: { tags: [`post:${slug}`], revalidate: 3600 } });

Три ловушки ISR, на которых обжигаются все:

  1. Кэш живёт на каждой ноде отдельно. Два инстанса — два независимых кэша, пользователи видят разные версии страницы. Лечится общим кэш-хендлером (Redis, S3) или инвалидацией через CDN.
  2. Ревалидацию запускает трафик. Страница без посетителей не обновится никогда, а на редко посещаемой странице почти каждый пользователь будет получать stale.
  3. Кэшируется всё дерево. Один персональный блок внутри — и страница либо становится динамической, либо начинает показывать чужие данные. Это, кстати, самый частый инцидент безопасности в Next-приложениях.

Без фреймворка ровно то же делается на CDN заголовком stale-while-revalidate — механика описана в RFC 5861, а не изобретена вендорами.

PPR: статическая оболочка с дырками

Частичный пререндеринг — попытка снять дилемму «страница либо статическая, либо динамическая». На билде рендерится оболочка, всё динамическое оборачивается в <Suspense> и оставляет в HTML заглушку; при запросе статика мгновенно отдаётся с CDN, а дырки заполняются стримингом с сервера. Технология молодая (в Next она за флагом), но модель правильная и наверняка приживётся: гранулярность выбора стратегии падает с уровня страницы до уровня компонента.

Гидратация: механика и её цена

Гидратация: прикрепление дерева к DOM и объём JS при полной гидратации, островах и resumability

hydrateRoot не создаёт DOM — он идёт по существующим узлам параллельно с выполнением компонентов и «прикрепляет» к ним состояние и слушатели. Отсюда все следствия.

Работа делается дважды. Сервер выполнил компоненты ради строки, клиент выполняет те же компоненты ради дерева. Весь код компонентов всё равно едет в браузер — даже для абзаца текста, который никогда не изменится.

Разметка обязана совпасть. React 19 при расхождении выбрасывает поддерево и рендерит его заново на клиенте, попутно роняя LCP и добавляя сдвиг макета. Причины расхождений всегда одни и те же: Date/Math.random/crypto.randomUUID в теле компонента, обращение к window/localStorage, локаль и таймзона сервера против браузерных, useId в обход React, невалидный HTML (<div> внутри <p> — браузер переставит узлы, и дерево уже не то), расширения браузера, вставляющие свои узлы.

Единственный честный способ показать значение, известное только клиенту:

// У useSyncExternalStore есть ОТДЕЛЬНЫЙ снимок для сервера,
// поэтому первый клиентский рендер гарантированно совпадёт с HTML
function useMediaQuery(query: string) {
  const subscribe = useCallback((cb: () => void) => {
    const mql = matchMedia(query);
    mql.addEventListener('change', cb);
    return () => mql.removeEventListener('change', cb);
  }, [query]);

  return useSyncExternalStore(
    subscribe,
    () => matchMedia(query).matches,  // клиент
    () => false,                       // сервер: детерминированное значение
  );
}

suppressHydrationWarning — не решение, а глушилка: она подавляет предупреждение для текста одного узла (типично — отформатированная дата) и не чинит расхождение структуры. typeof window !== 'undefined' в теле компонента — прямой путь к mismatch: на сервере ветка одна, на клиенте другая, а первый рендер обязан быть одинаковым.

Гидратацию можно резать. React 18+ гидратирует каждую границу <Suspense> независимо (selective hydration), а если пользователь кликнул по ещё не оживлённому блоку — React перехватывает событие, гидратирует нужную границу первой и переигрывает событие. Практический вывод: <Suspense> — не только про загрузку данных, это ещё и способ разбить одну длинную задачу гидратации на несколько прерываемых. Что напрямую бьёт по INP — см. производительность.

Острова, RSC, resumability: три способа не гидратировать лишнее

Острова (Astro, Fresh, Marko). Страница — статический HTML, интерактивные компоненты помечаются явно и оживают независимо друг от друга. Директива задаёт не «оживить», а «когда оживить»:

---
const posts = await getPosts();   // выполняется на сборке/сервере, в браузер не поедет
---
<Layout>
  <PostList posts={posts} />                    <!-- 0 КБ JS -->
  <Search client:idle />                        <!-- когда главный поток освободится -->
  <Comments client:visible />                   <!-- когда доскроллили -->
  <Chart client:media="(min-width: 768px)" />   <!-- только на десктопе -->
  <Widget client:only="react" />                <!-- вообще без SSR -->
</Layout>

Ограничение честное: острова изолированы, общее состояние между ними приходится тянуть через внешнее хранилище (nanostores и подобные) — см. управление состоянием. Для контентных сайтов это идеальный размен; для приложения, где интерактивно всё, островов просто не получится.

RSC (React Server Components). Серверные компоненты выполняются только на сервере и не попадают в бандл вообще. По проводу едет не HTML, а сериализованное описание дерева (Flight-формат), которое React вклеивает в существующее дерево без потери клиентского состояния.

// app/product/[id]/page.tsx — серверный компонент по умолчанию
import { AddToCart } from './add-to-cart';

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } }); // прямой доступ к БД
  return (
    <article>
      <h1>{product.title}</h1>
      {/* markdown-парсер на 120 КБ остался на сервере */}
      <Description html={renderMarkdown(product.description)} />
      <AddToCart id={product.id} price={product.price} />
    </article>
  );
}
// add-to-cart.tsx
'use client';
// Это НЕ «сделать компонент клиентским». Это «отсюда начинается клиентский граф»:
// всё, что импортируется ниже, поедет в браузер.
export function AddToCart({ id, price }: { id: string; price: number }) { /* ... */ }

Два правила, которые надо усвоить сразу: у серверных компонентов нет состояния, эффектов и обработчиков; props через границу должны быть сериализуемы — функцию не передать, а вот серверный компонент можно передать клиентскому как children, и это главный приём композиции в RSC.

Resumability (Qwik). Радикальный вариант: в HTML сериализуется не только данные, но и граф слушателей — какой обработчик к какому элементу и какой кусок состояния ему нужен. При старте выполняется крошечный загрузчик; настоящий код скачивается в момент первого клика по конкретной кнопке. Гидратации нет как этапа. Плата: маленькая экосистема, непривычная отладка, ограничения на замыкания. Выигрыш реален на контентных сайтах и почти незаметен в тяжёлых приложениях, где код всё равно понадобится весь.

Как выбирать — честно

Практические правила, которые редко подводят. Сомневаетесь между SSG и SSR — берите SSG: он дешевле, надёжнее и всегда быстрее, а перейти на SSR потом легче, чем обратно. Нужен SEO — нужен HTML с контентом на первом ответе; Google выполняет JS, но с задержкой и не всегда, а остальные краулеры и превью в мессенджерах не выполняют вовсе. Продукт целиком за логином — SSR почти бессмыслен, вы платите за инфраструктуру ради экрана, который пользователь видит раз в неделю. Страница на 90% статична — думайте про острова или RSC раньше, чем про мемоизацию. И главное: стратегия выбирается на страницу, а не на приложение — в одном проекте нормально иметь статический лендинг, ISR-каталог и CSR-кабинет.

Сравнение самих фреймворков — Next, Remix, Nuxt, SvelteKit, Astro, SolidStart, Qwik — вынесено в отдельную статью про фреймворки; там же про то, чем React проигрывает и выигрывает у Vue, Svelte, Angular и Solid.

Как это измерять, а не угадывать

Любое утверждение «SSR ускорит нам сайт» проверяется числами. Инструментов ровно четыре.

1. Server-Timing — единственный способ увидеть, из чего состоит TTFB, прямо в DevTools → Network → Timing.

res.setHeader('Server-Timing',
  `db;dur=${dbMs}, render;dur=${renderMs}, cache;desc="HIT", total;dur=${totalMs}`);

2. Navigation Timing — реальные цифры первой загрузки из браузеров пользователей.

const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
// responseStart — TTFB; responseEnd - responseStart — сколько длился стриминг;
// domContentLoadedEventEnd — когда разобран HTML и выполнены модули

3. Long Animation Frames — находит именно те задачи, которые держат поток во время гидратации.

new PerformanceObserver((list) => {
  for (const e of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
    if (e.blockingDuration < 50) continue;
    // scripts[] показывает файл и функцию, а не абстрактное «Scripting»
    report({ dur: e.duration, blocking: e.blockingDuration, scripts: e.scripts.map((s) => s.sourceURL) });
  }
}).observe({ type: 'long-animation-frame', buffered: true });

4. Свои метки на клиентскую навигацию — потому что Core Web Vitals их не видят.

Это важный и малоизвестный факт: LCP и CLS измеряются один раз, за первую загрузку документа. Клиентские переходы метрики не пересчитывают. Поэтому SPA с мгновенным первым экраном и десятисекундными переходами внутри имеет прекрасные «зелёные» CWV в отчётах. Экспериментальный Soft Navigations API это чинит, но пока меряйте сами:

export function instrumentNavigation(to: string) {
  const start = performance.now();
  return () => {
    // Вызываем, когда новый экран отрисован с реальными данными
    const dur = performance.now() - start;
    if (dur > 500) analytics.track('slow_soft_nav', { to, dur });
  };
}

И общее правило: Lighthouse — это лаборатория для сравнения «до и после» на одном железе, а решения принимаются по полевым данным (CrUX, web-vitals в своей аналитике). Подробный разбор метрик и бюджетов — следующая статья.

Типичные ошибки

  • Считать, что SSR ускоряет интерактивность. Он ускоряет пиксели. Готовность к вводу определяется объёмом JS.
  • Запрашивать данные из useEffect на новом экране. Это каскад по определению: сначала рендер, потом запрос, и так на каждом уровне вложенности. Данные — забота маршрута.
  • Ставить SSR ради SEO на страницу за логином. Краулер туда не попадёт никогда.
  • Терять фокус, скролл и объявление при переходе. Перехватили клик — верните всё, что отобрали у браузера.
  • Ломать Ctrl+клик и среднюю кнопку. Ссылка обязана быть <a href> с полным набором проверок в обработчике.
  • Чинить hydration mismatch через suppressHydrationWarning или typeof window. Первое глушит симптом, второе гарантирует расхождение.
  • Кэшировать персональную страницу. ISR или s-maxage без Vary и без выноса личного блока — это утечка данных, а не оптимизация.
  • Прогревать все маршруты сразу. Prefetch без разбора — это тот же большой бандл, только по частям и с чужого трафика.
  • Стримить через буферизующий прокси. Работает, ошибок нет, эффекта ноль.
  • Держать медленный запрос вне границы Suspense. Оболочка ждёт его целиком, и стриминг не даёт ничего.
  • Верить зелёным CWV в SPA. Они описывают только первую загрузку.

Мини-итог

Роутинг и рендеринг — две независимые оси, и путаница почти всегда рождается из их склеивания. Клиентский роутинг — это pushState плюс подписка на popstate плюс таблица маршрутов, отсортированная по весу сегментов; всё остальное в роутере — восстановление того, что вы отобрали у браузера: скролла, фокуса, объявления, отмены запросов. Главный перф-баг здесь не рендер, а каскад запросов, и лечится он переносом загрузки из компонента в маршрут — render-as-you-fetch вместо fetch-on-render.

По второй оси выбор описывается двумя вопросами: одинаков ли контент для всех и как скоро он устаревает. Одинаков и стабилен — SSG. Одинаков, но меняется — ISR или stale-while-revalidate на CDN. Персонален — SSR, желательно со стримингом, где <Suspense> работает точкой разреза потока и позволяет отдать FCP как у статики. Не нужен ни SEO, ни первый экран — честный CSR без лишней инфраструктуры.

Гидратация — то, что связывает обе оси и стоит дороже всего. Она выполняет ту же работу второй раз, тащит в браузер весь код компонентов и требует побайтового совпадения разметки. Именно поэтому вся отрасль последние пять лет ищет способ её не делать: острова оживляют только помеченное, серверные компоненты вообще не едут в бандл, resumability заменяет гидратацию сериализованным графом слушателей. И в любом из этих миров решение принимается по числам — Server-Timing, Navigation Timing, LoAF и собственные метки на клиентские переходы, которых Core Web Vitals просто не видят.

Источники

Что дальше

Стратегию выбрали, гидратацию порезали, метрики расставили. Дальше — сама дисциплина производительности: что такое LCP, INP и CLS в деталях, как ставить бюджеты и защищать их в CI, как читать flame chart, ленивая загрузка изображений и шрифтов, и почему «оптимизация» без измерения — это ритуал, а не инженерия.

Производительность фронтенда: Core Web Vitals, бюджеты, ленивая загрузка

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов