Своя граница: писатель, провод, читатель

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

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

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

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

Задача у писателя простая на словах: взять дерево, которое вернул серверный компонент, и превратить его в текст. Элемент — обычный объект с типом, ключом и пропсами, вы разбирали его в статье «JSX — это не HTML»; значит, обойти дерево можно так же, как любой другой объект. Особенным местом будет ровно одно: встретив функцию-компонент, писатель её вызывает.

Прежде чем жать Run, решите: встретятся ли в отправленном тексте слова Product и Price?

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

Ни того, ни другого. Product и Price — это функции, писатель позвал их на сервере, и в текст поехало то, что они вернули. Вот почему их кода нет в браузере: он не «вырезан оптимизатором» — ему просто нечего делать в тексте, который едет по проводу.

А форму записи стоит запомнить: ["$", тип, ключ, пропсы] — четыре ячейки. Первая ячейка — метка «это элемент», дальше тип ("main", "h1"), ключ и объект пропсов, внутри которого лежат дети. Продакшн-сборка настоящего React пишет на это дерево ровно такую же строку, символ в символ.

У элемента React есть поле $$typeof с символом внутри — Symbol.for("react.transitional.element"), по нему React и отличает элемент от постороннего объекта. Символ из общего реестра, по таблице прошлой статьи, проезжает — но писать его имя в каждом элементе долго, а элементов на странице тысячи. Поэтому на проводе его место занимает один знак: строка "$" в нулевой ячейке массива.

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

Свой payload увидеть просто: в проекте на Next откройте вкладку «Сеть» и перейдите по ссылке внутри приложения — придёт ответ, набранный такими же строками. Под next dev не пугайтесь: к четвёрке там добавлены три отладочные ячейки — кто создал элемент, где в коде и прошла ли проверка, — а между строками идут служебные, со стеком. Чистую четвёрку покажет продакшн-сборка — next build и next start.

Теперь главное место миниатюры. Что делать писателю, когда компонент помечен 'use client'? Вызвать его нельзя, и не из вежливости: у такого компонента состояние, обработчики и жизнь во времени — всего этого на сервере не существует. Записать его код тоже нельзя, потому что кода тут нет.

Нет буквально. Сборщик, увидев директиву, оставляет серверной стороне не модуль, а заглушку с адресом: путь к файлу и имя экспорта, вида ./like-button.tsx#LikeButton. В миниатюре эту роль играет карта bundle: компонент — адрес.

В примере два console.log: один в серверном Stats, другой в клиентской LikeButton. Предскажите, какие из них напечатаются, — и только потом запускайте.

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

Напечаталась одна строка, про Stats. LikeButton писатель не вызывал: он завёл строку 1 с адресом модуля и поставил на место типа ссылку "$L1". Сравните с первым разделом: там на месте типа стояло "main" — имя тега; здесь стоит номер строки, в которой написано, что грузить.

И посмотрите, что стало с начинкой. <Stats /> лежал внутри клиентского компонента — и всё равно выполнился на сервере, а в поток поехал готовым элементом внутри пропса children. Это и есть «рамка клиентская, начинка серверная» из прошлой статьи, только теперь правило видно в тексте: писатель обходит пропсы до того, как отдаёт ссылку, и клиентский компонент про свою начинку по-прежнему ничего не знает.

Часть дереваЧто с ней сделал писательЧто осталось в тексте
Серверный компонентвызвалтолько результат вызова
Обычный тегзаписал четвёркой["$","main",null,{…}]
Клиентский компонентне вызывалстрока I[…] и ссылка "$L1"
Пропсы клиентского компонентаобошёл вглубьданные, поле за полем
children клиентского компонентаобошёл вглубь и вызвал, что внутриготовая разметка

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

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

Приём тот же, что у доллара из первого раздела: значение особого вида превращается в строку с приставкой, а читатель по приставке узнаёт, что собирать. Предскажите две вещи: во что превратится note: undefined и что станет со строкой "$100".

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

Дата уехала строкой $D… и вернулась датой; карта уехала строкой $Q… и вернулась картой. Интереснее два других поля.

note: undefined стало "$undefined" — и последняя строка вывода объясняет зачем: обычный JSON.stringify такое свойство выбрасывает целиком, вместе с именем. Для пропсов это не мелочь: { note: undefined } и {} — разные объекты, во втором нет самого поля, и проверка "note" in props даст разные ответы по разные стороны провода.

А строка "$100" поехала как "$$100". Без этого удвоения читатель увидел бы в ней запись особого вида — и цена превратилась бы неизвестно во что. Так формат платит за то, что метки живут прямо в строках: одна строка из ста тысяч начинается с доллара, и ради неё правило нужно всем.

ПриставкаЧто этоПример
$Dдата"$D2026-03-01T00:00:00.000Z"
$Q, $Wкарта и множество"$Q2" — ссылка на строку с парами
$nbigint"$n90071992547409931"
$Sсимвол из общего реестра"$Stheme"
$undefinedundefinedполе не теряется вместе с именем
$@промис — дырка на проводеоб этом пятый раздел
$Lссылка на модуль"$L1" из второго раздела
$$строка, начинавшаяся с доллара"$$100" — это "$100"

У нас карта уехала целиком внутри своей строки, а настоящий React кладёт её пары отдельной строкой и ссылается номером — «$Q2». Если та же карта встретится в пропсах второй раз, заново он её не пишет: ставит ссылку по пути до первой — «$0:props:byTag», — и на другой стороне это будет тот же самый объект, а не копия.

Функция — самый частый случай, когда писателю приходится не записывать, а останавливаться; экземпляр класса и свой символ из таблицы прошлой статьи останавливают его так же. JSON.stringify в этом месте молчит: свойство с функцией он просто пропускает, вы это видели в прошлой статье. Молчание здесь — худшее из возможного: пропс исчезает, компонент падает уже в браузере на format is not a function, и до настоящей причины ещё идти и идти.

Поэтому писатель сначала ищет функцию, а потом рисует картинку места. В третьем вызове функция спрятана в объекте внутри массива — предскажите, найдёт ли он её.

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

Нашёл: проверка идёт вглубь, и путь props.items.0.onPick ведёт прямо к виноватому полю. Обратите внимание на второй вызов: он нарисовал точно ту картинку, которую вы читали в прошлой статье как сообщение React, — имя компонента, все пропсы и каретку под тем, из-за которого всё встало. Теперь понятно, из чего она сделана: React не показывает ваш исходник, он рисует элемент по тому, что у него в руках.

Настоящее сообщение отличается тремя вещами. Оно по-английски — Functions cannot be passed directly to Client Components. Вместо пути точками React дорисовывает картинку глубже: показывает объект, в котором нашлась функция, и ставит каретку внутри него. А если имя пропса похоже на обработчик — on и заглавная буква, как у onPick, — React догадывается, в чём дело, и пишет другое: Event handlers cannot be passed to Client Component props. Смысл тот же: место названо.

Если функция помечена 'use server', сборщик поступает с ней зеркально клиентскому компоненту: заменяет тело ссылкой, а адрес кладёт в свой манифест — серверный. Писатель записывает эту ссылку как обычное значение, и в браузер приезжает заглушка.

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

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

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

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

Оболочка ушла на нулевой миллисекунде — с дыркой "$@1" вместо оценки, — а содержимое догнало её отдельным куском через триста. С браузерной стороны это картина из статьи «use() и Suspense»: пока строки 1 нет, промис в пропсе не завершён, use() в клиентском компоненте приостанавливается, и на месте блока стоит заглушка ближайшей границы <Suspense>. Приехала строка — промис завершился, и блок встал на место.

Тем же приёмом едет и приостановленный серверный компонент под <Suspense>, только метка у дырки другая — «$L2», как у клиентского компонента: на месте разметки стоит ссылка на строку, которая придёт позже. Как на этом держится потоковая отдача всей страницы — отдельная статья дальше в этой главе.

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

Разбор идёт строка за строкой. Строка с I запоминается в таблице «номер — адрес модуля»; строка 0 разбирается как JSON, и её четвёрки превращаются обратно в элементы. На месте типа может стоять имя тега — тогда всё просто; а может стоять ссылка "$L1" — тогда читатель идёт в манифест.

Предскажите три вещи: что окажется на кнопке до нажатия, что после — и попадёт ли внутрь кнопки серверная разметка.

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

Кнопка работает. По проводу приехал текст, в котором не было ни строчки её кода, — а на экране живой компонент со своим состоянием, и нажатие меняет число. Ссылка "$L1" превратилась в настоящую функцию из бандла, пропс initial={5} приехал данными, и useState взял из него начальное значение.

Строка «Куплено 812 раз» тоже на месте — внутри кнопки, — и обошлось это без единого вызова серверного компонента: Stats в браузере нет вовсе, приехала его готовая разметка. Тот же самый механизм, что и в примере с children из прошлой статьи, просто теперь вы видите обе его половины.

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

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

Настоящий React говорит почти теми же словами: Could not find the module "…" in the React Server Consumer Manifest. This is probably a bug in the React Server Components bundler. В Next его произносит читатель, который работает на сервере и собирает из потока HTML первого показа. У писателя есть зеркальное сообщение — там манифест называется React Client Manifest, и приходит оно, когда адреса нет уже у него.

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

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

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

  • Писатель обходит дерево как обычный объект: серверный компонент он вызывает, обычный тег записывает четвёркой ["$", тип, ключ, пропсы], а в текст едет только результат.
  • Клиентский компонент — единственное место, где функция не вызывается: сборщик оставил серверу лишь адрес модуля, и в поток уходят строка с этим адресом и ссылка "$L1" на неё.
  • Значения особых видов едут строками с приставкой — $D для даты, $Q и $W для карты и множества, $undefined вместо потерянного поля; строка, начинавшаяся с доллара, метится удвоением.
  • Функцию писатель не выбрасывает молча, как JSON.stringify, а останавливается и называет место: компонент, пропс и каретку под виноватым значением.
  • Незавершённый промис становится дыркой "$@1": оболочка уходит сразу, содержимое догоняет отдельным куском — с браузерной стороны это и есть заглушка, которая сменяется блоком.
  • Читатель собирает дерево обратно, подставляя компоненты из бандла по манифесту; когда манифест разошёлся с потоком, он падает с внятным «модуля нет в манифесте», а не с непонятной разметкой.

1/4Писатель встретил <LikeButton />, и LikeButton помечен 'use client'. Что он делает?

2/4В пропсе клиентского компонента лежит строка "$100". Что окажется в тексте?

3/4Серверный компонент передаёт клиентскому промис, который к моменту записи ещё не завершился. Что уедет в браузер?

4/4Читатель дошёл до "$L1", а адреса из строки 1 в манифесте нет. О чём это говорит?

Кнопка, которая никогда не оживёт

У этого писателя нет особого случая для клиентских компонентов: он вызывает любую функцию, какую встретит. Карта bundle заполнена, но не используется. Запустите и посмотрите на текст, а потом почините — так, чтобы в выводе появилась строка с адресом модуля, а на месте типа встала ссылка.

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

В тексте приехала обычная кнопка — и без onClick: функцию JSON.stringify молча выбросил. Разметка есть, нажатие мертво, и никто об этом не сказал. Строк с адресом модуля ноль, а значит, читателю неоткуда взять настоящий компонент.

Чинится проверкой до вызова: клиентский компонент узнаётся по карте, и на его месте появляется ссылка.

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

Понадобится ещё let nextRow = 1; рядом с rows. После правки вывод начинается со строки 1:I["./like-button.tsx#LikeButton"], а в нулевой строке на месте типа стоит "$L1" — и пропс initial едет рядом, данными.

Множество, которое пропало

Писатель листьев знает дату и карту, а про множество не слышал. Запустите и убедитесь, что потеря происходит молча — ни ошибки, ни предупреждения. Добавьте множеству свой вид записи: в тексте должна появиться приставка, а проверка в последней строке — напечатать true 2.

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

Множество провалилось в последнюю строку writeLeaf и уехало объектом без единого поля: JSON.stringify о Set не знает ничего. Читатель честно вернул то, что приехало, — {}, у которого нет ни size, ни методов.

Вид записи добавляется парой строк — по одной с каждой стороны провода:

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

Теперь в тексте "$W[\"март\",\"кофе\"]", а проверка печатает true 2. Приставка $W выбрана не случайно — именно её ставит настоящий React; $S у него занято символами.

Прочитать поток

Перед вами две строки чужого ответа и печаталка скелета. Не запуская, ответьте: сколько тегов на странице, какой модуль обязан лежать в манифесте браузера и что окажется внутри абзаца после сборки. Потом запустите и сверьтесь.

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

Скелет выглядит так:

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

Тегов три: main, h1 и p. Четвёртая позиция — не тег, а ссылка: строка 2 называет адрес ./tabs.tsx#Tabs, и без этого модуля в манифесте читатель до разметки не дойдёт. Пропс active={1} едет обычным числом.

А внутри абзаца сюрприз. Там запись вида $D, и читатель честно соберёт из неё настоящий Date — прямо в детях тега p. Рисовать такое React откажется: Objects are not valid as a React child (found: [object Date]). Провод довёз дату без потерь; ошибка случилась раньше, на сервере, где компонент положил её в разметку, не превратив в строку. Типичный путь к такому потоку — поле из базы, объявленное строкой, а на деле пришедшее датой; про такие места была статья «Данные извне — это unknown».

Заодно видно, где проходит граница: Tabs — клиентский, но его начинка приехала готовым абзацем и выполнялась на сервере.