CSR, SSR и SSG, детерминированный HTML, hydrateRoot, useId, потоковая передача, выборочная гидратация и диагностика расхождений.
Серверный HTML ускоряет первый экран только тогда, когда браузер воспроизводит ту же модель и корректно продолжает взаимодействие.
Вы выберете способ отрисовки, устраните расхождения гидратации и спроектируете границы потоковой передачи без ложных гарантий.
Понадобятся этапы отрисовки и фиксации, Suspense, границы браузера и сервера и маршрутизация. Итог урока — SSR-страница каталога с потоковой передачей вторичного содержимого, корректными идентификаторами и диагностикой гидратации.
CSR подходит закрытым инструментам после загрузки каркаса, SSR — первому HTML, зависящему от запроса, SSG — содержимому, известному при сборке. Подходы можно смешивать по маршрутам. Учитывайте TTFB, LCP, кеширование, персонализацию, размещение и эксплуатационную стоимость; SSR не гарантирует меньше JavaScript.
Решение принимается по маршрутам, а не по приложению целиком, и записывается вместе с причиной:
| Маршрут | Способ | Причина |
|---|---|---|
/ (лендинг) | SSG | содержимое известно при сборке, нужен дешёвый кеш на CDN |
/catalog | SSR | зависит от запроса и региона, важен LCP и индексация |
/admin/* | CSR | закрытая панель за входом, индексация не нужна |
/docs/* | SSG + инкрементальное обновление | редкие правки, большой объём страниц |
Утверждение «SSR — это быстро» неполно: HTML появится раньше, но объём JavaScript останется тем же, а TTFB вырастет, потому что теперь ответ ждёт данные. Включать SSR для всего без измерения — значит платить за сервер там, где хватало статики.
Проверьте себя. Запишите способ отрисовки для каждого маршрута.
Частая ошибка. Включить SSR для всего без измерения.
Серверный результат и первая клиентская отрисовка должны совпасть. Не читайте Date.now, случайное значение, window, localStorage или ширину окна во время отрисовки без согласованного снимка. Передайте данные запроса, а браузерное улучшение выполните эффектом после гидратации либо покажите стабильную заглушку.
Любое чтение окружения во время отрисовки даёт два разных результата на сервере и в браузере:
// ❌ сервер отрисует одну строку, браузер — другую: расхождение гидратации
function Header() {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640;
return <nav>{isMobile ? <BurgerMenu /> : <FullMenu />}</nav>;
}Значение, зависящее от запроса, передают сверху; браузерное уточнение делают после гидратации:
// ✅ первая отрисовка детерминирована, улучшение — после гидратации
function Header({ renderedAt }: { renderedAt: string }) {
const [isNarrow, setIsNarrow] = useState(false); // одинаково на сервере и клиенте
useEffect(() => {
const media = window.matchMedia('(max-width: 639px)');
setIsNarrow(media.matches); // выполняется только в браузере, после совпадения
}, []);
return <nav>{isNarrow ? <BurgerMenu /> : <FullMenu />}<time>{renderedAt}</time></nav>;
}Метка времени приходит свойством, а не из Date.now() во время отрисовки: иначе сервер и браузер разойдутся на те миллисекунды, что прошли между ответом и гидратацией.
Проверьте себя. Передайте метку времени через свойство.
Частая ошибка. Ветвиться по typeof window в JSX первой отрисовки.
hydrateRoot и владение DOMhydrateRoot прикрепляет React к серверному HTML и ожидает совпадение. У корня один владелец; ранние изменения DOM расширениями браузера или встроенными скриптами могут помешать. Восстанавливаемые ошибки отправляют в телеметрию с безопасным контекстом. root.render до завершения гидратации может удалить серверное содержимое.
Восстанавливаемая ошибка не ломает приложение, но именно она сообщает о расхождении — её нельзя терять:
// src/entry.client.tsx
hydrateRoot(document.getElementById('root')!, <App />, {
onRecoverableError(error, errorInfo) {
// расхождение гидратации доходит сюда; в рабочей сборке предупреждения не видны
reportError({
kind: 'hydration',
message: error instanceof Error ? error.message : String(error),
componentStack: errorInfo.componentStack,
route: window.location.pathname,
});
},
});React в этом случае восстанавливается, переотрисовывая проблемное поддерево на клиенте. Пользователь чаще всего ничего не заметит, но вы потеряете преимущество серверного HTML именно там, где расхождение произошло, — поэтому такие события нужно считать и разбирать, а не подавлять.
Проверьте себя. Подключите onRecoverableError.
Частая ошибка. Игнорировать предупреждения гидратации в рабочей среде.
useId для связей доступностиuseId создаёт стабильные идентификаторы, согласованные между SSR и гидратацией, для aria-labelledby и aria-describedby. Не используйте их как ключи списка или идентификаторы базы данных. Нескольким корням задают identifierPrefix, чтобы избежать совпадений.
Идентификатор нужен для связи элементов, и он обязан совпасть на сервере и в браузере:
// src/features/tickets/TitleField.tsx
function TitleField({ error }: { error?: string }) {
const id = useId(); // одинаковый на сервере и после гидратации
const errorId = `${id}-error`;
return (
<>
<label htmlFor={id}>Заголовок</label>
<input id={id} aria-invalid={!!error} aria-describedby={error ? errorId : undefined} />
{error && <p id={errorId}>{error}</p>}
</>
);
}Math.random() на этом месте даёт разные значения на сервере и клиенте — связь aria-describedby порвётся, и программа чтения с экрана не прочитает ошибку. Обратное тоже верно: useId не годится в качестве ключа списка, потому что не связан с данными и меняется при изменении структуры дерева.
Проверьте себя. Свяжите поле и ошибку в нескольких экземплярах формы.
Частая ошибка. Идентификатор через Math.random() во время отрисовки.
Серверный поток отправляет каркас (shell) в onShellReady, затем вставляет содержимое Suspense. Ошибка до каркаса может изменить статус HTTP; после начала потока статус уже отправлен, поэтому сбой локализуется в интерфейсе и телеметрии. Для поискового робота или статической генерации можно ждать allReady.
Разница между двумя обратными вызовами — это разница между «ещё можно вернуть 500» и «уже нельзя»:
// src/entry.server.tsx
const stream = renderToPipeableStream(<App url={request.url} />, {
onShellReady() {
// каркас готов: статус ещё не отправлен, здесь можно отдать 200 и начать поток
response.statusCode = didError ? 500 : 200;
response.setHeader('Content-Type', 'text/html');
stream.pipe(response);
},
onShellError() {
// сбой до каркаса: страница не начата, отдаём честную ошибку
response.statusCode = 500;
response.end('<h1>Ошибка сервера</h1>');
},
onError(error) {
didError = true;
reportError(error); // сбой внутри границы Suspense: статус уже ушёл
},
});Отсюда правило: решение об авторизации и перенаправлении принимают до начала потока. Если начать поток и затем понять, что нужен редирект на страницу входа, HTTP-статус изменить уже нельзя — останется только клиентская навигация, то есть лишний кадр с чужой разметкой.
Проверьте себя. Разделите критичные данные товара и рекомендации.
Частая ошибка. Начать поток до определения статуса или перенаправления авторизации.
Потоковая передача с Suspense позволяет React гидрировать границы по готовности и пользовательскому взаимодействию (selective hydration). Щелчок по ещё не гидратированной области может повысить её приоритет. Не делайте одну огромную границу и не рассчитывайте, что гидратация исправит долгие задачи сторонних скриптов.
Границы стоит расставлять по пользовательским сценариям — тогда самое нужное становится интерактивным первым:
// app/product/[id]/page.tsx
<article>
<Suspense fallback={<BuyBoxSkeleton />}>
<BuyBox productId={id} /> {/* главный сценарий: гидратируется первым */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} /> {/* второстепенное: может подождать */}
</Suspense>
</article>Одна граница на всю страницу лишает React выбора: гидратация станет одной длинной задачей, и клик по кнопке покупки не сможет получить приоритет. При этом гидратация не отменяет чужие длинные задачи: сторонний тег аналитики, занявший главный поток на 300 мс, задержит интерактивность независимо от того, как расставлены границы.
Проверьте себя. Разделите интерактивные области по пользовательским сценариям.
Частая ошибка. Одна граница на всю тяжёлую панель.
Серверный процесс SSR обслуживает много запросов. Изменяемый кеш модуля или переменная пользователя может утечь между запросами. Данные, авторизация и кеши должны быть ограничены запросом средствами фреймворка или контекста; общий кеш допустим только для безопасных публичных данных с правильным ключом. Отмена запроса прекращает лишнюю работу.
Переменная модуля живёт столько же, сколько процесс, и видна всем запросам одновременно:
// ❌ src/server/session.ts — данные одного пользователя видны другому
let currentUser: User | null = null;
export function setCurrentUser(user: User) { currentUser = user; }
export function getCurrentUser() { return currentUser; } // чей это пользователь?Под нагрузкой два параллельных запроса перепишут переменную друг другу, и страница одного пользователя отрисуется с данными другого. Область запроса задают средствами платформы:
// ✅ src/server/session.ts — контекст живёт ровно один запрос
const requestContext = new AsyncLocalStorage<{ user: User }>();
export function runWithUser<T>(user: User, fn: () => T): T {
return requestContext.run({ user }, fn);
}
export function getCurrentUser(): User {
const store = requestContext.getStore();
if (!store) throw new Error('Вне области запроса');
return store.user;
}То же правило касается кешей: общий кеш допустим только для публичных данных и только с ключом, включающим все входы. Приватные данные в общем кеше — это утечка между пользователями, а не оптимизация.
Проверьте себя. Проверьте параллельные запросы разных пользователей.
Частая ошибка. Переменная модуля currentUser.
Классифицируйте расхождение: неверная вложенность HTML и исправление браузером, недетерминированные данные, ветвление по окружению, внешнее изменение или порядок CSS-in-JS. Сохраните серверный HTML и входы первой клиентской отрисовки. Исправляйте источник, а подавление применяйте лишь для неизбежного одиночного текста вроде метки времени.
Неверная вложенность — самая незаметная причина: браузер молча перестраивает дерево, и React видит уже не тот HTML, который отправил сервер.
// ❌ браузер вынесет <div> из <p> — структура в DOM не совпадёт с серверной
<p>Описание: <div>{text}</div></p>
// ✅ допустимая вложенность
<div>Описание: <span>{text}</span></div>Для единственного неизбежного случая — текста, который принципиально различается, — есть точечное подавление:
// ✅ подавляем только этот текстовый узел, а не предупреждения целиком
<time suppressHydrationWarning>{new Date().toLocaleTimeString('ru-RU')}</time>suppressHydrationWarning действует на один элемент и не исправляет расхождение — он лишь говорит «здесь так и задумано». Глобальное подавление предупреждений убирает единственный сигнал о том, что серверный HTML перестал использоваться.
Проверьте себя. Создайте список проверок и регрессионный тест сообщений консоли.
Частая ошибка. Глобально подавить warnings.
Исправьте приложение, где отрисовка читает дату и window, идентификаторы случайны, каркас авторизации различается, а большая граница задерживает весь ответ. Ограничьте данные запросом, сделайте начальное дерево детерминированным, примените useId и разделите поток.
Работа готова, когда серверная и клиентская первые отрисовки совпадают, консоль чиста от расхождений, взаимодействие восстанавливается, а HTML без JavaScript содержит критичное содержимое.
Далее: Серверные компоненты и серверные функции