open atlas
↑ К треку
React с нуля до senior RCT · 08 · 03

Streaming SSR и селективная гидратация: HTML волнами

renderToPipeableStream шлёт каркас первым; приостановленные поддеревья доезжают скрытыми кусками с инлайн-скриптами подмены. Гидратация идёт по границам, клик повышает её приоритет и переигрывается. Медленный await в каркасе блокирует всё.

RCT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Новостной сайт перевёл страницы статей на 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 как швы. Всё, что вне любой границы, — каркас. Жизненный цикл:

  1. React рендерит, пока каркас не готов. Приостановленные поддеревья его не блокируют — на их месте React выводит HTML fallback границы.
  2. Срабатывает onShellReady; вы начинаете отдавать ответ. Браузер получает целую, пригодную к отрисовке страницу — лейаут, статичный контент, скелетоны — в одном chunked-ответе HTTP, который остаётся открытым.
  3. По мере резолва каждого приостановленного поддерева на сервере React дописывает в поток ещё кусок: настоящий HTML в скрытом <div> плюс инлайн-<script>, который переносит его в слот fallback в DOM. Для подмены не нужен клиентский фреймворк — она работает до загрузки вашего бандла.
  4. Когда все границы дослались, поток закрывается (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 деградирует; метрики расходятся, потому что заголовок приезжает второй волной.
  • Недетерминированный рендер в каркасе — несовпадение гидратации без границы-контейнера клиентски перерендеривает полстраницы, удваивая работу на устройстве пользователя ровно тогда, когда оно занято сильнее всего.
  • Единица гидратации размером со страницу — одна граница вокруг всего означает, что первый клик куда угодно оплачивает полную гидратацию; гранулярные границы делают тапнутый виджет интерактивным в одиночку.
Вспомните перед уходом
  1. 01
    Проведите страницу статьи через renderToPipeableStream от запроса до полной интерактивности, называя, что ограничивает каждую веху.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.