Гидратация: совпасть с HTML сервера и заплатить за отгруженный JS
Гидратация навешивает слушатели на серверный HTML — разметка обязана совпасть с клиентским рендером, иначе React дорого перерендеривает с нуля. Date.now, локаль, browser-only ветки — классика расхождений; Suspense даёт выборочную гидратацию; меньше JS — лучше INP/TBT.
Баг-репорт гласил: «Метки времени заказов мигают при загрузке, а иногда перерисовывается вся страница». Локально — ничего. В продакшене — Sentry, полный ошибок «Hydration failed because the server rendered HTML didn’t match the client». Виноватая строка выглядела невинно: <span>{formatRelative(order.createdAt, Date.now())}</span>. Сервер отрендерил «2 минуты назад» в 14:03:07; к моменту, когда клиент на среднем телефоне через 4G догидратировался, было 14:03:11, и клиент вычислил «3 минуты назад». Два честных рендера — два разных ответа. React 18 такое не замазывает: при расхождении он выбрасывает серверный DOM этой границы и перерендеривает её целиком на клиенте — пользователь видит перерисовку, телефон платит за рендер страницы дважды, а аналитики спрашивают, почему на странице заказов просел INP. Второй репорт пришёл из Франкфурта: цены расходятся, потому что сервер с немецкой ICU-локалью отформатировал 1.234,56 €, а браузер пользователя — €1,234.56. Тот же класс багов: любой вход, различающийся между серверным рендером и первым клиентским, — это расхождение гидратации, которое ждёт своего трафика.
После этого урока ошибки hydration в продакшене перестанут выглядеть как магия: вы будете знать три входа, которые их вызывают, как починить каждый и как измерить реальную JS-стоимость, которую вы платите.
Что гидратация делает на самом деле
Серверный HTML — это краска, а не поведение: пользователь видит готовую страницу, но ни один слушатель не навешен, состояния не существует, ref пустые. Гидратация — то, как React оживляет страницу: hydrateRoot(domNode, reactNode) рендерит дерево компонентов на клиенте ещё раз — но вместо создания DOM он обходит существующий серверный DOM, усыновляет каждый узел в своё fiber-дерево, навешивает обработчики событий и инициализирует состояние и эффекты. Это рендер, чей результат — реконсиляция с уже существующей разметкой.
У этой конструкции одно жёсткое предусловие, прямо сформулированное в доках React: дерево, переданное в hydrateRoot, обязано произвести тот же результат, что произвёл сервер. React не сверяет-и-патчит различия во время гидратации; совпадение — допущение, зашитое в алгоритм, а не предоставляемая фича. Текстовые различия в development патчатся с предупреждением, но структурные и атрибутные расхождения в React 18 запускают восстановление: React бросает серверный HTML затронутой границы и выполняет полный клиентский рендер ей на замену. Видимая цена — вспышка заменённого контента; цена по производительности — пользователь на среднем устройстве только что отрендерил страницу дважды: один раз в HTML, с которым нельзя взаимодействовать, второй — в JS, заблокировавшем главный поток.
// Три классические фабрики расхождений — везде «два честных рендера, два ответа»:
// 1. Время: сервер рендерит в T0, клиент гидратируется в T0 + сеть + парсинг + исполнение.
<span>{formatRelative(order.createdAt, Date.now())}</span>
// 2. Локаль/окружение: ICU Node против настроек браузера пользователя.
<td>{price.toLocaleString()}</td>
// 3. Browser-only ветки: во время серверного рендера window не существует.
<div>{typeof window !== "undefined" ? <LocalStorageGreeting /> : <DefaultGreeting />}</div>
// Форма лекарства всегда одна: первый клиентский рендер равен серверному.
function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []); // эффекты выполняются только после гидратации
return mounted ? <>{children}</> : null; // и сервер, и первый клиентский рендер: null
}Паттерн лечения стоит подчеркнуть, потому что он выглядит парадоксально: чтобы отрендерить браузеро-зависимый контент, вы рендерите ничего (или плейсхолдер) и на сервере, и в первом клиентском проходе, а переключаетесь после срабатывания useEffect — эффекты выполняются только после гидратации, так что переключение становится обычным обновлением, а не расхождением. Для единичного неустранимого различия (метка времени, которую сервер обязан отрендерить) есть suppressHydrationWarning на этом элементе — он велит React не трогать одно текстовое различие: скальпель, а не политика. Случайные ID правильно решаются через useId, который стабилен при гидратации по построению.
React 18: компонент рендерит `Math.random()` в DOM. Серверный вывод: 0.42. Гидратация на клиенте вычисляет 0.87. Что делает React?
Выборочная гидратация: Suspense как граница гидратации
До React 18 гидратация была монолитной: один синхронный проход по всему дереву, и ничто не интерактивно, пока не готово всё. Страница не могла даже начать гидратироваться, пока не приехал весь её JS, а тяжёлый компонент высоко в дереве держал в заложниках всех соседей. Выборочная гидратация React 18 ломает монолит через <Suspense>: каждая граница Suspense становится независимо гидратируемым островом. Гидратация идёт граница за границей, кусками, уступающими главный поток — и она управляется приоритетом: если пользователь кликает внутри ещё-не-гидратированной границы, React записывает событие и синхронно гидратирует эту границу первой, вне очереди, а затем воспроизводит взаимодействие. Тяжёлый виджет комментариев внизу больше не задерживает кнопку покупки наверху.
Это сочетается со стриминговым SSR: серверный компонент, который суспендится (медленные данные), достримливает свой HTML позже и гидратируется по прибытии, пока оболочка уже интерактивна. Ментальная модель сдвигается от «страница гидратируется» к «границы гидратируются — в порядке, нужном пользователю».
export default function ProductPage() {
return (
<main>
<BuyBox /> {/* критично: гидратируется первым */}
<Suspense fallback={<Skeleton />}>
<Reviews /> {/* тяжёлый: стримится + гидратируется позже */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<Recommendations /> {/* пользователь кликнул сюда? -> вне очереди */}
</Suspense>
</main>
);
}Второе свойство важно операционно: в React 18 расхождение гидратации внутри границы Suspense восстанавливается только в этой границе — клиентский перерендер ограничен островом и не каскадирует до корня. Границы — это стены, ограничивающие радиус поражения при восстановлении, а не только loading-состояния.
Острова, INP и JS, который вы не отгрузили
Прежде чем оптимизировать скорость гидратации, задайте более полезный вопрос: а сколько из того, что вы гидратируете, вообще должно работать в браузере? В большинстве приложений — намного меньше, чем сейчас отгружается.
Стоимость гидратации примерно пропорциональна отгруженному JS: каждый отгруженный компонент нужно скачать, распарсить, исполнить и реконсилировать с DOM, прежде чем он станет по-настоящему интерактивным. Эта работа ложится на главный поток и проявляется в двух полевых метриках. TBT (Total Blocking Time, лабораторная) накапливает каждый кусок long task свыше 50 мс во время загрузки — монолитная гидратация большого дерева и есть такая задача: нередко 1–3 секунды блокировки на среднем Android за несколько сотен KB компонентов. INP (Interaction to Next Paint, Core Web Vital) измеряет задержку реальных взаимодействий; порог «хорошо» — 200 мс, и тап, попавший в середину гидратации, ждёт главный поток, прежде чем хоть что-то отрисуется. Страница выглядит готовой — HTML отрисован секунды назад — но тычется как мёртвая: зазор между «отрисовано» и «интерактивно» и есть окно гидратации, и пользователи проводят его в rage-кликах.
Архитектурный ответ экосистемы — острова интерактивности: рендерить на сервере всё, гидратировать только компоненты, которым JS действительно нужен. Next.js выражает острова через границу RSC — серверные компоненты не шлют JS и не гидратируются вовсе; гидратируются только поддеревья за "use client". Astro выражает то же по-островными директивами; Qwik идёт дальше и возобновляет сериализованное состояние вместо повторного рендера. Инженерный рычаг везде один, и он у вас в руках: самая быстрая гидратация — та, которую вы не отгрузили. Перенос форматтера, статического графика или markdown-тела из клиентского компонента в серверный не ускоряет гидратацию — он заставляет гидратацию этого поддерева перестать существовать, и TBT/INP улучшаются прямо пропорционально.
▸Почему это работает
Почему стратегия React при расхождении — восстановление (перерендер с нуля), а не сверка реального DOM с ожидаемым деревом и точечный патч? Потому что контракт производительности гидратации построен на том, чтобы не делать эту работу. Усыновление узлов по позиции в предположении равенства — один дешёвый проход; проверка каждого атрибута и текстового узла против виртуального дерева заставила бы каждую гидратацию платить цену редкой сломанной. React выбирает: доверять, а при нарушении доверия — перестроить границу, делая корректные страницы быстрыми, а некорректные — заметно сломанными (предупреждение + перерисовка), чтобы баг чинили у источника. Та же инженерная позиция, что у оптимистичной конкуренции: не платить за обнаружение конфликтов на каждой операции; платить только — и громко — когда конфликт действительно случился.
INP товарной страницы — 450 мс на средних телефонах; трейсы показывают тапы, попадающие в гидратацию большого дерева клиентских компонентов. Какое изменение бьёт в корень проблемы?
- 01Что вызывает расхождения гидратации, что делает React 18 при расхождении и каков канонический паттерн лечения?
- 02Как работает выборочная гидратация и почему отгружать меньше JS важнее для INP/TBT, чем оптимизировать саму гидратацию?
Серверный HTML — краска без поведения; hydrateRoot оживляет его, рендеря дерево компонентов на клиенте и усыновляя существующий DOM — навешивая слушатели, инициализируя состояние — при одном жёстком предусловии: клиентский вывод обязан равняться серверному. React не сверяет-и-патчит при гидратации; равенство — допущение алгоритма. Фабрики расхождений — входы, честно различающиеся между двумя рендерами: Date.now (клиент гидратируется через секунды после серверного рендера), локаль и различия ICU между Node и браузером, browser-only ветки по typeof window, случайность. В React 18 расхождение запускает восстановление: серверный HTML затронутой границы выбрасывается и перерендеривается на клиенте целиком — видимая вспышка и двойной рендер на устройстве пользователя, поэтому расхождения — баги производительности, а не косметические предупреждения. Канонический фикс рендерит идентичный вывод на сервере и в первом клиентском проходе (null или плейсхолдер), а переключается внутри useEffect — эффекты выполняются после гидратации, и смена становится обычным обновлением; suppressHydrationWarning — скальпель для единичного неустранимого различия, useId генерирует стабильные при гидратации ID. Suspense (граница ленивой загрузки и гидратации) превращает монолитную гидратацию в выборочную: каждая граница гидратируется независимо, кусками уступая главный поток; достримленный HTML гидратируется по прибытии; клик внутри негидратированной границы проходит вне очереди и воспроизводится; восстановление при расхождении ограничено границей. Модель стоимости: работа гидратации пропорциональна отгруженному JS и ложится на главный поток — TBT (Total Blocking Time, суммарное блокирующее время) при загрузке и деградировавший INP (Interaction to Next Paint — время до следующей отрисовки, порог «хорошо» — 200 мс) для ранних тапов: окно «отрисовано, но мертво», которое пользователи прокликивают в ярости. Мышление островами закрывает его: серверные компоненты не гидратируются, поэтому вынос неинтерактивных поддеревьев из клиентских компонентов не ускоряет гидратацию — он её удаляет. Теперь, увидев регрессию INP или ошибки hydration в Sentry, вы будете знать, к чему тянуться: useEffect, suppressHydrationWarning, серверный компонент или граница <Suspense> — каждый инструмент лечит свою корневую причину.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.