Webrium
Подписаться

Серверные компоненты: год в продакшене

Вы узнаете, как серверные компоненты пересылают дерево по проводам, почему проп через границу становится частью сетевого контракта и какие поломки модели предсказуемы заранее

Поле разделено швом «use client»: элементы-пропсы пересекают границу между сервером и клиентом и становятся сетевым контрактомсерверклиент

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

Что уезжает по проводам

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

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

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

Поэтому граница — это дизайн, а не аннотация. Её проводят и ревьюят, как API. Про то, где вообще проходит граница клиент — сервер и как её выбирать, у нас есть отдельный разбор.

Почему ломается именно так

Обычное клиентское приложение живёт в одном процессе, и все его идиомы держатся на ссылках. Модель серверных компонентов пересылает дерево по значению. Почти каждая характерная поломка — следствие этого сдвига.

Замыкание — ссылка на область видимости, поэтому функция-проп через границу не переедет. Типичная первая ошибка: передать в клиентский компонент колбэк и удивиться, что «просто функцию» нельзя. Систематическое исключение одно — серверные экшены: функция остаётся на сервере, а через границу уезжает пометка «это можно позвать». Не отмена правила, а его осознанное применение: ссылка на вызов вместо ссылки на область видимости.

Модуль живёт в двух копиях. Если один и тот же модуль импортируют и серверные, и клиентские компоненты, он собирается дважды — в серверный бандл и в клиентский. Любой объект «один на приложение» — конфиг, реестр, кеш в памяти модуля — на самом деле два разных объекта, которые друг о друге не знают. Поломка выглядит как «глобальное состояние молча рассинхронилось», хотя глобального состояния и не было: было два локальных.

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

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

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

Кеши, которых раньше не было

Дерево, передаваемое по значению, можно хранить — и модель этим пользуется. Между сервером и экраном появляется несколько слоёв памяти: кеш HTML-ответа, кеш полезной нагрузки на сервере, кеш роутера на клиенте. У каждого свой срок жизни и свой способ инвалидации, и они не согласованы между собой.

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

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

Тупик: директива до тех пор, пока не соберётся

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

Не удалось. Директива действует вниз по графу импортов: один «use client» на корне делает клиентским весь граф под ним. Вы заплатили всё — второй рантайм, сериализацию пропсов, слои кеша, вторую ментальную модель рядом со старой — и не получили ничего: код не ушёл из бандла, данные не переехали на сервер. Архитектура та же, протокол дороже.

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

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

Что помогает: ставить границу по признаку интерактивности, а не по удобству правки. Начинать от листьев, а не от корня. Держать пары «провайдер — потребитель» по одну сторону. Ревьюить каждый проп через границу как API. И не тащить старый слой данных — эффекты с фетчами внутри клиентских компонентов, — иначе появятся два способа получить данные, и оба будут неправильными в разных местах.

Позиция редакции

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

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

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

С чем это связано

Метка над заголовком говорит, зачем туда идти: продолжить тему, перейти к практике или увидеть возражение.

На шкале времени жизни экрана величина — момент, когда становятся известны входные данные, — пробивает пороговую линию между сервером и клиентом: если данные изсервер/клиентвремя жизни экрана
Углубление

Где проходит граница клиент — сервер

Сортировка компонентов по типам заходит в тупик. Редакция предлагает другой критерий — момент, когда становятся известны входные данные — и разбирает один экран по шагам.

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

Размер бандла больше не главная метрика

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

Состояние бывает четырёх видов, и библиотека нужна только одному из нихвиды состояниянужна библиотека
Углубление

Состояние в React: когда нужна библиотека, а когда нет

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

Шесть ровных по высоте столбцов — скорость WebAssembly — идут выше пунктирной линии плана с самого первого столбца: коду не нужен разогревСкорость со старта
Углубление

WebAssembly: где он уже окупается

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

Письмо по вторникам

Один разбор недели и короткий список того, что изменилось. Без дайджестов на сорок ссылок.

Начните вводить — материалы появятся здесь.

выбрать · Enter открыть · Esc закрыть