Серверный компонент, SSR и гидратация — три разные вещи
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
«Серверный компонент», «серверный рендер» и «гидратация» звучат как три названия одного и того же — и именно поэтому вокруг них столько путаницы. Это ответы на три независимых вопроса: где выполняется код, что приезжает в первом ответе сервера и что оживает в браузере. Разведите их — и половина странностей App Router перестанет быть странностями.
В части II вы писали перерисовку из состояния своими руками, и вопрос был ровно один: когда перерисовать. Код жил в браузере, HTML был одним и тем же пустым каркасом, третьей сущности не существовало вовсе. App Router разбивает эту простоту на три оси, и дальше их придётся различать.
| Вопрос | Возможные ответы | Чем задаётся |
|---|---|---|
| Где выполняется код компонента? | только на сервере / и на сервере, и в браузере | серверный он или клиентский — то есть директива "use client" |
| Что приезжает в первом ответе сервера? | готовая разметка / пустой <div id="root"> и тег скрипта | серверный рендер (SSR) — свойство сборки, а не отдельного компонента |
| Что оживает в браузере? | клиентские компоненты / ничего, пока не загрузился бандл | гидратация: React обходит готовые узлы и вешает на них обработчики |
Оси независимы, и ответ на одну ничего не говорит про две другие. Клиентский компонент выполняется на сервере. Серверный компонент не гидратируется. Страница может приехать готовой разметкой, и при этом ни одного серверного компонента в ней не будет — так работал React-рендер на сервере задолго до App Router.
Слово «серверный» в первых двух вопросах значит разное: в первом — «код не уехал в браузер», во втором — «HTML посчитан до отправки». Один и тот же корень для двух разных вещей — половина всей путаницы отсюда.
Серверный рендер ничего не знает про React и гораздо старше его: сервер отдаёт готовую разметку, браузер её показывает — так работали PHP и Rails. Противоположность — пустой каркас плюс бандл: разметку считает браузер. Именно так устроен каждый пример этой части в песочнице и любой проект на Vite: createRoot получает пустой div и наполняет его.
| Рендер в браузере | Серверный рендер | |
|---|---|---|
| первый ответ сервера | пустой div и тег скрипта | готовая разметка страницы |
| когда виден текст | после загрузки и выполнения бандла | сразу, до всякого JavaScript |
| что видит поисковик или превью в мессенджере | пустой каркас, если он не выполняет скрипты | страницу |
| что делает React в браузере | createRoot — рисует дерево с нуля | hydrateRoot — присоединяется к готовому |
Проверяется это без инструментов: «Просмотр кода страницы» (Ctrl+U) показывает ровно тот HTML, который приехал с сервера, а панель «Элементы» из части II — уже живое дерево, изменённое скриптами. Два разных источника правды, и путать их дорого: страница с рендером в браузере в «Элементах» выглядит точно так же, как страница с SSR.
SSR не отменяет JavaScript. Бандл всё равно загрузится и выполнится — иначе не будет ни состояния, ни обработчиков. HTML — это первый кадр; код нужен для второго и всех остальных.
Проверка на две минуты: отключите в браузере JavaScript и перезагрузите страницу. Что осталось видно — то приехало готовым. Что исчезло — то рисовал бандл.
Гидратация — операция «оживить готовое»: React обходит уже существующие узлы, строит по ним своё внутреннее дерево и вешает обработчики, вместо того чтобы создавать узлы заново. Название неудачное, но прижилось; смысл в нём один — присоединиться, а не нарисовать.
Зачем понадобилась отдельная операция, лучше всего видно на её отсутствии. Положим в страницу «серверную» разметку руками и попробуем нажать кнопку до того, как React о ней узнает.
Первый клик не сделал ничего: обработчика в разметке нет и быть не может — onClick не превращается в атрибут HTML, это функция, а функции по проводу не ездят. Дальше интереснее: alive === fromServer даёт false. createRoot не стал разбираться в чужой разметке — он стёр её и нарисовал свою.
В настоящем приложении это провал: сервер посчитал HTML, браузер его показал, а React тут же выбросил и нарисовал заново — мигание, сброшенный фокус, потерянная позиция прокрутки и двойная работа. Поэтому на странице с SSR вызывают не createRoot, а hydrateRoot: он идёт по существующим узлам и ничего не создаёт. Условие у сделки одно — разметка должна совпасть с тем, что React насчитает сам.
Разницу между этими двумя способами видно прямо здесь. Гидратации нужна не разметка, приехавшая по сети, а разметка, уже лежащая в контейнере — а положить её пример может и сам. Два одинаковых контейнера, два способа, один компонент:
createRoot отвечает false: он выбросил готовую кнопку и создал новую. hydrateRoot отвечает true — узел тот самый, что был в разметке, и клик после гидратации работает. Именно ради этого true серверный рендер и затевается: если бы React перерисовывал всё заново, готовый HTML не давал бы ничего, кроме картинки на долю секунды.
А совпасть она обязана потому, что рендеров два и оба — вызовы одной и той же функции: первый на сервере, второй в браузере. Если функция не чистая, два вызова дадут разное.
Компонент между вызовами не менялся, а разметка разошлась. Ровно это и происходит при ошибке гидратации: React находит в DOM не то, что насчитал, пишет в консоль сообщение о несовпадении и перерисовывает поддерево на клиенте. Страница не ломается — но пользу от готового HTML на этом куске вы потеряли.
Список короткий и повторяется из проекта в проект: Math.random() и случайные идентификаторы; new Date() и форматирование времени (у сервера свой часовой пояс, у читателя свой); чтение window, localStorage или куки прямо в теле компонента; ветвление по ширине экрана.
Лечение всегда одно из двух. Если значение можно посчитать один раз — считайте его на сервере и передавайте пропсом. Если оно принципиально браузерное, покажите на первом рендере ту же заглушку, что и сервер, а настоящее значение подставьте эффектом: это тот самый случай, ради которого эффект и существует. Для стабильных идентификаторов есть useId, а для неизбежных расхождений вроде отметки времени — атрибут suppressHydrationWarning на одном узле.
Серверный компонент отвечает на первый вопрос — где выполняется код. Он не отменяет ни серверный рендер, ни гидратацию: страница на App Router делает все три вещи сразу, просто с разными компонентами по-разному.
| Серверный компонент | Клиентский компонент | |
|---|---|---|
| код в бандле браузера | нет | да |
| где выполняется тело | на сервере, один раз | на сервере (первый рендер) и в браузере (все остальные) |
useState, onClick | нельзя | можно |
| попадает в первый HTML | да | да |
| гидратируется | нет — оживлять нечего | да |
Четвёртая и пятая строки — самое важное в таблице. Серверный компонент попадает в HTML: его разметка приезжает вместе со всеми остальными. Но оживлять его не нужно и нечем — кода в браузере нет, состояния нет, обработчиков нет. Гидратация трогает только клиентские острова, и в этом вся экономия: чем меньше островов, тем меньше кода браузер грузит и выполняет.
В браузер от серверного компонента приезжает не код, а результат — описание готового дерева. Оно нужно React отдельно от HTML: по HTML нельзя понять, где кончается серверная разметка и начинается клиентский остров, а при переходе по Link новую страницу надо вставить в уже живое дерево, а не перезагружать целиком.
Описание можно увидеть глазами. В исходнике страницы на App Router рядом с разметкой лежат длинные строки в вызовах self.__next_f.push — это и есть дерево. Строки вида 4:I[98521,["/_next/static/chunks/…"],"ThemeToggle"] — дырки под клиентские компоненты: номер модуля, файлы, которые придётся загрузить, и имя экспорта. Всё остальное дерево приехало уже посчитанным.
Проверьте прямо здесь: Ctrl+U на этой странице и поиск по __next_f. Учебник выложен статикой, HTML посчитан заранее, а не на каждый запрос, — на форму описания это не влияет.
Читатель открыл страницу приложения на App Router. Вот что происходит и какая из трёх вещей за что отвечает.
| Шаг | Что происходит | Чья это работа |
|---|---|---|
| 1 | сервер выполняет серверные компоненты: они ходят в базу, ждут ответы, возвращают разметку | серверные компоненты |
| 2 | получается дерево, где на месте клиентских компонентов стоят ссылки: «здесь будет вот этот модуль с такими пропсами» | серверные компоненты |
| 3 | по этому дереву React строит HTML — и выполняет тела клиентских компонентов, чтобы вместо ссылок оказалась разметка | серверный рендер |
| 4 | в ответ уезжают HTML и описание дерева; читатель видит страницу, а JavaScript ещё не выполнялся | серверный рендер |
| 5 | браузер загружает бандл — в нём код только клиентских компонентов | граница "use client" |
| 6 | React проходит по готовым узлам и присоединяется к ним: появляются состояние, обработчики, эффекты | гидратация |
| 7 | дальше всё как в обычном React: клик, состояние, рендер в браузере | клиентские компоненты |
Между четвёртым шагом и шестым есть окно, в котором страница выглядит рабочей, но не отвечает: разметка на экране, обработчиков ещё нет. Это не баг, а физика — и ровно то, что напечатал первый пример строкой «клик до оживления». Окно тем шире, чем больше кода в клиентском бандле: вот практическая причина держать границу "use client" в листьях, а не поднимать её к корню.
Одна и та же страница, собранная тремя способами, отвечает на три вопроса по-разному.
| Как собрано | Код компонентов | Первый ответ | Что гидратируется |
|---|---|---|---|
Vite и createRoot | весь уезжает в браузер | пустой каркас | ничего — обычный первый рендер |
React с серверным рендером (Pages Router, свой сервер с renderToString) | весь уезжает в браузер | готовый HTML | вся страница целиком |
| App Router | уезжает только код клиентских компонентов | готовый HTML и описание дерева | только клиентские острова |
Польза от развода трёх понятий не теоретическая: она в том, что по симптому сразу видно, в какую сторону копать.
| Что видите | В какую из трёх вещей смотреть |
|---|---|
| «Просмотр кода страницы» показывает пустой каркас | серверный рендер: разметки в первом ответе нет вовсе |
| текст на месте, кнопка секунду не реагирует | гидратация: бандл ещё не загрузился или не дошёл до этого острова |
| в консоли ошибка о несовпадении разметки | гидратация: два рендера дали разный HTML — ищите нечистую функцию |
window is not defined при сборке | серверный рендер: тело клиентского компонента лезет в браузер там, где браузера нет |
ключ из process.env нашёлся в бандле | граница: файл оказался по клиентскую сторону "use client" |
| текста нет в HTML, хотя данные приходят | серверный рендер: данные запрашивает эффект, а он выполняется только в браузере |
Порядок разбора тот же, что и порядок вопросов: сначала «где выполняется код», потом «что приехало в первом ответе», и только потом «что не ожило». Ошибка почти всегда находится на первом же вопросе, ответ на который вас удивил.
- Три вопроса независимы: где выполняется код (серверный или клиентский компонент), что приехало в первом ответе (серверный рендер) и что ожило в браузере (гидратация).
- Серверный рендер старше React и не про компоненты: это просто готовая разметка в первом ответе вместо пустого каркаса.
- Гидратация — присоединение к готовым узлам, а не рисование заново;
createRootстёр бы разметку,hydrateRootеё сохраняет. - Условие гидратации — совпадение разметки: тело компонента выполняется дважды, и нечистая функция даёт два разных HTML.
- Клиентский компонент попадает в первый HTML так же, как серверный; разница в том, что его код уезжает в браузер и он гидратируется.
- Между приходом HTML и концом гидратации страница видна, но не отвечает: чем ниже граница
"use client", тем это окно короче.
1/4Компонент лежит в файле с "use client" и держит состояние в useState. Сколько раз выполнится его тело, пока читатель открывает страницу и ничего не нажимает?
2/4Гидратируется ли серверный компонент?
3/4В браузере отключили JavaScript и открыли страницу App Router: список постов отдаёт серверный компонент, кнопка «лайк» — клиентский. Что увидит читатель?
4/4В консоли ошибка о несовпадении разметки. Компонент печатает new Date().toLocaleTimeString() прямо в теле. Какая из трёх вещей сломалась?
Три вопроса про два компонента
В проекте два файла. app/post/page.tsx — обычный компонент страницы без директив: он await-ит пост из базы и показывает заголовок. components/like-button.tsx начинается с "use client" и экспортирует кнопку с useState; страница показывает её под заголовком.
Ответьте про каждый из двух компонентов на три вопроса: попадёт ли его код в бандл браузера; будет ли его разметка в первом HTML; будет ли он гидратироваться. Шесть ответов — и, если хотя бы один вызывает сомнение, найдите его в таблицах статьи.
Компонент страницы: кода в бандле нет (он серверный, файл остаётся на сервере); разметка в первом HTML есть; гидратации нет — оживлять нечего.
Кнопка: код в бандле есть (директива увезла файл в браузер); разметка в первом HTML есть — её посчитал серверный рендер, выполнив тело кнопки на сервере; гидратация есть — именно ради неё код и уехал.
Общее место, на котором чаще всего ошибаются, — второй вопрос у кнопки. «Клиентский» описывает, куда уехал код, а не то, чего нет в HTML: разметка клиентского компонента приезжает готовой, просто до конца гидратации она мертва.
Починить разъезжающуюся разметку
Два корня здесь изображают два рендера одного компонента — серверный и браузерный. Сейчас они дают разный HTML, а значит, в настоящем приложении на этом месте была бы ошибка гидратации. Сделайте так, чтобы сравнение печатало true, — и при этом номер должен остаться случайным.
Считать значение внутри тела нельзя: тело выполняется дважды. Значит, номер должен родиться один раз и приехать в компонент готовым — пропсом.
В App Router роль «одного раза снаружи» играет серверный компонент: он считает значение и передаёт его клиентскому пропсом, а тот на обоих рендерах видит одно и то же. Если же значение принципиально браузерное — время читателя, содержимое localStorage, ширина окна, — пропсом его не передать: на первом рендере показывают заглушку, а настоящее значение подставляет эффект.
Что попадёт в HTML
Список статей выводят двумя способами. В первом это клиентский компонент: он держит массив в useState и заполняет его в useEffect. Во втором — серверный компонент: он await-ит те же данные прямо в теле.
Что окажется в первом ответе сервера в каждом случае и что увидит превью ссылки в мессенджере, которое не выполняет скрипты? Ответ на два абзаца, кода писать не нужно.
Первый способ: тело компонента выполнится и на сервере, но эффекты на сервере не запускаются — данных ещё нет, и в HTML уедет пустой список (или заглушка «загружаем»). Превью ссылки увидит именно этот пустой список: скрипты оно не выполняет, а текст появляется только после бандла и ответа сервера.
Второй способ: сервер дождался данных до того, как построил HTML, — в первом ответе уже лежат готовые заголовки. Превью увидит статьи, читатель увидит их до всякого JavaScript, а гидратации на этом куске не будет вовсе: серверному компоненту нечего оживлять.
Обратите внимание, что различает эти два случая: не «сервер против браузера» вообще, а конкретно второй вопрос — что успело попасть в первый ответ.