Откуда берётся вопрос
Любой экран заставляет выбирать. Шапка с именем пользователя — на сервере, если кнопка «Выйти» в ней должна реагировать на клик? Таблица — на сервере, но заголовки колонок хотят сортировки. Поле поиска — на клиенте, но начальный запрос приходит из URL. Каждый такой вопрос выглядит как вопрос про конкретный компонент, и потому на него отвечают точечно. А точечные ответы не складываются в систему: сегодня вы унесли кнопку на клиент, завтра данные для неё, послезавтра удивляетесь, почему страница мигает скелетонами.
Наш тезис: вопрос «клиент или сервер» — вопрос про место, но отвечают на него не местом, а временем — моментом, когда становятся известны данные. Место тогда определяется само. Отдельно мы уже писали, каково это — жить с серверными компонентами в долгом проекте; здесь — только о том, где проходит сама линия.
Тупик: сортировка компонентов по типам
Первый ход почти у каждого, кто столкнулся с выбором, — классификация. Интерактивное — клиент, статичное — сервер. Есть обработчики, хуки, браузерные API — в клиентскую кучу; тексты и картинки — в серверную. Иногда сортируют грубее, по роутам: «эта страница серверная, та клиентская».
Не помогает. Интерактивность — свойство поведения, а не компонента: одна и та же таблица здесь просто вывод, а там — с фильтрацией. Классификацию приходится перерисовывать с каждой фичей, и каждый такой перерисовка — рефакторинг.
Главная беда в другом. Классификация отвечает на вопрос «где выполняется код», а первичный вопрос — «откуда приходят данные». Карточка, выглядящая полностью статичной, но подтягивающая свои данные в эффекте, попадает в клиентскую кучу и даёт мигание скелетона там, где сервер отдал бы её готовой. И наоборот: форма, обвешанная обработчиками, нуждается в серверных данных — начальных значениях, списках вариантов. Бинарный признак «интерактивное/статичное» не покрывает серую зону, а серая зона — это большая часть интерфейса.
Деление по роутам ломается ещё проще: на экранах, где смешано и то и другое. То есть почти на всех реальных.
Классификация не вредна — она лишь следствие, а не причина. Рисовать её можно только после того, как граница проведена чем-то другим. Чем — дальше.
Ось времени: когда известны входные данные
Для любого куска интерфейса есть вопрос полезнее «где»: когда становятся известны его входные данные. Моменты, от раннего к позднему:
- на сборке, до деплоя — тексты, лендинги, документация;
- на запросе, когда пользователь просит страницу, — персональные данные, свежий список, права доступа;
- только после действия пользователя — текст фильтра, открытые панели, недописанная форма, ховер.
Правило отсюда следует из простой арифметики: рендерить в самый ранний момент, к которому входные данные уже известны. Известны на сборке — пререндер. Известны на запросе — рендер на сервере. Появляются только после действия — в браузере. Машина определяется моментом, а не наоборот.
Два уточнения к этой лестнице. Первое: «известны на запросе» не значит «уникальны для каждого» — между сборкой и запросом есть общедоступный кэш: контент, который считается на сервере, но живёт до инвалидации. Вопрос там не «на какой машине», а «как часто ему разрешено протухнуть». Второе: между запросом и действием есть ступень — запрос по требованию. Пользователь acted, но вычисление живёт на сервере: подсказки поиска, пагинация, выгрузки. Выбор между «спросить сервер» и «посчитать на месте» на этой ступени решается бюджетом задержки — о нём ниже.
И обратное направление: если взаимодействие обязано ощущаться мгновенным, его обратная связь рендерится локально, даже когда источник истины живёт на сервере. Оптимистичное обновление — ровно про это: состояние живёт на клиенте, истина — в базе, и связаны они не границей, а синхронизацией. Именно из смешения «где живёт состояние» и «где живёт истина» и растут обычные споры про «клиент или сервер».
Практика: один экран по шагам
Теперь руками. Возьмите один реальный экран и пройдите его так.
-
Инвентаризация состояний. Без кода, просто список: всё, что меняется после первой отрисовки. Для каждой строки — триггер: навигация, локальный ввод, ответ сервера, таймер. Карандашная работа, один проход. Большинство строк окажется очевидными — и это нормально: ценность в паре неочевидных.
-
Момент известности данных. Для каждого рендерящегося куска: когда известны его входные данные? Сборка — пререндер. Запрос — серверный рендер. Только после действия — клиентский остров. Всем, что управляется URL, хорошо на сервере: переход по ссылке — это всё равно запрос. (С клиентским роутингом URL становится локальным состоянием — не забудьте его синхронизировать, иначе кнопка «назад» вас удивит.)
-
Бюджет задержки. Для каждого взаимодействия — цепочка последовательных ходов до первой обратной связи. Умножьте на ваше время оборота, сравните с терпимостью, которую вы можете позволить себе именно для этого действия. Числа ваши; метод такой: если бюджет не сходится, состояние либо уезжает в локальное, либо обратная связь становится оптимистичной. «Добавить в корзину» — хрестоматийный случай: клик обязан откликнуться сразу, истина — в базе. Клиент показывает новое состояние немедленно и синхронизируется в фоне.
-
Резать дерево по листьям. Клиентский остров — минимальное поддерево, покрывающее локальное состояние и разметку, которая его читает; всё снаружи остаётся на сервере. Форма — та самая серая зона: начальные значения и списки вариантов известны на запросе, рендерим на сервере; ввод, валидация, фокус — после действия, остров. Граница проходит не «форма или не форма», а внутри формы. Две типовые ошибки: большое вместилище уносят на клиент ради одного обработчика — двигайте обработчик вниз, а не вместилище вверх; и внутри острова лежит разметка, не читающая ничего локального, — пропускайте её внутрь как уже отрендеренный серверный контент. Самая дорогая ошибка — провайдер в корне дерева: клиентский контекст, нужный всему приложению, утаскивает в клиентский бандл всё. Держите провайдеры внутри минимального поддерева, которому они действительно нужны.
-
Проверить, что пересекает линию. Через границу клиент — сервер переходит только сериализуемое: функции не переезжают (механизм вызова серверного кода — отдельная история и не лазейка для тащащих замыканий), экземпляры классов не переезжают, живые соединения не переезжают. Если через границу течёт свалка — половина стора, колбэки, свежее соединение, — граница стоит не там случайно, а просто не там. Двигайте её. Граница — это API, а узкий API переживает рефакторинги.
-
Повторить при изменении продукта. Граница двигается: статичная таблица стала сортируемой — заголовки колонок стали островом. В ревью имеет смысл защищать не старую границу, а рассуждение, по которому она проведена.
Чего серверный рендер не решает
Оговорка, без которой всё написанное легко прочесть неправильно. Если ваш механизм — классический серверный рендер с полной гидратацией, перенос разметки на сервер улучшает первую отрисовку, но не убирает из бандла ни строчки кода: дерево всё равно повторно исполняется в браузере, просто стартует с готового HTML. Реально режут полезную нагрузку только острова, частичная гидратация и концепция серверных компонентов: то, что осталось на сервере, в браузер не приезжает вовсе.
Практическая проверка: перенесли кусок на сервер — бандл для браузера стал меньше? Если нет, вы изменили отрисовку, а не вес. Так что внутри вопроса «что рендерить на сервере» прячутся два разных вопроса: стратегия первой отрисовки и граница кода. При полной гидратации вы выбираете только первый. Знайте, какой у вас механизм, прежде чем проводить границу.
Позиция редакции — и когда она неверна
Наша позиция: граница проводится временем, а не машиной. Сначала спросите, когда становятся известны входные данные этого куска, — и рендерите в самый ранний момент, к которому они уже известны. Фреймворк, директива в верхней строчке файла, схема деплоя — это реализация, она следует из ответа, а не наоборот.
Условие, при котором мы неправы. Временное правило работает, пока результат рендера сопоставим по весу со своими входными данными. Если результат тяжелее в разы — длинные списки, огромные таблицы, тяжёлая поточтенная вёрстка — может оказаться дешевле отдать компактные данные и пересобрать разметку на клиенте, хотя данные и были известны на запросе. То же рассуждение, кстати, про процессор: серверный делится между всеми пользователями сразу, клиентский принадлежит одному. Если рендер дорог и индивидуален, свалить его на клиента может оказаться милосерднее к архитектуре. Уточнённое правило такое: по умолчанию решает время, но вес и общая стоимость могут пересилить — и узнаётся пересиливание по той же арифметике, а не по вкусу.
Где схема не применяется в полном виде: когда собственного сервера нет вовсе. Статический хостинг схлопывает ступень «запрос» — выбор остаётся между пререндером с периодическими пересборками и fetching на клиенте; функции на краю сети возвращают среднюю ступень, просто ближе к пользователю. Лестница укорачивается, логика не меняется.
И последнее, чтобы не читалось как догма. Небольшую внутреннюю панель для пары известных пользователей мы бы рендерили почти целиком на клиенте и не мучились: первая загрузка амортизируется неделей работы, а текучесть важнее отрисовки. Модель не ломается — меняются веса. Главный навык здесь, кажется, не «всё на сервере», а граница, которую можете объяснить.