Серверные компоненты React – это большой кусок работы. Недавно мы переосмыслили нашу документацию и устроили ребрендинг Mux. Пока мы этим занимались, мы перенесли весь материал сайтов mux.com и docs.mux.com на серверные компоненты. Так что, поверьте мне… я знаю. Знаю, что это возможно, не так страшно и, в принципе, что дело того стоит.

Давайте я вам объясню, почему, ответив на следующие вопросы: почему так важны серверные компоненты, а также для чего они хорошиДля чего они не так хорошиКак их использоватькак их постепенно внедрять и какие продвинутые паттерны следует использовать, чтобы всем этим управлять? Дочитав эту статью, вы станете замечательно представлять, следует ли вам использовать серверные компоненты React, а если следует – то как использовать их эффективно.

Как мы к этому пришли?

Есть отличный способ осмыслить серверные компоненты React – понять, какую именно проблему они решают. Давайте с этого и начнём.

Давным-давно, в седой древности, сайты генерировались на серверах при помощи таких технологий как PHP. Так было очень удобно выбирать с сервера данные с использованием секретов. На больших компьютерах выполнялась сложная работа, при которой активно нагружался ЦП, а на клиент приходила красивая и лёгкая HTML-страница, персонализированная под интересы пользователя.

Клиент запрашивает веб-страницу, а сервер в ответ отображает её и отправляет на клиент в формате HTML. Красиво и просто.
Клиент запрашивает веб-страницу, а сервер в ответ отображает её и отправляет на клиент в формате HTML. Красиво и просто.

 

А потом мы стали задумываться: как можно добиться более быстрого отклика и усилить интерактивность? В самом ли деле при каждом действии, которое пользователь совершает на странице, мы хотим отправлять на сервер порцию куки и заставлять сервер сгенерировать совершенно новую страницу? Что, если делегировать эту работу клиенту? Весь рендеринговый код можно просто написать на JavaScript и в таком виде отправить на клиент!

Такую технологию назвали «рендеринг на стороне клиента» (CSR), а впоследствии появился термин «одностраничные приложения» (SPA), и этот шаг был отрицательно оценен в широких кругах. Конечно, такая технология проста, и это дорогого стоит! На самом деле, в течение долгого времени команда React рекомендовала по умолчанию применять именно этот подход при работе с их инструментом create-react-app. Если речь идёт о часто меняющихся страницах, для которых характерна высокая интерактивность (возьмём, к примеру, дашборд), то этого, пожалуй, достаточно. Но что, если вам нужно, чтобы поисковик прочитал вашу страницу, а этот поисковик не умеет выполнять JavaScript? Что, если на сервере требуется хранить секреты? Что, если пользователи работают с маломощными устройствами, или у них плохое соединение с Интернетом (со многими так и есть)?

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

Далее, в ходе навигации, клиентам не требуется общаться с сервером, поскольку код для рендеринга на них уже есть, и при переходах на каждую новую страницу отклик получается быстрым.

Именно здесь в дело вступили рендеринг на стороне сервера (SSR) и генерация статических сайтов (SSG). В таких инструментах как Next.js и Gatsby технологии SSR и SSG используются для генерации страниц на сервере и для отправки их на клиент в виде кода, написанного на HTML и JavaScript. Лучшее от двух миров. Клиент может сразу же отобразить этот HTML, так что пользователю просто будет на что посмотреть. Затем, когда загрузится JS, страница станет красивой и интерактивной. Бонус: этот HTML смогут прочитать поисковики, что, согласитесь, круто.

При рендеринге на стороне сервера клиентам отправляется HTML, который на них «сразу же виден», а также JavaScript, нужный для отображения последующих страниц. На то, чтобы JavaScript загрузился, и страница им пропиталась, времени требуется больше.
При рендеринге на стороне сервера клиентам отправляется HTML, который на них «сразу же виден», а также JavaScript, нужный для отображения последующих страниц. На то, чтобы JavaScript загрузился, и страница им пропиталась, времени требуется больше.
Точно как и в случае с CSR, при последующей навигации в условиях SSR в наличии есть весь код рендеринга, который понадобится для отображения следующей страницы, поэтому переход между страницами происходит быстро.
Точно как и в случае с CSR, при последующей навигации в условиях SSR в наличии есть весь код рендеринга, который понадобится для отображения следующей страницы, поэтому переход между страницами происходит быстро.

На самом деле, это очень хорошо! Но остаётся решить ещё некоторые проблемы. Во-первых, при большинстве SSR/SSG подходов мы пересылаем на клиент весь JavaScript, нужный для генерации страницы, и здесь клиент вновь выполняет весь этот код и связывает этот HTML с только что загрузившимся JavaScript. (Кстати, этот союз часто называют «пропитыванием» (hydration) — этот термин часто употребляется в нашем ремесле.) В самом ли деле нужно отправлять и выполнять весь этот JavaScript? В самом ли деле требуется дублировать все операции рендеринга, просто, чтобы сайт пропитался?

Во-вторых, что делать, если серверный рендеринг занимает слишком много времени? Может быть, выполняется слишком много кода, может быть, загрузка застряла из-за того, что мы отправили в базу данных медленный вызов и дожидаемся его окончания. И вот пользователю надоело ждать. Бум.

Именно здесь нам и пригодятся серверные компоненты React.

Что такое серверные компоненты React? Для чего они хороши?

Неудивительно, что React Server Components (RSC) – это компоненты, написанные при помощи React и выполняемые на сервере, а не на клиенте. Правда, здесь особенно интересен не вопрос «что?», а вопрос «почему?». Зачем нам требуются RSC? Дело в том, что фреймворки с поддержкой RSC выигрывают у SSR в двух важных отношениях.

Во-первых, во фреймворках с поддержкой RSC для нас предусмотрен способ самостоятельно определять, где именно будет выполняться наш код: что должно выполняться только на сервере (как в старые добрые времена PHP), а что – на клиенте (как SSR). Так различаются, соответственно, серверные компоненты и клиентские компоненты. Поскольку мы можем явно указывать, где будет выполняться наш код, мы можем посылать на клиент меньше JavaScript – так у нас будут получаться более компактные пакеты (бандлы), и при пропитывании придётся выполнять меньше работы.

Интерактивные компоненты (зелёные) отправляются на клиент, а статические (синие) остаются на сервере.
Интерактивные компоненты (зелёные) отправляются на клиент, а статические (синие) остаются на сервере.

Второе достоинство RSC-управляемых фреймворков заключается в том, что серверные компоненты могут выбирать данные непосредственно из конкретного компонента. По завершении этой выборки серверные компоненты могут потоком отправлять эти данные на клиент.

Такой новый подход к выборке данных меняет ситуацию сразу в двух отношениях. Во-первых, при выборке данных с применением React об этом процессе становится гораздо проще судить. Ведь серверный компонент может просто… напрямую выбрать данные при помощи библиотеки node или функции fetch, которую все мы знаем и любим. Ваш компонент user выберет пользовательские данные, компонент movie выберет данные о фильме и пр. Больше не приходится прибегать ни к каким библиотекам или useEffect для управления сложными состояниями загрузки (react-query, я тебя не разлюбил), равно как и не нужно больше выбирать блок данных на уровне страницы при помощи getServerSideProps, а затем забуриваться на уровень именно того компонента, которому эти данные понадобились.

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

Этот серверный компонент напрямую выбирает касающиеся его данные, а когда готов – посылает их на клиент в виде потока.
Этот серверный компонент напрямую выбирает касающиеся его данные, а когда готов – посылает их на клиент в виде потока.

На закуску: а что делать, если нужно выбрать на сервере данные именно в ответ на действие, которое пользователь совершил на клиенте (например, отправил форму)? Это также осуществимо. Клиент может отправить данные на сервер, а сервер – выполнить выборку или другое нужное действие, а затем потоком отправить эту информацию на клиент точно так, как посылал и исходные данные. Чисто технически такая двунаправленная коммуникация не относится к React Server Components — это React Actions — но обе эти технологии строятся на одном и том же фундаменте, и они тесно взаимосвязаны. Правда, здесь мы не будем вдаваться в подробности React Actions. Приберегу эту тему для следующего поста в блоге.

Для чего не слишком хороши серверные компоненты React?

До сих пор я рисовал очень приятную картинку. Если RSC настолько лучше CSR и SSR, то почему бы ими не пользоваться? Я задавался тем же вопросом, и на горьком опыте (как понятно из названия этого поста) убедился, что тут действительно есть подводные камни. Причём, немало. Вот три аспекта, которые дольше всего реализуются при миграции на серверные компоненты React.

CSS-in-JS - не лучшее начало

Оказывается, что по состоянию на настоящий момент сочетание CSS-в-JS не работает с серверными компонентами. Это больно. Переход с styled-components на Tailwind CSS, пожалуй, был самым крупным мероприятием в ходе нашей миграции на RSC, правда, мы думали, что эта морока стоит затраченных усилий.

То есть, если вы когда-то пошли ва-банк и всё организовали в виде CSS-в-JS, то теперь вам придётся плотно поработать. Что ж, как минимум, это хорошая возможность мигрировать на платформу получше, правда?

Контекст React не работает с серверными компонентами

Можно обращаться к контексту React только в клиентских компонентах. Если вы хотите, чтобы данные совместно использовались между разными серверными компонентами, но без применения пропсов – то, пожалуй, придётся прибегнуть к старым добрым модулям.

А теперь сюрприз: если вы хотите, чтобы какие-либо данные использовались только в пределах поддерева вашего приложения React, то именно в серверных компонентах предусмотрен отличный механизм для этой цели. (Если я неправ, и он есть где-то ещё – пожалуйста, поправьте меня. Мне его действительно не хватает.)

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

Но на нашем маркетинговом сайте всё вышло гораздо сложнее. У нас на маркетинговом сайте есть области, где совместно используется некоторая тема. Например, на следующем скриншоте каждый компонент в области над «подвалом» сайта должен «понимать», что он находится на зелёном фоне, поэтому в нём обязательно должна использоваться тёмно-зелёная граница. Как правило, для совместного использования такого состояния темы можно было бы прибегнуть к Context, но здесь мы имеем дело в основном со статическими компонентами, идеально сочетающимися с Server Components, технология Context здесь не вариант. Мы нашли обходной маневр, серьёзно задействовав специальные свойства CSS (пожалуй, так даже лучше, поскольку в таком случае проблемы оказываются в плоскости оформления, а не в плоскости данных). Но другим разработчикам, возможно, повезло меньше.

С каждым компонентом в этом разделе должна использоваться зелёная тема. Мы пользовались собственными свойствами CSS, поскольку React Context невозможно использовать с серверными компонентами.
С каждым компонентом в этом разделе должна использоваться зелёная тема. Мы пользовались собственными свойствами CSS, поскольку React Context невозможно использовать с серверными компонентами.

Честно говоря, сложно удержать в голове всё сразу

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

Всякий раз, когда мы подключали к работе с нашей базой кода нового сотрудника, всплывали такие вопросы: «Что выполняется на сервере?», «Что выполняется на клиенте?». Каждому PR-специалисту поступала обратная связь о случаях, когда какая-либо информация без нужды/случайно попадала на клиент. Я часто добавлял в мой код консольные логи, чтобы проверить, где будет выполняться логирование: на сервере или на клиенте. Причём, не заставляйте меня заговаривать о сложности кэширования.

Ситуация улучшается, когда напрактикуешься и выработаешь надёжные паттерны. Давайте же об этом и поговорим. Как мы пользуемся серверными компонентами React? Как рекомендуем организовать поступательную миграцию? Как мы справляемся с нетривиальными вещами, не создавая нечитаемых комьев спагетти-кода?

Как пользоваться серверными компонентами React?

Вам ещё не страшно? Считаете, игра стоит свеч? Что ж, давайте перейдём к делу и начнём с самых основ.

На момент подготовки оригинала этой статьи существует всего одна реализация RSC, готовая к использованию в продакшене. Это новая app directory из Next.js 13. Можно выкатить и собственный фреймворк RSC, но, если вы из тех разработчиков, которые занимаются такими делами, то, вероятно, вы не стали бы читать этот пост. В любом случае, некоторые из сделанных ниже замечаний могут быть немного специфичны для Next.js.

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

Возможно, мысленно визуализировать модель серверных компонентов не так легко, зато нам просто повезло, насколько там элементарный синтаксис. По умолчанию любой компонент, который вы пишете в новом каталоге app directory, предусмотренном в Next.js 13, будет серверным компонентом. Иными словами, никакая часть из кода вашей страницы не будет отправляться на клиент.

Простейший серверный компонент

function Description() { 
  return (
    <p>
      Ничто из этого кода не будет отправляться на клиент. Только HTML!
    </p>
  )
}

Добавим в этот серверный компонент возможность async – и можно просто приступать к выборке данных! Вот как это может выглядеть:

Серверный компонент с выборкой данных

async function getVideo(id) {
  const res = await fetch(`https://api.example.com/videos/${id}`)
  return res.json()
}

async function Description({ videoId }) {
  const video = await getVideo(userId)
  return <p>{video.description}</p>
}

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

В нижеприведённом примере на клиенте будут видны надписи “loading comments” (загрузка комментариев) и “loading related videos.” (загрузка связанных видео). Когда сервер справится с выборкой компонентов, он отобразит компонент <Comments /> и потоком отправит на клиент тот компонент, который нужно отобразить. Аналогично будет сделано и со связанными видео.

Серверный компонент с выборкой и потоковой передачей данных

import { Suspense }  from 'react'

async function VideoSidebar({ videoId }) {
  return (
    <Suspense fallback={<p>loading comments...</p>}>
      <Comments videoId={videoId} />
    </Suspense>
    <Suspense fallback={<p>loading related videos...</p>}>
      <RelatedVideos videoId={videoId} />
    </Suspense>
  )
}

Применять React.Suspense выгодно не только потому, что можно по мере готовности данных потоком передавать их на клиент. React также может воспользоваться границами Suspense для расстановки приоритетов – так можно (исходя из действий пользователя) выбирать, какие части приложения пропитывать в первую очередь. Такой приём называется «выборочной пропиткой», но обсуждение этой темы, пожалуй, лучше оставить экспертам.

Клиентские компоненты

Теперь допустим, что у вас есть определённый код, который нужно выполнять на клиенте. Например, у вас может быть слушатель onClick, либо вы реагируете на данные, сохранённые в useState.

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

Простейший клиентский компонент

"use client"
import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(count + 1)

  return (
    <button onClick={increment}>
      The count is {count}
    </button>
  )
}

Второй способ доставить компонент на клиент — импортировать его через клиентский компонент. Иными словами, если пометить компонент как “use client”, то на клиент будет доставлен не только этот компонент, но и всете компоненты, которые он импортирует.

(Означает ли это, что серверный компонент не может быть потомком клиентского компонента? Нет, не означает, но тут всё несколько сложно. Подробнее об этом позже.)

Возможно, вам будет удобно представить ситуацию таким образом: “use client” сообщает компоновщику пакетов, что именно здесь и пролегает граница между клиентом и сервером. Если так не проще, то просто забудьте этот тезис.

Что делать, если библиотека не поддерживает клиентские компоненты?

Второй из вышеописанных способов может пригодиться для решения распространённой проблемы. Допустим, вы хотите работать с библиотекой, которая пока не поддерживает серверные компоненты React и поэтому не содержит директивы “use client”. Если вы хотите гарантировать, что эта библиотека будет доставлена на клиент, то импортируйте её из клиентского компонента – и она будет отправлена на клиент вместе с ним.

Преобразуем библиотеку в клиентский компонент

"use client"

// поскольку эта библиотека импортирована как клиентский компонент,
// она также превращается в клиентский компонент
import MuxPlayer from "@mux/mux-player-react"

function ClientMuxPlayer(props) {
  return <MuxPlayer {...props} />
}
Можно преобразовать библиотеку в клиентский компонент, импортировав её из другого клиентского компонента.
Можно преобразовать библиотеку в клиентский компонент, импортировав её из другого клиентского компонента.

В каком случае мне стоит остановиться на клиентских компонентах?

Давайте отступим немного назад и подытожим.

Сегодня серверные компоненты – это передний край технологии React. С их помощью очень удобно выбирать данные и выполнять затратный код, который не требуется или не хочется отправлять на клиент: например, если нужно отобразить текст длинной статьи в блоге или подсветить синтаксис в блоке кода. В удобных случаях следует оставлять код в виде серверных компонентов, чтобы не раздувать тот пакет с кодом, который передаётся на клиент.

Клиентские компоненты – это тот React, который мы знаем и любим. Их можно отображать на стороне сервера, после чего они отправляются на клиент для последующего пропитывания и выполнения. Клиентские компоненты очень удобны в тех случаях, когда вы хотите реагировать на пользовательский ввод или менять состояние с течением времени.  

Если ваше приложение было целиком сделано из клиентских компонентов, то оно будет работать точно как с обычными SSR-фреймворками. Поэтому никто вас не заставляет преобразовывать сразу всё приложение в серверные компоненты! Внедряйте их пошагово, сначала именно в тех участках приложения, где они принесут наибольшую пользу. А, если уж зашла речь о постепенном внедрении…

Как постепенно внедрить серверные компоненты React в реальной рабочей базе кода?

В данном акте драмы из зала обычно слышится: «Красиво! Но, по-видимому, это большой кусок работы, а у нас нет времени переписывать всю базу кода». Что ж, могу вас заверить, что это и не требуется. Вот как можно в три шага переделать большую часть вашего кода в серверные компоненты:

  1. Добавить директиву “use client” в корень вашего приложения

  2. Поставить эту директиву настолько низко в дереве рендеринга, насколько это возможно

  3. Если будут возникать проблемы с производительностью – использовать продвинутые паттерны

Давайте разберём каждый из этих шагов в отдельности.

1. Добавляем директиву "use client" в корень вашего приложения

В сущности, вот и всё. Если вы работаете с Next.js 13, то перейдите наа страницу верхнего уровня page.tsx, и там в самом верху поставьте “use client”. Ваша страница будет работать точно как и раньше, но только теперь вы будете готовы перенести её в мир серверных компонентов! 

video/page.jsx

"use client"

export default function App() {
  <>
    <Player />
    <Title />
  </>
}
Добавляя “use client” в корень приложения, вы преобразуете ваше приложение в серверные компоненты. Функционально оно теперь будет устроено точно как SSR-приложение.
Добавляя “use client” в корень приложения, вы преобразуете ваше приложение в серверные компоненты. Функционально оно теперь будет устроено точно как SSR-приложение.

У вас есть какие-нибудь данные, которые нужно выбирать с сервера? Из клиентского компонента это делать нельзя, поэтому переходим к серверному компоненту. Давайте добавим его в качестве предка нашего серверного компонента. Серверный компонент будет выбирать нужные данные и передавать их на нашу страницу. Вот как это будет выглядеть:

video/page.jsx

/**
  * Всё, что мы тут делаем – это выбираем данные с сервера
  * и передаём эти данные в клиентский компонент
  */
import VideoPageClient from './page.client.jsx'

// раньше это был getServerSideProps
async function fetchData() {
  const res = await fetch('https://api.example.com')
  return await res.json()
}

export default async function FetchData() {
  const data = await fetchData()
  {/* Содержимое нашей страницы мы переместили в этот клиентский компонент */}
  const <VideoPageClient data={data} />
}

export default Page
video/page.client.jsx

/**
  * Здесь может жить всё наше приложение кроме функции выборки данных.
  */
"use client"

export default function App({ data }) {
  <>
    <Player videoId={data.videoId} />
    <Title content={data.title} />
  </>
}
Добавляем серверный компонент в качестве предка нашего приложения, так что теперь можем выбирать данные с сервера  
Добавляем серверный компонент в качестве предка нашего приложения, так что теперь можем выбирать данные с сервера  

2. Перемещаем эту директиву настолько близко к корню дерева рендеринга, насколько это возможно

Далее берём эту директиву “use client” и перемещаем из самого высокоуровневого компонента в каждый из его потомков. В нашем примере речь идёт о подобном переносе из компонента <Client /> в компоненты <Player /> и <Title />.

video/Player.jsx

"use client"
import MuxPlayer from "@mux/mux-player-react"

function Player({ videoId }) {
  return <MuxPlayer streamType="on-demand" playbackId={videoId} />
}
video/Title.jsx

"use client"

function Title({ content }) {
  return <h1>{content}</h1>
}
Перемещаем директиву “use client” ещё ниже по дереву компонентов.
Перемещаем директиву “use client” ещё ниже по дереву компонентов.

И повторить! Правда… поскольку ни у <Player />, ни у <Title /> нет потомков, в которые можно было бы положить директиву “use client”, давайте удалим их!

С <Title /> никаких проблем, так как для <Title /> не требуется никакого клиентского кода, его можно передавать в виде чистого HTML. Тем временем, <Player /> выбрасывает ошибку.

Компонент Player зависит от пользовательских действий, поэтому ему требуется директива “use client”.
Компонент Player зависит от пользовательских действий, поэтому ему требуется директива “use client”.

Отлично. Значит, ниже уже не пройти. Давайте восстановим “use client” в компоненте <Player />, чтобы исправить эту ошибку, и будем считать, что дело сделано.

Окончательное состояние нашего приложения.
Окончательное состояние нашего приложения.

Видите? Получилось не так плохо. Мы перенесли наше приложение на серверные компоненты. Теперь, по мере добавления новых компонентов и рефакторинга старых, можем развивать приложение, держа в уме серверные компоненты. Кроме того, нам удалось немного ужать отправляемый пакет, не включая в него <Title /> !

3. Если будут возникать проблемы с производительностью - использовать продвинутые паттерны

В большинстве случаев должно хватить вариантов 1 и 2. Но, если вы заметите проблемы с производительностью, то можно выжать ещё некоторые оптимизации из вашего RSC-преобразования.

Например, когда мы переносили на RSC наш сайт с документацией, мы придерживались при этом двух паттернов, чтобы выиграть по максимуму. Во-первых, мы обёртывали ключевые серверные компоненты в Suspense, чтобы организовать потоковую передачу тех данных, выборка которых идёт медленно (как было продемонстрировано выше). Всё наше приложение генерируется в статическом виде (кроме одного динамического элемента – журнала изменений в боговой панели, его мы получаем из CMS). Обёртывая эту боковую панель в Suspense, мы выигрываем в следующем: приложению не приходится дожидаться, пока разрешится операция выборки CMS. Кроме того, мы воспользовались соглашением, действующим в loading.js из Next.js 13: здесь под капотом применяется комбинация Suspense и потоковой обработки.

Вторая оптимизация, которую мы применили, заключалась в следующем: мы творчески переупорядочили клиентские и серверные компоненты и таким образом позаботились, чтобы на сервере остались самые большие наши библиотеки – например, Prism, которую мы используем для подсветки синтаксиса. А если уж речь зашла о творческом переупорядочивании клиентских и серверных компонентов…

Вы упомянули продвинутые паттерны?

Как же вам удаётся перемешивать клиентские и серверные компоненты?

Выше мы говорили о том, что любой компонент, импортированный из клиентского компонента, сам становится клиентским компонентом. Так как же сделать серверный компонент потомком клиентского? Если коротко, передаём серверные компоненты как потомки или пропсы, а не импортируем их. Серверный компонент будет отображаться на сервере, сериализоваться и отправляться в ваш клиентский компонент.

На мой взгляд, во всей путанице с RSC именно этот приём сложнее всего уложить в голове. Но на практике становится проще. Давайте разберём его на нескольких примерах и для начала посмотрим, как всё сделать неправильно.

Как НЕ НАДО смешивать клиентские и серверные компоненты

// Всё, что импортировано из клиентского компонента, превращается в клиентский компонент, 
// так что это неверный вариант!
import ServerComponentB from './ServerComponentB.js'

function ClientComponent() {
  return (
    <div>
      <button onClick={onClickFunction}>Button</button>
      {/* поскольку это было импортировано из клиентского компонента, этот компонент также становится клиентским. */}
      <ServerComponentB />
    </div>
  )
}
Всё, что вы импортируете из клиентского компонента, становится клиентским компонентом. Это может быть нежелательно.
Всё, что вы импортируете из клиентского компонента, становится клиентским компонентом. Это может быть нежелательно.

Импортировав ServerComponent в клиентском компоненте, мы отправили ServerComponent на клиент. О, нет! Чтобы всё сделать правильно, нужно подняться на уровень выше, до ближайшего серверного компонента – в данном случае, ServerPage — и уже здесь делать необходимую работу.

Как смешивать клиентские и серверные компоненты

import ClientComponent from './ClientComponent.js'
import ServerComponentB from './ServerComponentB.js'

/** 
  * Первый способ смешивать клиентские и серверные компоненты —
  * передать серверный компонент клиентскому
  * в качестве потомка.
  */
function ServerComponentA() {
  return (
    <ClientComponent>
      <ServerComponentB />
    </ClientComponent>
  )
}

/** 
    * Второй способ смешивать клиентские и серверные компоненты —
  * передать серверный компонент клиентскому
  * в качестве пропса
  */
function ServerPage() {
  return (
    <ClientComponent
      content={<ServerComponentB />}
    />
  )
}
ServerComponentB остаётся серверным компонентом, если передать его ClientComponent получает его в качестве потомка или пропса, а не импортировать напрямую.
ServerComponentB остаётся серверным компонентом, если передать его ClientComponent получает его в качестве потомка или пропса, а не импортировать напрямую.

Можно ли сделать так, чтобы половина файла была клиентским компонентом, а половина - серверным компонентом?

Ни в коем случае! Но ниже описан паттерн, которым мы часто пользуемся, если хотим добиться, чтобы часть функционала нашего компонента осталась на сервере. Допустим, мы делаем компонент <CodeBlock />. Вероятно, мы захотим, чтобы подсветка синтаксиса так и осталась на сервере, чтобы нам не приходилось отправлять на клиент эту большую библиотеку. Но нам может понадобиться в этом компоненте и некоторая клиентская функциональность, чтобы пользователь мог переключаться при работе между разными фрагментами кода. Первым делом разобьём этот компонент на две половины: CodeBlock.server.js и CodeBlock.client.js. Первая импортирует вторую. (Имена здесь могут быть любыми; мы используем .server и .client, просто чтобы всё было максимально логично.)

components/CodeBlock/CodeBlock.server.js

// filename: components/CodeBlock/CodeBlock.server.js
import Highlight from 'expensive-library'
import ClientCodeBlock from './CodeBlock.client.js'
import { example0, example1, example2 } from './examples.js'

function ServerCodeBlock() {
  return (
    <ClientCodeBlock
      // поскольку мы передаём их в виде пропсов, они остаются только на сервере
      renderedExamples={[
        <Highlight code={example0.code} language={example0.language} />,
        <Highlight code={example1.code} language={example1.language} />,
        <Highlight code={example2.code} language={example2.language} />
      ]}
    >
  )
}

export default ServerCodeBlock
components/CodeBlock/CodeBlock.client.js

"use client"
import { useState } from 'react'

function ClientCodeBlock({ renderedExamples }) {
  // поскольку нам требуется реагировать на состояние и на слушатели onClick,
  // это должен быть клиентский компонент
  const [currentExample, setCurrentExample] = useState(1)
  
  return (
    <>
      <button onClick={() => setCurrentExample(0)}>Example 1</button>
      <button onClick={() => setCurrentExample(1)}>Example 2</button>
      <button onClick={() => setCurrentExample(2)}>Example 3</button>
      { renderedExamples[currentExample] }
    </>
  )
}

export default ClientCodeBlock

Теперь, когда у нас есть два этих компонента, давайте максимально облегчим их потребление, применив эту великолепную файловую структуру. Положим два этих файла в каталог, который называется CodeBlock, а также добавим в него файл index.js, имеющий следующий вид: 

export { default } from './CodeBlock.server.js'

Теперь любой потребитель может импортировать CodeBlock из ‘components/CodeBlock.js’, а как клиентский, так и серверный компонент остаются прозрачными.

Как-то запутанно. Как я могу быть уверен, что мой код действительно выполняется на сервере?

Честно говоря, сначала мы просто добавили в наш код console.log на этапе разработки и проверили, откуда идут эти логи: с сервера или из браузера. На первом этапе и это вполне работало, но со временем мы нашли способ получше.

Если вы хотите наверняка гарантировать, что ваш серверный компонент никогда не попадёт в пакет для отправки на клиент, то можно импортировать пакет только для сервера. Это особенно удобно, если вы хотите быть уверены, что большая библиотека или секретный ключ не окажутся где не положено. (Правда, если вы пользуетесь Next.js, он сам защитит вас от нечаянной отправки ваших переменных окружения.)

Используя пакеты «только для сервера», мы также приобретали ещё одну тонкую, но важную выгоду: повышали читаемость кода и удобство его поддержки. Если человек, занятый поддержкой, видит в самом начале файла пометку server-only, он сразу знает, где именно работает этот файл, и может не держать в уме полную модель всего дерева компонентов.

Итак, следует ли мне пользоваться серверными компонентами React?

В конце концов, работа с серверными компонентами React имеет свою цену. Речь не только о коварных аспектах CSS-in-JS или контекста React. Важна и дополнительная сложность: важно понимать, что работает на сервере, а что на клиенте, понимать пропитывание, учитывать инфраструктурные издержки и, конечно же, управлять сложностью кода (особенно при смешивании клиентских и серверных компонентов). Каждая грань такой сложности – ещё одна лазейка для багов и проблем с поддержкой кода. При помощи фреймворков эту сложность можно купировать, но полностью устранить нельзя.

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

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

Комментарии (9)


  1. ThisMan
    10.09.2023 21:44
    +7

    Пара вопросов

    1. Как в итоге повлиял этот переход на скорость загрузки и работу сайта? Есть ли объективные цифры/замеры?

    2. Сколько в итоге у вас получилось серверных компонентов? 10%? 20%?

    3. Есть ли какие то практик, как быстро оценить, какая часть приложения доступна для перевода, чтобы сразу понять, что это того стоит?


    1. dndred
      10.09.2023 21:44

      Если у вас много данных __NEXT_DATA__ для гидрации и большой Total Blocking Time в Lighthouse для ре-рендера, то серверные компоненты могут с этим помочь.


      1. ThisMan
        10.09.2023 21:44

        Хотел спросить про другие вопросы, а потом понял, что это перевод) В общем, интересно, конечно, есть ли объективный профит от всей этой затеи


        1. dndred
          10.09.2023 21:44

          Метрики я указал.

          Мы изучали вопрос переезда, так как у нас почти целиком не интерактивный контент рендерится. Но отсутствие Context API ломает основаный на нём React Query и CSS-in-JS, так что объём работы получился огромный.

          Для новых проектов я бы рассматривал RSC, да.


  1. vagon333
    10.09.2023 21:44

    Я недопонял гибкость RSC: RSC это только компонент с логикой только на сервере, или есть и клиентская часть?
    Например, мне нужен простой grid.
    Т.е. данные формируются на сервере, через выборку из базы.
    UI на клиенте и взаимодействие с гридом (навигация, фильтрация) запрашивается на клиенте, но отрабатывается на сервере.
    Данный функционал - это RSC, или это вообще невозможно в одном контроле?


    1. dndred
      10.09.2023 21:44

      Для интерактивного UI необходим JS и исходные данные. RSC на выходе имеет только HTML и не содержит ни JS, ни данных.

      В данном случае нужно использовать обычный клиентский компонент. Он может быть пререндерен на сервере при необходимости.


  1. Rorg
    10.09.2023 21:44

    Спасибо за статью! Сейчас как раз занимаюсь переделкой сайта на RSC (Next 13, так что вместе с app)


  1. Sap_ru
    10.09.2023 21:44
    +2

    В очередной раз непонятно какую проблему решали и решили ли.

    Какие именно проблемы были с CSR? Не абстрактные "могут", а реальные. У вас были поисковики, которые вас не индексировали, и это влияло на продвижение? Значительная часть вашей аудитории использует устройства, неспособные отображать динамический контент?

    Что именно стало лучше? Оно вообще с точки зрения бизнеса окупилось и почему? Сложность стала больше. Требования к серверам - больше. Затраты на поддержку и разработку больше. Откуда прибыль? Что именно было решено, что это около затраты на переход и продолжает окупать затраты на поддержание?


  1. unicrus
    10.09.2023 21:44
    +1

    а что – на клиенте (как SSR)

    Похоже, что должно быть CSR.