SSR и гидратация: один HTML, два выполнения React
Открываешь публичную страницу канала Echo. Название и описание уже видны,
но кнопка со счётчиком ещё не реагирует: JavaScript не успел загрузиться.
В Network у документа есть готовый HTML, хотя App написан на React.
Как превратить эту разметку в работающее приложение, не стирая её?
В 07-frontend/15 · Стратегии рендера мы выбирали между CSR, SSR и SSG. Здесь разберём один механизм: сервер строит HTML, а браузер восстанавливает React поверх него. Главный договор: серверный HTML и первый клиентский рендер должны описывать один интерфейс по одному снимку данных.
1. Что пересекает сеть
Представь, что сервер прислал фотографию пульта. Кнопки на фотографии видны, но нажатия не меняют состояние. Для React-обработчиков нужна клиентская часть приложения. HTML при этом не обязан быть бесполезным без JS: обычные ссылки, нативные формы и встроенная проверка полей работают средствами браузера.
При SSR с последующей гидратацией происходит следующее:
- HTTP-сервер выбирает маршрут и получает данные для этого запроса.
- Серверный renderer выполняет компоненты и превращает их результат в HTML.
У него нет браузерного DOM, а
useEffectиuseLayoutEffectне выполняются. - Браузер получает HTML, разбирает его в DOM и может показать контент до загрузки React. Стили и шрифты тоже влияют на момент показа.
- Клиентский JS вызывает
hydrateRoot. React снова выполняет компоненты, восстанавливает своё дерево и состояние хуков, связывает его с существующим DOM и подключает обработку событий. - После гидратации обновления состояния работают как в обычном React-приложении.
Сеть не переносит серверный Fiber, замыкания обработчиков или содержимое
memoizedState. Она переносит HTML, код для браузера и нужные начальные
данные. useState на клиенте инициализируется заново. Поэтому важен не
одинаковый исходный файл сам по себе, а одинаковый результат первого рендера.
SSR отвечает за получение HTML. Гидратация нужна, только если этот HTML должен дальше управляться клиентским React. Можно отдать серверную страницу без React в браузере вообще.
2. Минимальный путь: App, сервер, браузер
Начнём с синхронного renderToString, чтобы видеть весь договор. Это
renderer и две точки входа, не настройка сборщика: браузерная сборка должна
собрать client.tsx в /client.js, а HTTP-адаптер вызвать handle с Request
и отправить полученный Response. Код серверного entry не должен попадать
в клиентский bundle. В учебном проекте эти обязанности можно поручить Vite SSR;
в прикладном проекте их обычно выполняет фреймворк.
// App.tsx: общий код для сервера и браузера
import { useId, useState } from 'react';
export type InitialData = {
channel: string;
loadedAt: string;
initialCount: number;
};
export function App({ data }: { data: InitialData }) {
const [count, setCount] = useState(data.initialCount);
const emailId = useId();
return (
<main>
<h1>Канал {data.channel}</h1>
<p>Снимок: <time dateTime={data.loadedAt}>{data.loadedAt}</time></p>
<label htmlFor={emailId}>Email для приглашения</label>
<input id={emailId} name="email" type="email" />
<button type="button" onClick={() => setCount((value) => value + 1)}>
Нажатий: {count}
</button>
</main>
);
}
Сервер создаёт снимок один раз на запрос. Время берётся здесь, а не заново
внутри App. Название канала из URL используется как текст, не как HTML:
// server.tsx: только сервер
import { renderToString } from 'react-dom/server';
import { App, type InitialData } from './App';
export function handle(request: Request): Response {
const data: InitialData = {
channel: new URL(request.url).searchParams.get('channel') ?? 'general',
loadedAt: new Date().toISOString(),
initialCount: 0,
};
const html = renderToString(<App data={data} />, {
identifierPrefix: 'echo-',
});
const payload = JSON.stringify(data).replaceAll('<', '\\u003c');
return new Response(`<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>Echo</title></head>
<body>
<div id="root">${html}</div>
<script id="initial-data" type="application/json">${payload}</script>
<script type="module" src="/client.js"></script>
</body>
</html>`, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'private, no-store',
},
});
}
В браузере читаем этот же снимок. Не делаем новый запрос за начальными
данными до гидратации и не пересчитываем loadedAt:
// client.tsx: только браузер
import { hydrateRoot } from 'react-dom/client';
import { App, type InitialData } from './App';
const container = document.getElementById('root')!;
const snapshot = document.getElementById('initial-data')!;
const data: InitialData = JSON.parse(snapshot.textContent!);
hydrateRoot(container, <App data={data} />, {
identifierPrefix: 'echo-',
onRecoverableError(error, info) {
console.error('Ошибка гидратации', error, info.componentStack);
},
});
createRoot здесь не замена hydrateRoot: он предназначен для клиентского
монтирования, а не присоединения к серверной разметке. Не вызывай сразу
root.render, чтобы «закрепить» результат: обновление до завершения
гидратации может переключить корень на клиентский рендер и очистить HTML.
identifierPrefix одинаков на обеих сторонах. Он нужен useId, а не
сериализации useState. Нескольким независимым корням нужны разные префиксы;
внутри одного корня сервер и браузер используют один. Механику пути дерева,
mountId и updateId смотри в
07-frontend/06 · useId.
3. JSON в HTML: данные тоже часть границы безопасности
React экранирует текст в JSX. Но строка ${payload} вставляется в HTML
вручную, вне этой защиты. Одного JSON.stringify здесь мало: HTML-парсер
распознаёт </script> даже внутри JSON-строки и даже у
<script type="application/json">. Пользовательское имя канала могло бы
закрыть элемент и добавить чужую разметку.
Замена < на последовательность \u003c убирает возможность закрыть script,
а JSON.parse восстанавливает исходную строку. Это защита именно для JSON
в таком HTML-элементе, не универсальное экранирование для любого контекста.
Не заменяй её ручной сборкой JavaScript вроде window.data = '${value}'.
В фреймворке используй его механизм передачи данных.
В снимок кладут только то, что разрешено прочитать посетителю. Секрет БД,
ключ API и серверная сессия не становятся закрытыми оттого, что лежат в
application/json. Они видны в исходнике ответа. Аннотация InitialData
помогает компилятору, но не проверяет JSON внешнего API: такой ответ нужно
проверять на границе получения данных.
Данные запроса держи внутри обработчика. Общий изменяемый currentUser или
Redux store на уровне модуля может перемешать параллельные запросы двух
пользователей. HTML с персональными данными нельзя отдавать из общего
CDN-кеша. В примере стоит private, no-store; публичный кеш требует отдельной
политики, которая учитывает маршрут, авторизацию и состав данных.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.