Streaming SSR и селективная гидратация: HTML волнами
renderToPipeableStream шлёт каркас первым; приостановленные поддеревья доезжают скрытыми кусками с инлайн-скриптами подмены. Гидратация идёт по границам, клик повышает её приоритет и переигрывается. Медленный await в каркасе блокирует всё.
Новостной сайт перевёл страницы статей на renderToPipeableStream и праздновал в стейджинге: TTFB упал с 1,9 с до 210 мс, тело статьи видно, пока комментарии доезжают следом. Продакшен рассказал другое: для залогиненных пользователей TTFB стал хуже, чем у старого блокирующего renderToString, — 2,4 с на p99. Виноват был дифф в четыре строки: шапка с персонализацией, читающая уровень подписки пользователя, с await на самом верху компонента лейаута. Лейаут стоит выше всех границ Suspense — а значит, входит в каркас (shell), и поток не начинается, пока каркас не готов. Один медленный поход в session store (p99 900 мс, плюс холодная реплика Redis на той неделе) встал перед каждым байтом HTML, включая статичную шапку сайта. Команда заново построила блокирующий SSR с лишними шагами, а фреймворк сделал ровно то, что велели границы. Бейдж подписки перенесли под границу с fallback null, каркас стал чистым лейаутом — и TTFB вернулся к 230 мс; бейдж всплывал на 800 мс позже, чего никто не заметил. У стримингового конвейера один необсуждаемый контракт: каркас — это то, что вы пообещали отрендерить раньше, чем кто-либо что-либо увидит, и каждый await в нём — налог на каждого пользователя.
Конвейер: сначала каркас, потом куски по мере резолва
renderToPipeableStream (Node; на edge-рантаймах — renderToReadableStream) рендерит дерево на сервере, используя границы Suspense как швы. Всё, что вне любой границы, — каркас. Жизненный цикл:
- React рендерит, пока каркас не готов. Приостановленные поддеревья его не блокируют — на их месте React выводит HTML fallback границы.
- Срабатывает
onShellReady; вы начинаете отдавать ответ. Браузер получает целую, пригодную к отрисовке страницу — лейаут, статичный контент, скелетоны — в одном chunked-ответе HTTP, который остаётся открытым. - По мере резолва каждого приостановленного поддерева на сервере React дописывает в поток ещё кусок: настоящий HTML в скрытом
<div>плюс инлайн-<script>, который переносит его в слот fallback в DOM. Для подмены не нужен клиентский фреймворк — она работает до загрузки вашего бандла. - Когда все границы дослались, поток закрывается (
onAllReady— хук для краулеров и статической генерации: дождаться всего и отправить разом, разменивая TTFB — Time To First Byte, время до первого байта ответа — на полный HTML).
Режим отказа следует из шага 1: любая зависимость от данных вне границы блокирует каркас. Неважно, что остальная страница готова к стримингу: первый байт ждёт самый медленный await над линией границ. Это та же дисциплина расстановки, что и в уроке про загрузочный UI, только теперь с ценником TTFB — расстановка границ решает не только что исчезает вместе, но и что сервер вправе отложить.
// ❌ в shell: TTFB каждого пользователя теперь включает этот запрос
const tier = await session.getTier(userId); // p99 900ms
// ✅ под границей: shell стримится немедленно, badge — позже
<Suspense fallback={null}>
<TierBadge userId={userId} />
</Suspense>После миграции на renderToPipeableStream TTFB дашборда всё равно 1,8 с, хотя большинство виджетов лежит под границами Suspense. Трейс показывает: первый байт ответа ждёт вызов сервиса фиче-флагов. Где баг?
Селективная гидратация: по границам, прерываемо, с учётом кликов
На клиенте hydrateRoot больше не гидратирует страницу одним синхронным монолитом. Границы Suspense делят гидратацию на независимые единицы, и отсюда три свойства:
- Гидратация начинается до того, как всё доехало. Каркас гидратируется, как только загрузился бандл, даже пока поздние куски ещё стримятся. Доехавшие границы гидратируются по мере появления их кода и контента.
- Гидратация прерываема. Между границами React уступает браузеру — длинная гидратация больше не блокирует ввод, как однопроходный
hydrate(), где 600 мс гидратации означали 600 мс мёртвых кнопок. - Взаимодействие меняет приоритеты. Если пользователь кликает внутри ещё не гидратированной границы, React записывает событие, срочно гидратирует именно эту границу (пропуская её вперёд тех, что раньше по дереву), а затем переигрывает клик по уже живым обработчикам. Клик не теряется и не достаётся мёртвому HTML — он ставится в очередь и доставляется с опозданием. На быстрой машине зазор неощутим; на медленной пользователь замечает паузу между тапом и реакцией — что всё равно строго лучше мира до React 18, где тап молча не делал ничего.
Дизайн-следствие: границы — это ещё и единицы гидратации. Граница на всю страницу означает, что клик куда угодно гидратирует всё; гранулярные границы означают, что поле комментария, по которому тапнули, гидратируется в одиночку — миллисекунды вместо сотен.
Несовпадения гидратации при стриминге
Гидратация сравнивает серверный HTML с первым клиентским рендером и требует их идентичности. Стриминг поднимает ставки двумя способами. Во-первых, классические причины — Date.now(), new Date().toLocaleString(), Math.random(), ветвление по user-agent, различия локалей — теперь срабатывают в разное время для разных границ: сервер отрендерил кусок с комментариями на 1,4 с позже каркаса, и любое производное от времени значение расходится не только между сервером и клиентом, но и между границами. Во-вторых, восстановление дороже, чем кажется: при несовпадении React клиентски перерендеривает содержимое ближайшей границы Suspense с нуля, выбрасывая дострименный HTML этой границы. Страница не падает, консоль предупреждает — а вы молча платите двойную стоимость рендера и возможное мигание области. Несовпадение в каркасе (над которым границы нет) — дорогой случай: React пересоздаёт на клиенте куда большую область. Дисциплина: всё недетерминированное рендерится одинаково (suppressHydrationWarning для таймстампа — скальпель, а не образ жизни) либо уезжает в эффект, выполняющийся только на клиенте, с осознанной платой в виде двухпроходного рендера этого узла.
TTFB против LCP: размер каркаса
Стриминг даёт ручку, а не бесплатный обед. Всё, что в каркасе, приходит на TTFB, но задерживает его; всё, что за границей, улучшает TTFB, но приезжает поздней волной. Два Core Web Vitals в противоборстве: TTFB (хорошо — до 800 мс) награждает тонкий каркас; LCP (хорошо — до 2,5 с) награждает самый крупный контентный элемент в первой волне — если ваш hero-баннер или заголовок статьи доезжает третьей волной, LCP считается по приходу третьей волны. Сениорская эвристика: каркас содержит лейаут плюс LCP-элемент тогда и только тогда, когда его данные стабильно быстрые (кэш в единицы миллисекунд или вовсе без данных); всё с длинным хвостом — персонализация, рекомендации, комментарии — за границы. И мерьте волны: каркас в 90% страницы — это переименованный блокирующий SSR, а каркас-пустая-рамка отдаёт LCP второй волне и теряет то, что стриминг купил.
Во время стриминга пользователь кликает «ответить» в секции комментариев, пока её граница ещё не гидратирована. Что на самом деле делает React 18+?
Режимы отказа, которые стоит отрепетировать
- Медленный await в каркасе — инцидент из пролога: один поход в session store над линией границ держит TTFB каждого пользователя. Аудируйте то, что вне границ, как аудируете горячий путь.
- LCP-элемент за медленной границей — TTFB на дашборде красивый, а LCP деградирует; метрики расходятся, потому что заголовок приезжает второй волной.
- Недетерминированный рендер в каркасе — несовпадение гидратации без границы-контейнера клиентски перерендеривает полстраницы, удваивая работу на устройстве пользователя ровно тогда, когда оно занято сильнее всего.
- Единица гидратации размером со страницу — одна граница вокруг всего означает, что первый клик куда угодно оплачивает полную гидратацию; гранулярные границы делают тапнутый виджет интерактивным в одиночку.
- 01Проведите страницу статьи через renderToPipeableStream от запроса до полной интерактивности, называя, что ограничивает каждую веху.
- 02Во что обходится несовпадение гидратации при стриминговом SSR, откуда берутся специфичные для стриминга случаи и какова стратегия сдерживания?
Streaming SSR заменяет ответ «всё или ничего» волнами по одному открытому соединению. Каркас — всё вне любых границ Suspense — рендерится первым, и до его готовности не отправляется ничего: этот контракт и укусил новостной сайт, где один 900-миллисекундный поход в session store в лейауте держал каждый байт для каждого пользователя — заново построенный блокирующий SSR с лишними шагами. Приостановленные поддеревья уезжают в каркасе как HTML fallback, а потом доезжают скрытыми кусками в паре с инлайн-скриптами, подменяющими их на месте, — без бандла, в том же chunked-ответе. На клиенте границы становятся единицами гидратации: hydrateRoot идёт по границам, уступает между ними и трактует взаимодействие как сигнал приоритета — клик внутри холодной границы перехватывается, граница прыгает в начало очереди гидратации, и клик переигрывается по живым обработчикам, а не умирает на мёртвом HTML. Несовпадения стоят больше, чем подсказывает предупреждение: React перерендеривает содержимое ближайшей границы на клиенте с нуля, так что недетерминизм в каркасе — где ущерб нечем ограничить — это дорогой вариант, а стриминг добавляет новый источник: рассинхрон времени между кусками. Архитектурный вопрос — размер каркаса против двух метрик, тянущих в разные стороны: TTFB (до 800 мс) хочет тонкий каркас; LCP (до 2,5 с) хочет самый крупный элемент в первой волне. Лейаут плюс LCP-контент с быстрыми данными — в каркас, каждая зависимость с хвостом — за границу. Расстановка, снова, и есть весь дизайн. Теперь, когда вы переходите на renderToPipeableStream и TTFB (время до первого байта) не улучшается — первым делом проверьте, что стоит выше ваших границ Suspense: один медленный await там воссоздаёт ровно тот блокирующий SSR, от которого вы уходили.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.