196
3
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

Серверные компоненты в React

Время чтения 34 минуты
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
196
3
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

Привет! На связи Изрипов Юсуп, фронтенд-разработчик, прошел путь от фриланса до роли ведущего разработчика в таких крупных российских компаниях, как AliExpress и VK. Последние годы работаю в бигтех-компаниях, над продуктами, ежедневная аудитория которых составляет десятки миллионов пользователей. В этой статье мы подробно разберем, как серверные компоненты меняют подход к разработке современных приложений.

React 19.1 и Next.js 15.3.2 уже вышли, и React Server Components (серверные компоненты, они же RSC) объявлены стабильной частью React-экосистемы и фреймворка Next.js. Ниже я расскажу, что такое серверные компоненты, как они работают под капотом и как с ними жить разработчику. Поговорим об архитектуре RSC, загрузке данных и кешировании, интеграции с Next.js (новый app-роутинг, директивы use client, layout), рассмотрим ограничения и подводные камни. Конечно же, не обойдем вниманием практические примеры и тонкости, также затронем вопросы безопасности, тестирования и производительности. В конце сравним RSC с альтернативными подходами (Remix, Astro и др.).

Серверные компоненты в React

Зачем нужны серверные компоненты?

Еще недавно приложения, написанные на React, либо рендерились целиком на клиенте, либо частично на сервере (SSR) с последующей гидратацией на клиенте. Оба подхода неидеальны: чистый клиентский рендеринг (CSR) быстро перегружает браузер тяжелым JS, а SSR требует повторной гидратации всех интерактивных компонентов на клиенте, что тоже несет изрядные накладные расходы. React Server Components предлагают новое решение: вынести часть UI-логики и рендеринга на сервер, отправляя в браузер уже готовый HTML-контент вперемешку с интерактивными компонентами там, где это нужно. Проще говоря, мы можем писать React-компоненты, которые выполняются только на сервере, они могут напрямую лезть в базу данных или файлы, собирать HTML, и этот готовый UI стримится в браузер. Клиент же получит уже отрендеренный результат и загрузит лишь необходимый минимум JavaScript для интерактивных частей приложения.

В чем профит? Во-первых, естественно, улучшается производительность и время загрузки. Пользователь сразу получает содержимое страницы/компонента, без ожидания, пока прилетит весь JS-бандл и прогрузится состояние приложения. Нет этапа повторного рендеринга на клиенте для тех частей, что уже отрисованы на сервере. Во-вторых, уменьшается размер бандла, ведь код серверных компонентов вообще не попадает в клиентский JS. Например, если вы используете тяжелую библиотеку для рендеринга Markdown или графиков, ее можно подключить в серверном компоненте, тогда она выполнится на сервере и пользователю не придется тянуть сотни килобайт этой библиотеки в браузер. В-третьих, повышается безопасность: чувствительная логика (ключи API, секреты, доступ к БД) остается на сервере и не светится на фронте. Ну и бонусом мы избавляемся от лишней боли с написанием REST/GraphQL API для простых случаев. Звучит прям здорово, нет?

Кстати, идеи, заложенные в RSC, во многом навеяны опытом прошлых лет. Я бы сравнил это с «возвращением к истокам»: отрисовка HTML на сервере напоминает старый добрый (нелюбимый) PHP или шаблонизаторы Rails. Разница в том, что RSC сохраняют декларативность и модульность React, но позволяют нам решать на каждом компоненте, где его лучше рендерить, на сервере или клиенте. В итоге получаем лучшее из двух миров: и быстроту классических серверных рендеров, и интерактивность React там, где она нужна.

Архитектура серверных компонентов React

Серверные компоненты, грубо говоря, просто React-компоненты, которые исполняются не в браузере, а на сервере (или во время сборки). Когда React видит компонент без директивы 'use client' вверху файла, он трактует его как серверный и НЕ будет включать его код в бандл фронтенда.

По сути, в React 19+ все компоненты по умолчанию считаются серверными, если не указано обратное. Чтобы сделать компонент клиентским (то есть рендерить его на клиенте и включить в JS-бандл), нужно явно прописать в начале файла строку 'use client'. Это своего рода «разграничитель» между мирами сервера и клиента.

Архитектурно React теперь строит два графа модулей: один для серверных компонентов, другой для клиентских. Серверные компоненты могут импортировать другие серверные или клиентские компоненты, а вот наоборот нельзя. То есть деревья как бы вкладываются друг в друга: на вершине может быть серверный компонент, внутри которого где-то глубоко находится интерактивный кусочек клиентского компонента. Пример: страница профиля может рендериться на сервере, включая данные пользователя, но кнопка «Лайк» внутри нее интерактивная, ее мы реализуем как клиентский компонент. В таком случае React отрендерит на сервере всю страницу с HTML-разметкой и данными, вставит плейсхолдер вместо кнопки, а на клиент отправит и HTML страницы, и код кнопки. Браузер покажет контент сразу, а затем «гидрирует» кнопку, навесив на нее обработчики событий.

Можно представить это как «сплавленное» дерево из двух типов компонентов. HTML от серверных компонентов стримится в браузер прямо во время загрузки, по частям. Причем React умеет внедрять фрагменты HTML от сервера внутрь клиентского приложения в нужные места благодаря Suspense-границам и алгоритму слияния. Гидратация клиентских компонентов происходит параллельно потоковому получению серверного HTML.

Это значит, что пользователь может сразу увидеть и контент, и интерактивность не задерживается дольше нужного, как только соответствующий JS загружен, компоненты «оживают». При навигации или обновлении данных происходит похожая «магия», если часть UI нужно обновить, React может перерендерить серверный компонент на сервере и без перезагрузки отправить обновленный HTML-фрагмент на клиент, аккуратно вживляя его в текущее DOM-дерево без потери состояния остальных частей интерфейса. Клиентские компоненты при этом сохраняют свое состояние, пока серверные куски обновляются, впечатляет, правда?)

Важно понимать: серверные компоненты не заменяют собой обычные клиентские, они дополняют их. Конечно, мы по-прежнему будем писать компоненты с состоянием, эффектами, анимациями, всему этому место на клиенте. RSC не умеют, к примеру, работать с useEffect или useState, да это и не нужно на сервере, где нет пользовательских событий или динамического UI в реальном времени. Все подобные случаи требуют выносить логику в клиентский компонент (и вот тут появляется 'use client'). В идеале, как советуют в Vercel, приложение должно содержать здоровый баланс: серверные компоненты для тяжелого рендеринга и работы с данными, клиентские — для локальной интерактивности. Наша задача как разработчиков — решить, какую часть лучше выполнить на сервере, а какую оставить в браузере. Я, например, постепенно привык мыслить так: «Статичный контент или данные из БД? Это на сервер. Форма ввода, счетчик, анимация? Однозначно на клиент». Эта грань может быть тонкой, и иногда приходится пробовать разные подходы, профилировать, смотреть, как оно ведет себя на медленном устройстве. Но именно в этом и заключается новое преимущество: React наконец дает нам возможность выбора, где выполнять тот или иной кусок UI.

Немного под капотом

Хотя можно успешно пользоваться RSC, не вдаваясь в низкоуровневые детали, общий принцип стоит понять. Когда сборка (бандлер) обрабатывает ваш код, она разделяет его на две части. Код серверных компонентов обрабатывается особым образом, по сути, React превращает результат рендеринга серверного компонента в сериализованный JSON-поток или специальный формат (в комьюнити его прозвали React Flight). Этот поток содержит дерево UI и данные, которые нужны для восстановления компонентов на клиенте. Далее на клиенте React принимает этот поток и воссоздает из него компоненты в виртуальном DOM, при этом для серверных частей уже есть готовые элементы (их не нужно создавать заново, как при гидратации), а для вложенных клиентских компонентов вставляются своеобразные «заглушки» до тех пор, пока не загрузится их код. Всё это происходит автоматически, фреймворки типа Next.js скрывают от нас большинство сложностей. Мы лишь видим конечный эффект: контент появился сразу, а JS-код выполнился по минимуму.

Отмечу одну деталь: серверные компоненты могут быть асинхронными функциями (это нововведение React 18+). То есть вы спокойно пишете async function MyComponent() { ... } и внутри делаете await данных, например из базы или API. React будет ждать разрешения этих промисов во время рендера, и благодаря Suspense можно даже выводить запасной интерфейс, пока данные загружаются. Асинхронность «по всему стеку» упрощает жизнь: больше не нужно заранее получать все данные до рендера, компонент сам о себе позаботится. Правда, будьте внимательны: нельзя вызывать для серверных компонентов useEffect и ему подобные клиентские хуки, они попросту не поддерживаются (React вас ругнет, если попробуете). И естественно, никакой работы с document или window, этих глобальных объектов на сервере нет.

Интеграция с Next.js: app-роутер, layout, разделение на client/server

На практике большинству из нас серверные компоненты сейчас доступны через Next.js — этот фреймворк одним из первых внедрил поддержку RSC. Начиная с Next 13 (а к версии 15 это стало полностью стабильным) появилась новая система маршрутизации — App Router (папка app вместо привычной pages). В App Router все компоненты по умолчанию считаются серверными, а если нужен интерактивный/клиентский, мы отмечаем его 'use client'. Это кардинальное отличие от старого подхода, раньше мы писали getServerSideProps или getStaticProps на уровне страницы, чтобы добыть данные на сервере, а теперь мы просто пишем асинхронный компонент, и Next сам загрузит данные во время рендера этого компонента на сервере. Это ощущается очень естественно, данные запрашиваются там же, где и отображаются, нет лишних слоев абстракции. Помню, раньше приходилось городить отдельные файлы с данными или кастомные хуки, а теперь достаточно написать const data = await fetch(...) прямо в компоненте — и всё работает, страница получила данные, а код вызова даже не попал в бандл клиента.

Структура проекта с App Router поддерживает новые концепции. Например, в папке app можно создавать вложенные папки, каждая из которых может содержать файл page.jsx (или .tsx), это серверный компонент страницы, и файл layout.jsx для этой группы страниц. Layout сам по себе, как правило, серверный компонент, который оборачивает страницы. Благодаря этому Next.js умеет эффективно переиспользовать layout при навигации: при переходе на другую страницу с тем же layout сервер перерендерит только сменившуюся часть контента, а общий layout не пересылается заново. Это одна из фишек App Router — выделение стабильных частей UI (шапка, меню, общие обвязки) в серверные layouts для оптимизации. Клиент при переходах получает лишь дифф HTML для изменившейся части страницы, а остальное берется из кеша или остается нетронутым.

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


// app/components/LikeButton.jsx
'use client'// делает компонент клиентским
import { useState } from 'react';
 
export default function LikeButton() {
  const [likes, setLikes] = useState(0);
  return <button onClick={() => setLikes(likes + 1)}>{likes}</button>;
}

 

А вот страница, использующая этот компонент:


// app/page.jsx (серверный компонент страницы)
import LikeButton from './components/LikeButton';
 
export default async function Page() {
  const data = await getDataFromDB();  // например, запрос в базу
  return (
    <div>
      <h1>Привет, {data.userName}!</h1>
      <p>Баланс: {data.balance} монет.</p>
      <LikeButton />
    </div>
  );
}

 

Здесь Page серверный, он ждет данные (скажем, из базы данных или внешнего API) и выводит их сразу в HTML. Компонент LikeButton же клиентский, он интерактивный и работает уже в браузере (считаем клики). При рендеринге Next.js сделает так: на сервере выполнит Page, получит userName и balance, вставит их в HTML, вставит также placeholder для LikeButton. Браузер получит HTML с приветствием и балансом (которые сразу видны пользователю), плюс JS-бандл с кодом LikeButton. Как только JS загрузится, кнопка станет интерактивной и начнет считать клики. Получаем отличный UX, основные данные сразу на экране, и интерактивность появится почти мгновенно (задержка лишь на загрузку небольшого куска JS).

Важное правило: клиентские компоненты не могут содержать серверные (ведь тогда серверный код пришлось бы загружать на клиент, что нарушает идею). Поэтому вложенный в LikeButton компонент не может обращаться к БД напрямую, например. Но обратное вложение возможно, серверный компонент может рендерить внутри себя клиентский, мы это и сделали. Next.js следит за этими границами и выдаст ошибку, если вы вдруг попробуете импортнуть серверный модуль внутри клиентского.

Загрузка данных и кеширование

Data Fetching с приходом RSC претерпел изменения. В Next.js App Router вы больше не пишете getServerSideProps/getStaticProps. Вместо этого можно вызывать fetch() прямо внутри серверного компонента. Next.js под капотом переопределяет глобальный fetch, чтобы добавить ему умного поведения. По умолчанию (начиная с Next 15) все запросы fetch не кешируются автоматически. Ранее, в Next 13, было наоборот, fetch результаты кешировал и переиспользовал между запросами, пытаясь сделать всё статичным по умолчанию.

Раньше разработчики натыкались на множество багов кеширования — одни и те же данные не сразу сбрасывались после обновления, приходилось вручную чистить кеш или писать костыли с метками в URL, что приводило к хаосу в проде и спорным issue в репозиториях.

Команда изменила дефолт к Next 15, и теперь, чтобы кешировать ответ, надо явно указать опцию: fetch(url, { cache: 'force-cache' }) для статичных данных или fetch(url, { next: { revalidate: 60 } }) для условно-статичных (ISR, повторная валидация раз в N секунд). Если же данные точно динамические и должны приходить каждый раз свежие, можно указать cache: 'no-store', либо Next сам поймет, что запрос динамичный (например, если вы читаете заголовки запроса или куки, то такой fetch не станет кешировать).

Кроме того, Next.js предоставляет низкоуровневый API unstable_cache для кеширования любых асинхронных функций, не только запросов. Например, можно закешировать результат сложного DB-запроса: const getCachedUser = unstable_cache(async ()=>db.getUser(id), [id]), и Next будет хранить результат в памяти (или в распределенном кеше) и инвалидировать его по времени или тегам. Инвалидация реализована через функции revalidateTag и revalidatePath, можно пометить кеши тегами и сбрасывать их по событию (скажем, после записи новых данных вызвать revalidateTag('user'), чтобы все связанные кеши пользователей обновились).

Эти механизмы нужны для продвинутых случаев, когда вы хотите контролировать актуальность данных тонко. В большинстве же ситуаций вполне достаточно определиться: эта страница/компонент может быть статичным? Тогда используем revalidate (или вообще ничего, пусть генерится на билде и кешируется HTML). Или здесь всегда нужна самая свежая информация? Тогда либо no-store, либо серверные экшены (о них далее) для точечного обновления.

Отдельно стоит похвалить RSC за параллельность и отсутствие waterfall-эффекта. В классическом SSR у нас часто бывало так: сначала один запрос, потом второй зависит от первого и т. д., или разные компоненты последовательно делают fetch в useEffect, вызывая каскад запросов. RSC же позволяет запустить несколько загрузок данных одновременно во время серверного рендеринга. Если внутри разных компонентов вызывается fetch, Next.js выполнит их в параллель и даже сделает дедупликацию одинаковых запросов. То есть если два компонента хотят одни и те же данные, фактически запрос на сервер пойдет один, а результат поделится между ними. Плюс, поскольку это происходит на сервере, задержки на сетевую коммуникацию с внешними API или базой данных не так критичны, сервер, как правило, находится в том же дата-центре, что и БД, и может сходить за данными гораздо быстрее, чем если бы это делал браузер пользователя с другого конца света. Итог: меньше времени тратится впустую, страница рендерится быстрее, нет ситуации, где пользователь смотрит на пустую страницу, пока цепочка запросов выполняется один за другим.

Вспомним и о старых методах. В новых реалиях getServerSideProps, getStaticProps устарели (они работают только в папке pages, в App Router их нет вовсе). Теперь у нас более гранулярный и декларативный способ, каждый компонент сам решает, нужен ему fetch или нет. А вот что касается клиентского useEffect для запроса данных, в идеале его тоже больше не применять для загрузки первоначальных данных. Всё, что можно, стараемся получить на сервере, чтобы не было эффекта «данные догружаются после первого рендера». Пользователь должен увидеть уже готовую страницу. Я заметил, что с RSC мой код effect-хуков для данных почти свелся к нулю: если компонент можно рендерить на сервере, он сам там всё загрузит перед возвратом JSX, и никакой лишней мороки.

Пример. Допустим, у нас блог и мы хотим рендерить Markdown-статьи. Раньше мы бы либо 1) на клиенте загрузили Markdown-файл через useEffect, распарсили marked и вставили (что плохо, юзер увидит пустую страницу, потом текст появится, и тянем библиотеку marked в браузер), либо 2) на сервере (через getStaticProps) парсили Markdown и отдавали HTML как props. Второй вариант лучше, но громоздко, надо поддерживать отдельный слой подготовки данных. С RSC всё проще: мы пишем компонент ArticlePage серверным, внутри него делаем const content = await file.readFile(postPath), затем return <div dangerouslySetInnerHTML={{ __html: marked(content) }} />. Этот компонент выполнится на билд-сервере (если статический) или на сервере при запросе, сгенерирует HTML, и в бандл клиента ни marked, ни fs не попадут. Пользователь сразу получит готовую разметку статьи. Красота! И код при этом остается внутри компонента, рядом с разметкой, легче поддерживать, чем разрозненные файлы.

Server Actions: действия на сервере без API

Отдельно хочется рассказать о Server Actions — новинке, пришедшей вместе с RSC. Если серверные компоненты — это про чтение данных и рендеринг, то серверные экшены — про запись данных и обработку действий пользователя. Это функции, которые мы определяем на сервере и можем напрямую вызвать из клиентского компонента, минуя ручное создание API-эндпоинтов. По сути, React реализует концепцию RPC (Remote Procedure Call), вы можете передать функцию с сервера на клиент в виде коллбэка, и при вызове она выполнится на сервере.

Как это выглядит? Допустим, у нас есть форма обратной связи. В Next.js 13+ мы можем в файле app/contact/actions.js описать что-то вроде:


'use server';
export async function handleContactSubmission(formData) {
  // валидация и сохранение данных на сервере
  const { name, email, message } = formData;
  if (!email.includes('@')) {
    return { success: false, error: 'Некорректный email' };
  }
  await db.saveMessage({ name, email, message});
  return { success: true };
}

 

Обратите внимание на директиву 'use server‘, она нужна, чтобы явно указать, что функция должна остаться на сервере (Next сам позаботится, чтобы не включать ее в клиентский бандл, а вместо нее передаст некий «идентификатор» для вызова). Теперь в компоненте страницы мы можем сделать так:


// app/contact/page.jsx серверный компонент страницы
import { handleContactSubmission} from './actions';
 
export default function ContactPage() {
  return <ContactForm onSubmit={handleContactSubmission} />;
}

 

А вот ContactForm будет клиентским:


// app/contact/ContactForm.jsx
'use client';
export default function ContactForm({ onSubmit }) {
  const [status, setStatus] = useState(null);
 
  const handleSubmit = async (e) => {
    e.preventDefault();
    const formData = {
      name: e.target.name.value,
      email: e.target.email.value,
      message: e.target.message.value
    };
    const result = await onSubmit(formData); // вызываем серверный экшен
    if (result.success) {
      setStatus('OK');
    } else {
      setStatus(result.error);
    }
  };
 
  // JSX с полями и кнопкой:
  return <form onSubmit={handleSubmit}>...<button type="submit">Send</button></form>;
}

 

Когда пользователь нажимает Send, вызывается onSubmit(formData). React перехватывает этот вызов, сериализует formData и отправляет на сервер (в Next.js это специальный автоматически сгенерированный эндпоинт). На сервере выполняется handleContactSubmission, возвращает результат, React пересылает результат обратно клиентскому компоненту, и код продолжается после await onSubmit(...) как обычный промис. В итоге мы сделали форму с обработкой на сервере, не написав ни строчки явного API маршрута! Это очень похоже на подход фреймворка Remix с его Actions для форм либо на старые добрые form submit в обычных сайтах, но интегрировано прямо в React.

Конечно, под капотом Next.js создает эндпоинты для таких вызовов. Начиная с версии 15 для безопасности эти эндпоинты генерируются с непредсказуемыми именами (чтобы никто не смог их вызвать без вашего приложения) и автоматически удаляются, если соответствующий бандл клиентского компонента больше их не использует. Также Next.js следит за тем, чтобы вы не утянули в formData или аргументы экшена ничего несериализуемого (например, файл или ссылку на DOM). В моем опыте работа с Server Actions оказалась приятной, код получается линейным, никаких fetch('/api/...'), обработка ошибок сервером, а на клиенте просто получаем объект результата.

Нужно отметить ограничения: Server Actions выполняются на сервере, поэтому чрезмерное их использование может увеличить нагрузку на ваш бэкенд. Например, если вы каждое нажатие кнопки отправляете на сервер, подумайте о дебаунсе или локальных оптимистичных обновлениях. Также у экшенов есть latency, задержка на сетевой запрос. В локальной разработке почти незаметно, но в продакшене учитывайте, что отклик от сервера займет десятки, может, сотни миллисекунд, поэтому в UI хорошо бы показывать спиннер или какое-то состояние загрузки (как мы в примере делали setStatus('OK' или ошибка) после await). Next.js и React помогают сократить эту задержку: например, можно обновить UI сразу после вызова экшена, даже до того, как пришел ответ, используя оптимистичные обновления и мутируя кеш. Server Actions способны не только вернуть данные, но и обновить кеш RSC и триггернуть пересборку серверных компонентов в одном запросе. Это значит, что, скажем, при добавлении нового комментария через экшен вы можете сразу вставить его в локальный список (оптимистично), а когда сервер подтвердит, React автоматически обновит (перерендерит) серверный компонент списка комментариев с учетом нового элемента. Всё произойдет без перезагрузки страницы и без лишних запросов, одним пакетом. Такие вещи раньше нужно было делать через довольно громоздкие настройки state менеджера, а теперь доступно из коробки.

TL;DR: Server Actions делают взаимодействие с сервером простым и декларативным. Формы, кнопки типа «Удалить товар из корзины», любые мутации данных теперь можно выполнять, просто вызывая функцию, определенную на сервере. Это ощутимо сокращает код (минус болванки API и лишние слои), улучшает целостность (код обработки рядом с UI, меньше вероятность расхождений) и даже повышает доступность — формы работают даже без JS, т. к. Next.js подменяет действие формы на вызов экшена, но если JS отключен, произойдет стандартный HTTP submit на тот же эндпоинт. В итоге, как не раз отмечали разработчики, React всё больше становится полноценным фулстек-фреймворком, отнимая часть задач у бэкенда.

Ограничения и подводные камни

Разумеется, не всё так безоблачно, серебряной пулей RSC не являются. Перечислим некоторые ограничения и тонкости, с которыми я сам столкнулся или которые официально задокументированы.

Запрет на побочные эффекты и браузерные API в серверных компонентах. Как уже говорилось, на сервере нет DOM, нельзя использовать useEffect, useLayoutEffect, напрямую манипулировать элементами или обращаться к window. Серверный компонент должен быть чистой функцией по своим входам/выходам: получил props и/или данные — отдал JSX. Любой побочный эффект (логирование, отправка аналитики) лучше либо выносить в async-вызовы вне компонента, либо делать на клиенте. React заставляет нас следовать этому, что, впрочем, хорошо: серверные компоненты остаются простыми и детерминированными.

Сериализация данных. Когда серверный компонент передает что-то клиентскому, это «что-то» должно быть сериализуемо в JSON (или близкий формат). Например, вы не можете прокинуть из сервера в клиент функцию (кроме специальных помеченных Server Actions) или класс с методами, только простые объекты, массивы, примитивы и React-элементы. Если нарушить это правило, вы получите ошибку во время рендера. Практически это значит, что не стоит класть в props клиентского компонента сложные нестандартные объекты. Обычно передаются данные, строки, числа, может быть, Date (конвертируется в строку), простые Plain Old JavaScript Objects. В моем опыте, если нужно было что-то сложнее, я просто переношу логику обработки на сервер, лучше подготовить данные заранее и передать уже готовый результат.

Нельзя импортировать серверные компоненты в клиентских. Повторим это ограничение, так как оно частый источник ошибок. Если забыть и попытаться в файле с 'use client' сделать import MyServerComponent from './MyServerComponent', при сборке получите ошибку вида Cannot Import Server Component into a Client Component. Решение: либо вынести тот функционал на сервер выше по дереву (т. е. сделать родитель серверным, который обернет клиентский), либо, если нужно разделить логику, возможно, дублировать часть, к сожалению, иногда это необходимо. Этот момент слегка ухудшает DX, приходится думать о направлении зависимостей. Правило: зависимости всегда текут от серверных компонентов к клиентским, но не наоборот.

Поведение контекста (Context API). С контекстом ситуация такая: контекст, созданный в серверном компоненте, будет доступен всем вложенным и серверным, и клиентским компонентам (React умеет передавать контекст через границы RSC -> Client). Однако сам объект контекста тоже должен быть сериализуемым. Если в Provider вы положите, например, функцию, то в клиенте, скорее всего, эта функция будет undefined или вызовет ошибку. Решением является хранить в контексте только состояние (данные), а функции для изменения этого состояния, если надо, либо реализовать через Server Actions, либо в клиентском куске. Также учтите, что обновление контекста на клиенте не повлияет на серверные компоненты (они уже отрендерены на сервере). То есть общий flow такой: контекст, заданный на сервере, — это как бы «статические» данные для рендера. Если клиент изменил контекст, это локально изменит клиентские потребители, но серверные всё равно остались в прошлом HTML, их можно обновить, только перестримив с сервера заново. Это всё логично, но требует понимания при проектировании.

Отсутствие continuous updates. Из документации Next.js мы знаем, что RSC не поддерживают постоянный поток обновлений типа WebSocket соединений. Поясню: нельзя сделать серверный компонент, который сам по себе раз в пять секунд автоматически перерендеривается с новыми данными (ну технически можно встроить механизм на сервере push-уведомлений, но React из коробки это не предоставляет). Если нужен live update в реальном времени, придется либо использовать клиентские подписки (например, useEffect с WebSocket в клиентском компоненте, обновляющим state), либо периодический опрос с setInterval (тоже на клиенте). Серверные компоненты хорошо работают для запросов по требованию (пользователь зашел на страницу или совершил действие), но не для фоновых постоянных обновлений UI.

Увеличение нагрузки на сервер. Это уже аспект инфраструктурный, если раньше ваше React-приложение могло отдать статичный HTML и всю логику выполнить на клиенте, нагружая компьютеры пользователей, то с RSC мы переносим вычислительную работу на сервер. Серверный рендеринг каждого запроса или частые вызовы Server Actions потребуют ресурсов CPU и памяти на вашем сервере (или функции в облаке). Нужно следить за производительностью бэкенда. В Next 13 App Router по умолчанию строил всё статически (чтобы не нагружать), но в динамичных приложениях это не всегда возможно. Мой совет: используйте профилирование, метрики. Например, Next.js логирует время рендеринга серверных компонентов. Если видите, что страница генерируется на сервере 500 мс, а могла бы 50 мс, возможно, стоит оптимизировать запросы или часть логики перенести на клиент, если это не критично. Также не забывайте про CDN-кеширование, где уместно.

Латентность и опыт пользователя. Хотя RSC ускоряют первый рендер, они могут замедлить интерактивность, если переборщить. Например, если сделать через Server Actions обработку каждого нажатия на слайдер, пользователю придется ждать ответа сервера на каждый шаг, интерфейс будет дергаться и лагать. Такие вещи должны оставаться на клиенте. Правило: всё, что требует мгновенного отклика (анимации, drag-n-drop, поля ввода с мгновенной валидацией), делаем на клиенте. Сервер для более грубых операций: отправить форму, получить порцию данных и т. п.

Отладка и логирование. Появляется интересный нюанс, ваш код рассредоточен между сервером и клиентом, и ошибки могут случаться в разных средах. Ошибка в серверном компоненте часто проявится как страница с сообщением об ошибке (или специально отрендеренный компонент ошибки, если вы используете boundary), а в консоли браузера ничего не будет. Нужно смотреть логи сервера (терминал, где запущен Next). Примечание: с 15-й версии Next.js на клиент уходят логи, по крайней мере в режиме разработки. Они бывают помечены лейблом Server. Для экшенов аналогично: исключение, брошенное внутри handleContactSubmission, не попадет напрямую в catch на клиенте (клиент получит что-то вроде Server Error), а детали будут на сервере. Поэтому для комплексных взаимодействий настройте хорошее логирование на бэкенде. В Next 15, кстати, появились улучшения для логирования и инструментирования, можно отслеживать выполнение серверных компонентов, кеш и т. д. Это поможет в отладке. И конечно, пишите юнит-тесты (о них далее).

Совместимость библиотек. Не все React-библиотеки пока дружат с RSC. Если библиотека ожидает, что будет работать только в браузере (например, напрямую лезет в document при импортировании), то при попытке использовать ее в серверном компоненте вы получите ошибку еще на этапе сборки или выполнения на ноде. Пришлось столкнуться с этим, когда пробовал использовать одну старую библиотеку графиков, ее нельзя было заимпортить на сервер, и Next ругался. Пришлось либо найти аналог, либо (что я и сделал) рендерить график на клиенте целиком. По прошествии времени всё больше и больше библиотек обновляются и работают с RSC, но обязательно тестируйте сторонние компоненты, не используете ли вы их в неправильном окружении. Next.js частично помогает, в 15-й версии middleware добавляет условие react-server для пакетов, чтобы случайно не подключить на сервере что-то запрещенное.

Старые браузеры и окружения. Хотя на клиент отправляется меньше кода, тот JS, который отправляется (React runtime, hydration-логика), стал сложнее. Он должен уметь принимать стримы, склеивать их, работать с промисами. В целом React 18+ отрезал поддержку очень старых браузеров, так что большинство пользователей в порядке. Но всё же тестируйте ваше приложение в разных браузерах, как минимум убедитесь, что без JavaScript контент отображается (ведь HTML-то приходит) и что на медленных девайсах всё ок.

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

Лучшие практики и тонкости использования

Поделюсь накопившимся у меня опытом при работе с серверными компонентами и Next.js App Router:

  • Минимизируйте клиентский JavaScript. Используйте серверные компоненты везде, где не требуется интерактивность на клиенте. Это главный принцип RSC, чем больше логики и рендеринга уйдет на сервер, тем меньше нагрузка на браузер. Проверено, приложение ощущается заметно быстрее, когда размер бандла сокращается, а большая часть работы сделана до отправки в браузер.
  • Делите интерфейс по ответственности. Планируйте компоненты так, чтобы данные загружались как можно ближе к месту использования. RSC позволяют не тащить все данные на уровень страницы, можно, например, внутри компонента списка комментариев загрузить комментарии (серверный компонент CommentsList сам сделает fetch comments и отдаст JSX списка). Это помогает не перегружать страницу лишними данными (избавляемся от overfetching). Загружайте только то, что нужно именно этому компоненту.
  • Избегайте дублирования запросов. Если одни и те же данные нужны в нескольких местах, подумайте о поднятии запроса выше или использовании общего кеша. Next дедуплицирует fetch, но только в пределах одного рендера. Если у вас разные серверные компоненты на разных страницах тянут одно и то же из БД, имеет смысл вынести этот вызов либо в общий родитель (например, layout) с передачей вниз, либо воспользоваться unstable_cache с единым ключом, чтобы повторно использовать результат. В идеале, конечно, архитектура должна устранять повторные запросы.
  • Организуйте серверные экшены. Если у вас много логики в Server Actions (например, куча форм или операций), структурируйте их. Можно складывать экшены по файлам, группировать по функциональности. Имейте в виду, экшены сериализуются и отправляются клиенту, поэтому не стоит определять их внутри компонентов, лучше отдельно экспортировать, как мы делали в примере. Так они не создают замыкания на весь компонент (что может тянуть лишние зависимости).
  • Обрабатывайте ошибки. Серверные компоненты могут упасть при рендеринге (например, БД не ответила). Используйте механизмы Error Boundary (в Next.js можно создать error.jsx в папке маршрута, который будет показываться при ошибках в этой странице). Для экшенов обязательно оборачивайте вызовы в try/catch на клиенте, чтобы пользователь видел сообщение, если что-то пошло не так. И обязательно логируйте ошибки на сервере, иначе будет трудно понять, почему у пользователя что-то не работает.
  • Тестируйте в разных условиях. Уже писал об этом, но повторюсь, гоняйте ваше приложение в эмуляции медленного соединения, на слабых устройствах. RSC должны значительно помочь производительности, но важно убедиться, что вы действительно добиваетесь цели. Возможно, придется перестроить компонент или добавить Suspense с fallback-спиннером, если видите задержки. Иногда лучше чуть задержать часть UI, но не блокировать всю страницу. Suspense + стриминг дают возможность рендерить скелет экрана сразу, а детали догружать по мере готовности.
  • Юнит-тесты и интеграционное тестирование. Серверные компоненты, будучи чистыми функциями, довольно удобно тестировать. Вы можете вызывать их, имитируя данные, и проверять, что они возвращают нужный JSX (например, используя renderToString в тесте или React Testing Library на Node-окружении). Если компонент асинхронный, в Jest просто вызываете его в await act(async () => { result = await render(<MyServerComp props.../>); }). Но есть нюанс: популярные библиотеки тестирования пока не полностью поддерживают RSC прямо из коробки. Возможно, придется использовать экспериментальные утилиты или подход с snapshot-тестами рендеринга на сервере. Для экшенов можно писать тесты как для обычных функций (передавая им имитацию formData и проверяя побочные эффекты, например вызов mock-репозитория). Интеграционные тесты (Cypress/Playwright) тоже важны, они поймают проблемы на стыке клиента и сервера. В общем, не ленитесь тестировать, особенно логику экшенов и критичные серверные компоненты, потом будет легче делать дебаг.
  • Безопасность прежде всего. Хотя RSC и скрывают большую часть логики на сервере, нельзя терять бдительность. Проверяйте входные данные в экшенах (никто не мешает недоброжелателю отправить запрос прямо на ваш скрытый эндпоинт, угадать его сложно, но лучше считать, что он может). Пользовательский ввод валидируйте на сервере, как и раньше. Следите, какие данные вы выдаете в виде props клиентским компонентам, не прокиньте лишнего. И помните, что XSS всё так же возможен, если вы вставите непроверенный HTML. React на сервере по умолчанию экранирует всё, что вставляется как текст, но если используете dangerouslySetInnerHTML, то санитайзером пренебрегать нельзя, даже на сервере.
  • Миграция существующего кода. Если у вас большой проект на Next 12 / Pages Router и вы решили переехать на App Router и RSC, делайте это постепенно. Можно мигрировать страницу за страницей. Next позволяет иметь одновременно папки pages и app (но только одна из них должна обрабатывать конкретный роут). Учтите, что общие компоненты, скорее всего, придется проверить, не требуется ли где-то добавить 'use client'. И обязательно прогоните все тесты и кейсы, т. к. поведение может поменяться (особенно если у вас были кастомные _app или _document, их аналогов в App Router нет, многое делается иначе через провайдеры в layout). Документация Next содержит гид по миграции, следуйте ему и не стесняйтесь задавать вопросы в сообществах, многие уже прошли через это и помогут советом.

Быстрый взгляд на альтернативы (Remix, Astro и др.)

React Server Components не единственный подход к проблеме оптимизации рендеринга и уменьшения нагрузки на клиент. Будет полезно знать, чем они отличаются от некоторых альтернативных решений, появившихся ранее.

Remix — фреймворк, построенный на идее «форм, действий и загрузчиков». По сути, Remix изначально предложил разработчикам писать логику загрузки данных на сервере (через функции loader) и обработки форм (через функции action) в рамках каждого маршрута. Звучит знакомо? Да, это очень похоже на связку RSC + Server Actions, только реализовано на уровне фреймворка. Отличие в том, что Remix по-прежнему использует стандартный SSR, все данные из loader вставляются в HTML, затем гидратируются на клиенте. React Server Components позволяют спуститься глубже, до уровня отдельных компонентов. Зато у Remix есть сильная сторона — потоковая выдача данных формы сразу при отправке (используя <Form reloadDocument&gt;), что обеспечивает прогрессивный enhancement. Сейчас Next.js с экшенами стремится к тому же, убирая разницу между SPA и традиционными формами. Можно сказать, RSC и Server Actions «догнали» Remix, сделав аналогичные возможности встроенными в React. Если вы работали с Remix, вам будет концептуально проще понять новые подходы в React, хотя детали реализации и разные.

Astro — это инструмент статической генерации сайтов, известный своим «островным» подходом. В Astro вы пишете страницы, которые по умолчанию генерируются на сервере в чистый HTML, а отдельные интерактивные виджеты подключаются как островки (islands) с собственным небольшим бандлом JS. В некотором смысле Astro предвосхитил RSC-подход, он тоже предполагает, что бОльшая часть вашего контента статична и рендерится на сервере, а клиент получает минимум JS для интерактивности. Разница в том, что Astro не привязан к React (можно использовать любые фреймворки для островков, или Astro-компоненты) и у Astro нет единого концепта «приложения», это скорее сборщик страниц. React Server Components же дают похожую оптимизацию (всё, что может, рендерится сервером, а клиентские части по необходимости) прямо внутри React-приложения, бесшовно для разработчика. Можно шутливо сказать, что React подсмотрел у Astro и внедрил острова непосредственно в себя, теперь каждый серверный компонент как остров HTML, который не требует гидратации, а каждый клиентский — интерактивный кусочек, загружающийся по надобности. Если цель — минимизировать JS на клиенте, то и Astro, и RSC справляются, просто разными путями. При этом RSC сохраняют цельность SPA-приложения, тогда как Astro больше про MPA с вкраплением SPA-фрагментов.

Есть и другие, например Qwik с концепцией возобновляемости (Resumability) или Marco от eBay, но они менее распространены. Важно отметить: React Server Components выгодно отличаются тем, что это часть самого React. Вам не нужно переходить на сторонний фреймворк, достаточно обновиться до React 18/19 и воспользоваться поддержкой в Next.js или другом метафреймворке. Это снижает порог входа, большой React-экосистеме проще принять нововведение, чем переписать приложения на другой инструмент. Так что, хотя альтернативы интересны и у каждого свои плюсы, RSC, на мой взгляд, имеют большое будущее благодаря интеграции в наиболее популярную библиотеку UI.

Заключение

Серверные компоненты в React знаменуют собой значимый поворот в разработке веб-приложений. Признаться, сначала было непривычно, казалось, что React рушит стену между фронтендом и бэкендом, возвращая нас к более монолитному подходу. Но на практике выходит, что мы получаем более быстрые и легкие для пользователя приложения, а разработка остается приятной и компонентно-ориентированной. Мне нравится мыслить о RSC как об оптимизации по умолчанию: раньше надо было прилагать усилия, чтобы улучшить производительность (писать ручной код разделения, lazy лоады, SSR-специфичный код), теперь же фреймворк изначально делает приложение эффективным, если следовать его парадигме.

Конечно, переход к новой архитектуре требует времени и экспериментов. Не всё сразу будет гладко, где-то встретите баг инструментов, где-то придется переучиваться. Но, как по мне, результаты того стоят. По моему опыту, даже частичное внедрение серверных компонентов, например рендер главной страницы на сервере с готовыми данными, уже ощутимо ускоряет загрузку и индексирование. Пользователи видят контент быстрее, SEO улучшается, да и разработчикам меньше головной боли с состоянием. Next.js к 15-й версии отполировал большинство шероховатостей, RSC и экшены теперь стабильны, работают из коробки. Команда React тоже не стоит на месте, вероятно, вскоре мы увидим еще больше улучшений, например упрощение тестирования, расширение возможностей экшенов и т. д.

Лично мне серверные компоненты напомнили, почему я полюбил React когда-то: за возможность декларативно описывать UI как функцию от состояния. Теперь эта функция может выполняться не только в браузере, но и где угодно: на сервере, на edge-функции CDN, даже на этапе сборки. React стал универсальным инструментом, охватывающим и клиент, и сервер. Для разработчика уровня middle и выше это отличная возможность прокачаться, нужно понимать и фронт, и бэк хоть немного, думать о производительности комплексно. Зато и эффект от вашей работы будет более осязаемым.

Попробуйте и вы «впустить» серверные компоненты в свой проект. Начните с малого, переведите одну несильно интерактивную страницу на App Router, пометьте парочку компонентов как 'use client', остальные пусть будут серверными. Понаблюдайте за размером бандла, за временем TTFB (Time to First Byte) и TTI (Time to Interactive), скорее всего, увидите улучшения. А дальше, убедившись в выгоде, постепенно распространяйте подход на всё приложение.

Современный веб стремится быть быстрее и дружелюбнее, и React Server Components — мощный инструмент, чтобы этого добиться. Нам, разработчикам, надо только научиться правильно им пользоваться. Надеюсь, эта статья помогла разобраться в концепциях и вдохновила попробовать RSC в деле. Удачной разработки!

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники