Одно дерево тремя способами: payload, HTML, гидратация

серверные≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Три вопроса прошлой статьи — где выполнился код, что приехало в первом ответе сервера, что ожило в браузере — можно развести словами, а можно прогнать одно дерево тремя способами и прочитать ответы в консоли. Два обхода вы напишете сами: писатель payload и свой renderToString. Третий сделает настоящий ReactDOM.hydrateRoot — и одна лишняя склейка текста в вашей разметке заставит его выбросить всё, что вы посчитали.

Разделы темы:·

Прошлая статья развела три вопроса и закончилась таблицей симптомов. Слова верные, но проверить их пока нечем: «серверный рендер посчитал HTML», «гидратация присоединилась» — это описание снаружи. Внутри всё гораздо проще, и сейчас вы увидите насколько: одно и то же дерево, три обхода, три разных ответа.

Начинается всё с того, что дерево — это данные. Запись <Post /> ничего не выполняет: JSX превращается в вызов React.createElement, а тот возвращает обычный объект с полями type, props и key. В поле type лежит сама функция компонента, и решает, звать ли её, не JSX, а тот, кто обходит дерево. Вся разница между тремя способами — здесь.

Соберём дерево из двух компонентов: серверный Post и клиентский Likes. Директив в песочнице нет, поэтому «клиентский» — это пометка в наборе clients; сборщик Next заводит такой список сам, по строке "use client" в начале файла. Прогноз перед запуском: напечатается ли хоть одна строка из тел компонентов и что попадёт во вторую строку вывода?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одного «выполнилось тело» — дерево собрано, но не выполнено. А во второй строке главная неожиданность: в JSON от узла осталась пустая скорлупа, поле type исчезло. Причина знакомая: там лежала функция, а функция не записывается — то же самое случалось с обработчиком в пропсах, когда вы разбирали, что проезжает через границу. Поля _owner и _store — служебные, их добавляет отладочная сборка React.

Из этого следует всё остальное. Если функцию по проводу не отправить, значит, писатель payload обязан положить вместо неё что-то другое — например, имя. Если функцию всё-таки надо выполнить, чтобы получилась разметка, значит, выполнять её будет тот, у кого она есть, — сервер. Оба вывода мы сейчас проверим руками.

В теле Likes вызывается не React.useState, а своя функция с тем же именем — и это не подпорка ради примера. Настоящий React устроен так же: на время рендера он подставляет во внутреннее поле текущий набор хуков, о чём вы читали в правилах хуков. Один и тот же вызов в разных проходах попадает к разным исполнителям.

Проходов у нас два, и они честно разные. На строковом проходе состояние менять некому: рендер один, он же последний, — поэтому useState отдаёт начальное значение и пустую функцию. В браузере работу забирает настоящий React. Флаг onServer и есть переключатель между этими двумя исполнителями.

Первый вопрос — где выполняется код компонента. Отвечает на него обход, который умеет ровно две вещи: серверный компонент выполняет на месте и идёт дальше по тому, что тот вернул, а на клиентском останавливается и пишет вместо него дырку. Писателя вы уже собирали, когда проводили свою границу; здесь он нужен целиком, но в самой простой форме — не ряды с приставками, а обычные объекты. Суть обхода от формы записи не меняется, а читать её глазами легче.

Прогноз: какие строки «выполнилось тело…» появятся в выводе и что окажется в payload на месте кнопки?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Из двух тел выполнилось одно. Post отработал — сходил «в базу», вернул разметку; Likes не выполнялся вовсе, и в payload на его месте лежит {"client":"Likes","props":{"start":3}}. Это и есть ответ на первый вопрос, записанный данными: код серверного компонента остался на сервере вместе с результатом, код клиентского не выполнялся здесь и не мог — его работа начнётся в браузере.

Почему в дырке имя, а не сама функция, вы уже видели в первом примере: JSON.stringify молча выбросил поле type. По проводу едет имя, а в браузере по нему находят настоящий модуль. У Next вместо имени номер модуля и список файлов, которые придётся загрузить, — те самые строки вида 4:I[98521,…] в исходнике страницы.

Пропсы дырки — единственное, что уезжает от клиентского компонента по проводу, и записываются они тем же способом. Отсюда правило «через границу только сериализуемое»: функция не поместится в пропсы ровно по той причине, по которой в JSON не поместился сам компонент.

Второй вопрос — что приезжает в первом ответе. Payload на него не отвечает: браузер покажет разметку, а не описание дерева. Значит, по тому же дереву нужен другой обход, и правило у него противоположное — выполняется всё. Клиентский компонент тоже: в первом кадре на месте кнопки должна быть кнопка, а не дырка.

Прогноз: выполнится ли теперь тело Likes — и что станет с его onClick?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тело Likes выполнилось — вот механический ответ на вопрос из прошлой статьи, почему тело клиентского компонента выполняется дважды. Первый раз здесь, ради разметки; второй будет в браузере. А onClick в строку не попал: в атрибут HTML записывается строка, функции там негде лежать. Разметка кнопки есть, кнопка не нажимается — то самое мёртвое окно, только теперь видно, из чего оно сделано.

Тут же разрешается странность, которая могла остаться после сборки провода: там писатель клиентский компонент вызвать не мог — сборщик подсунул ему вместо файла ссылку. Наш строковый проход его вызывает, и противоречия здесь нет: наборов модулей на сервере два. В том, по которому пишется payload, на месте кнопки ссылка; в том, по которому строится HTML, — настоящая функция. Одна кнопка, две сущности, и обе живут на сервере.

Флаг обязан вернуться в false сразу после прохода: оставите true — и useState в браузере продолжит отдавать пустой сеттер, клик перестанет что-либо менять. Обратная ошибка — забыть включить флаг перед проходом: тогда useState уйдёт к настоящему React вне всякого рендера, и вы получите «Invalid hook call» из правил хуков.

Два обхода вы написали сами. Третий писать не нужно — он есть, называется hydrateRoot, и его работу вы уже видели: присоединиться к готовым узлам, а не рисовать заново. Не хватало только своей разметки, к которой можно присоединяться. Теперь она есть.

Прогноз перед запуском кажется скучным: строку посчитал наш писатель по тому же дереву, которое мы отдаём React, — значит, узел должен уцелеть. Запустите.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
кнопка та же самая? false — гидратация не удалась, и React пересоздал узлы. Причина в строке выше: узлов внутри кнопки: 1.

Разберём, откуда берётся это число узлов. В Likes написано нравится: {n} — у кнопки два ребёнка: строка и число. React создаёт под них два текстовых узла, а наш писатель склеил их в один: <button id="like">нравится: 3</button>. Обходя разметку, React ждёт на первом месте текст «нравится: », а находит «нравится: 3» — и объявляет несовпадение.

Дальше начинается самое дорогое. React не чинит один узел: он выбрасывает всё поддерево и рисует его заново, на своих узлах. Посчитайте строки «выполнилось тело Likes» — их четыре: строковый проход, попытка гидратации, перерисовка с нуля и клик. Кнопка после этого работает и даёт 4, но готовый HTML не дал ничего: его выбросили, и весь серверный рендер на этом куске оказался холостым.

React сообщает об этом ошибкой, но прочитать её в песочнице нельзя: React подключён отдельным файлом с чужого для песочницы происхождения, и текст таких ошибок браузер скрывает — в выводе остаётся строка о том, что бросила её подключённая библиотека. В своём проекте вы увидите фразу целиком: «Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client», а под ней — дерево с плюсом и минусом на разошедшемся месте.

Лечится это не в React, а в писателе. Раз ожидаются два узла, между ними нужна граница, и в HTML такая граница ровно одна — пустой комментарий. Прогноз: что теперь покажет счётчик узлов и переживёт ли кнопка гидратацию?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
узлов внутри кнопки: 3 — текст, комментарий, текст. Гидратация прошла молча, кнопка осталась той же самой, клик работает, а тело Likes выполнилось три раза вместо четырёх. Ради этого true серверный рендер и затевается: узлы, приехавшие с сервера, остались на месте и просто ожили.

Проверьте на этой самой странице: Ctrl+U и поиск по «<!--». В шапке найдётся «≈ <!-- -->11<!-- --> минут» — те же границы текстовых узлов, только расставил их настоящий серверный рендер React.

Наш писатель — миниатюра, и честно перечислить недостающее полезнее, чем сделать вид, что его хватит. Экранирование текста и значений атрибутов — про это задача ниже, и она не про красоту, а про безопасность. Одиночные теги (img, br, input) закрывать нельзя, а наш писатель закрывает. Имена пропсов React переводит в имена атрибутов: className становится class, htmlFor — for, объект style — строкой. Булев атрибут пишется без значения или не пишется вовсе.

И один пункт из той же серии, что и разделитель текстов: границы Suspense React помечает в разметке комментариями вида <!--$--> и <!--/$-->. Это и есть «невидимая метка с адресом» из статьи про потоковую отдачу: по ней приехавший позже кусок находит своё место и вытесняет заглушку.

В прошлом примере есть натяжка, и она важнее, чем кажется. Мы отдали hydrateRoot дерево <Post /> — то есть выполнили серверный компонент второй раз, уже в браузере. Так в настоящем приложении не бывает: кода Post в браузере нет, он не уехал в бандл, и звать его нечем. Значит, дерево для гидратации нужно взять из второго канала — из payload.

Соберём оба канала целиком: сервер пишет разметку и описание дерева, браузер читает описание, подставляет на место дырок настоящие модули и гидратирует этим деревом готовую разметку. Прогноз: какие тела выполнятся после строки «сервер отработал»?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

После разделителя нет ни одного «выполнилось тело Post» — и не может быть: в браузере от Post есть только результат, записанный в payload. Выполнилось лишь тело Likes, потому что его функция в браузере есть; реестр modules — это и есть бандл, а read — то место, где имя из провода превращается в настоящий компонент. Кнопка при этом осталась той же самой: разметка приехала одним каналом, дерево — другим, и в браузере они сошлись.

Вот и ответ, зачем нужны оба: в прошлой статье это было сказано словами, а здесь видно из кода. По HTML нельзя понять, где кончается серверная разметка и начинается клиентский остров: тегов «остров» не бывает. И при переходе по ссылке новую страницу надо вставить в живое дерево, а не перезагрузить целиком — для этого опять нужно описание, а не картинка.

ПроходНа какой вопрос отвечаетЧто выполняетсяЧто на выходе
писатель payloadгде выполнился кодтолько серверные компонентыописание дерева с дырками под модули
свой renderToStringчто лежит в первом ответевсе компоненты, включая клиентскиестрока HTML, в которой нет ни одного обработчика
hydrateRootчто ожило в браузеретолько клиентские компонентыживое дерево на тех же самых узлах

Наш сервер, кстати, прошёл по дереву дважды — отдельно ради payload и отдельно ради строки. Настоящий проходит один раз: HTML он строит уже по описанию, подставляя модули из своей копии бандла. На ответы это не влияет, но объясняет, почему серверный компонент выполняется ровно один раз даже при включённом серверном рендере.

Наш payload — один JSON.stringify на всё дерево, и отдать его можно только целиком. Настоящий устроен рядами — теми самыми, которые вы писали, собирая провод: у каждого куска свой номер, ссылка $L1 означает «этот кусок приедет позже», и ряды уходят в браузер по мере готовности — так и работает потоковая отдача, о которой вы читали в этой же главе. Строки self.__next_f.push в исходнике страницы — это именно ряды, разложенные по вызовам.

Отличий по мелочи ещё много: вместо имени функции — номер модуля от сборщика и список файлов; пропсы проверяются на сериализуемость, и непроходимое значение падает с ошибкой сборки, а не молча исчезает; типы вроде Date и Map едут своими пометками, а не превращаются в строку. Но устройство от этого не меняется: писатель обходит дерево, на клиентских компонентах ставит дырки, а читатель на другом конце подставляет в них модули.

Три вопроса из прошлой статьи после этого перестают быть терминами. «Где выполнился код» — кто вызвал функцию из поля type. «Что приехало в первом ответе» — что успел записать писатель разметки. «Что ожило» — к каким узлам присоединился hydrateRoot и по какому дереву. И если ответ на третий вопрос вас не устроил, чинить надо не React, а того из двух писателей, который соврал.

  • Дерево — обычный объект: JSX ничего не выполняет, а решает, звать ли функцию из поля type, тот, кто это дерево обходит.
  • Писатель payload выполняет серверные компоненты и оставляет вместо клиентских дырку с именем модуля и пропсами — функция по проводу не едет, потому что не записывается.
  • Свой renderToString обходит то же дерево иначе: выполняет все тела, включая клиентские, и отдаёт строку, в которой нет ни одного обработчика.
  • hydrateRoot присоединяется к готовым узлам, но только если разметка совпала с тем, что React насчитает сам; между двумя соседними текстами обязан стоять разделитель <!-- -->.
  • Несовпадение стоит дорого: React выбрасывает приехавшее поддерево и рисует заново — страница работает, а серверный рендер на этом куске оказался холостым.
  • Каналов два не для надёжности: HTML — первый кадр, payload — дерево, по которому в браузере восстанавливают острова и вставляют новые страницы в живое дерево.

1/4Почему писатель payload кладёт на место клиентского компонента имя, а не саму функцию?

2/4Что происходит с телом клиентского компонента, пока сервер строит HTML?

3/4В компоненте написано <p>счёт: {n}</p>, а сервер прислал <p>счёт: 0</p>. Что не так?

4/4Гидратация не сошлась с разметкой. Что сделает React?

Дырка, которая проглотила содержимое

Клиентская панель получает содержимое сверху — через children, тем самым приёмом, которым серверный компонент попадает внутрь клиентского. Писатель payload из статьи лежит прямо в примере. Запустите и посмотрите на payload: заметки в нём нет, и тело Note не выполнялось. Почините писателя так, чтобы содержимое дырки ехало по проводу готовым.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Дойдя до клиентского компонента, писатель возвращает дырку и на этом останавливается — вместе с содержимым, которое в дырку передали. А содержимое здесь серверное: его обязан посчитать сервер, потому что в браузере кода Note нет. Значит, обход должен продолжиться по children даже там, где сам компонент не выполняется. Писатель, которого вы собирали для своей границы, так и делал — обходил пропсы клиентского компонента вглубь; упрощённый писатель этой статьи эту ветку потерял.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После правки в выводе появляется «выполнилось тело Note», а в payload — вложенное дерево:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Читателю на другом конце остаётся передать эти kids третьим аргументом React.createElement — и серверная разметка окажется внутри клиентской панели, ни разу не побывав кодом в браузере. Это и есть механика слота children из статьи про то, что можно передать через границу.

Писатель, который пускает чужую разметку

Текст отзыва пришёл от читателя, и в нём оказались угловые скобки. Писатель разметки из статьи лежит прямо в примере. Запустите: сначала посмотрите, что попало в DOM, потом — уцелел ли узел после гидратации. Почините писателя так, чтобы чужие теги не оживали, а узел переживал гидратацию.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Писатель склеивает строку, а браузер разбирает её как разметку — значит, любой текст из данных становится тегами. Проверка печатает чужой тег ожил в DOM? true: в странице появился настоящий <b>, которого в дереве не было. Гидратация это ловит (в дереве текст, в DOM элемент) и перерисовывает поддерево — отсюда узел уцелел? false. На экране остаётся правильный вариант, но лишь потому, что React всё выбросил и нарисовал сам.

Лечение — экранирование, и оно обязано касаться и текста, и значений атрибутов:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Дальше toHtml оборачивает текстовые узлы и каждое значение атрибута. После правки вывод становится скучным — чужой тег ожил в DOM? false, узел уцелел? true, — и это правильный вид работающего серверного рендера. Заодно понятно, почему React вставляет текст только текстом: dangerouslySetInnerHTML называется так именно потому, что отключает эту защиту.

Данные разошлись: что останется на экране

Сервер посчитал разметку по данным «3», а дерево для гидратации собрали по данным «5»: пока грузился бандл, число успело измениться, и в браузер оно приехало уже другим. Скажите заранее, что напечатают строки «приехало с сервера» и «осталось на экране» и что увидит читатель, — и только потом запускайте.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первой печатается приехало с сервера: нравится: 3 — это разметка, которую читатель видел до всякого JavaScript. Последней — осталось на экране: нравится: 5: React нашёл в DOM не тот текст, объявил несовпадение и перерисовал поддерево по своему дереву.

Вывод из этого практический: при расхождении побеждает браузер, а первый кадр оказывается враньём, которое читатель успел прочитать. Ошибку гидратации поэтому и стоит считать настоящей ошибкой, а не шумом в консоли: сама по себе страница работает, но пользователь видел одно, а получил другое — и заплатил за это лишним рендером всего поддерева.

Чинится это не разделителями и не экранированием, а данными: то, что показывает сервер, должно приехать в браузер вместе с разметкой — пропсом или payload, а не быть посчитанным заново.