Роутинг и стратегии рендеринга: SPA, SSR, SSG, ISR, гидратация
В формах мы работали внутри одного экрана. Теперь — про то, как экранов становится много и откуда берётся HTML для каждого из них. Это самая заболтанная и самая плохо понятая тема во фронтенде: аббревиатуры SSR, SSG, ISR, RSC, PPR произносят как заклинания, а на собеседовании выясняется, что человек не может объяснить, почему кнопка на отрендеренной сервером странице секунду не реагирует на клик.
Разберём с нуля. Договоримся сразу: здесь два независимых вопроса, и путаница почти всегда рождается из их смешения.
- Роутинг — кто решает, что показать по данному URL: сервер (новый документ на каждый переход) или JavaScript в уже загруженной странице.
- Рендеринг — где и когда собирается 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, и так на каждом уровне вложенности.
или target не _self?"} B -->|да| C["Не мешаем браузеру:
обычная навигация"] B -->|нет| D["preventDefault"] D --> E["Матчинг URL по таблице маршрутов"] E -->|не совпало| F["Экран 404 без обращения к серверу"] E -->|совпало| G["Догружаем чанк маршрута,
если его ещё нет в памяти"] G --> H["Стартуем loader'ы ВСЕХ уровней
вложенности параллельно"] H --> I{"Критические данные
пришли за 100 мс?"} I -->|да| J["startTransition:
рисуем новый экран сразу"] I -->|нет| K["Показываем скелет,
старый экран не мигает"] J --> L["pushState"] K --> L L --> M["Восстановить скролл,
перевести фокус на h1,
объявить заголовок в aria-live"]
Лечение — 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> на сервере — это точка разреза потока. Всё, что снаружи границ, обязано попасть в оболочку и определяет 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, на которых обжигаются все:
- Кэш живёт на каждой ноде отдельно. Два инстанса — два независимых кэша, пользователи видят разные версии страницы. Лечится общим кэш-хендлером (Redis, S3) или инвалидацией через CDN.
- Ревалидацию запускает трафик. Страница без посетителей не обновится никогда, а на редко посещаемой странице почти каждый пользователь будет получать stale.
- Кэшируется всё дерево. Один персональный блок внутри — и страница либо становится динамической, либо начинает показывать чужие данные. Это, кстати, самый частый инцидент безопасности в Next-приложениях.
Без фреймворка ровно то же делается на CDN заголовком stale-while-revalidate — механика описана в RFC 5861, а не изобретена вендорами.
PPR: статическая оболочка с дырками
Частичный пререндеринг — попытка снять дилемму «страница либо статическая, либо динамическая». На билде рендерится оболочка, всё динамическое оборачивается в <Suspense> и оставляет в HTML заглушку; при запросе статика мгновенно отдаётся с CDN, а дырки заполняются стримингом с сервера. Технология молодая (в Next она за флагом), но модель правильная и наверняка приживётся: гранулярность выбора стратегии падает с уровня страницы до уровня компонента.
Гидратация: механика и её цена
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 просто не видят.
Источники
- React:
renderToPipeableStream,hydrateRoot,<Suspense> - Dan Abramov, New Suspense SSR Architecture in React 18 — лучшее объяснение стриминга и selective hydration
- React Server Components RFC и Making Sense of React Server Components
- React Router: data loading, TanStack Router
- MDN: History API, View Transition API, Navigation API
- RFC 5861: stale-while-revalidate, web.dev: Keeping things fresh with SWR
- Next.js: Caching, Partial Prerendering
- Astro: Islands Architecture, Jason Miller, Islands Architecture
- Qwik: Resumable vs Hydration
- Chrome: Long Animation Frames API, Speculation Rules
- web.dev: Rendering on the Web — статья Джейсона Миллера и Аддая Османи, с которой началась вся терминология
Что дальше
Стратегию выбрали, гидратацию порезали, метрики расставили. Дальше — сама дисциплина производительности: что такое LCP, INP и CLS в деталях, как ставить бюджеты и защищать их в CI, как читать flame chart, ленивая загрузка изображений и шрифтов, и почему «оптимизация» без измерения — это ритуал, а не инженерия.
Производительность фронтенда: Core Web Vitals, бюджеты, ленивая загрузка