Серверные компоненты React – это большой кусок работы. Недавно мы переосмыслили нашу документацию и устроили ребрендинг Mux. Пока мы этим занимались, мы перенесли весь материал сайтов mux.com и docs.mux.com на серверные компоненты. Так что, поверьте мне… я знаю. Знаю, что это возможно, не так страшно и, в принципе, что дело того стоит. Давайте я вам объясню, почему, ответив на следующие вопросы: почему так важны серверные компоненты, а также для чего они хороши? Для чего они не так хороши? Как их использовать, как их постепенно внедрять и какие продвинутые паттерны следует использовать, чтобы всем этим управлять? Дочитав эту статью, вы станете замечательно представлять, следует ли вам использовать серверные компоненты React, а если следует – то как использовать их эффективно.  Есть отличный способ осмыслить серверные компоненты React – понять, какую именно проблему они решают. Давайте с этого и начнём. Давным-давно, в седой древности, сайты генерировались на серверах при помощи таких технологий как PHP. Так было очень удобно выбирать с сервера данные с использованием секретов. На больших компьютерах выполнялась сложная работа, при которой активно нагружался ЦП, а на клиент приходила красивая и лёгкая HTML-страница, персонализированная под интересы пользователя.   А потом мы стали задумываться: как можно добиться более быстрого отклика и усилить интерактивность? В самом ли деле при каждом действии, которое пользователь совершает на странице, мы хотим отправлять на сервер порцию куки и заставлять сервер сгенерировать совершенно новую страницу? Что, если делегировать эту работу клиенту? Весь рендеринговый код можно просто написать на JavaScript и в таком виде отправить на клиент! Такую технологию назвали «рендеринг на стороне клиента» (CSR), а впоследствии появился термин «одностраничные приложения» (SPA), и этот шаг был отрицательно оценен в широких кругах. Конечно, такая технология проста, и это дорогого стоит! На самом деле, в течение долгого времени команда React рекомендовала по умолчанию применять именно этот подход при работе с их инструментом create-react-app. Если речь идёт о часто меняющихся страницах, для которых характерна высокая интерактивность (возьмём, к примеру, дашборд), то этого, пожалуй, достаточно. Но что, если вам нужно, чтобы поисковик прочитал вашу страницу, а этот поисковик не умеет выполнять JavaScript? Что, если на сервере требуется хранить секреты? Что, если пользователи работают с маломощными устройствами, или у них плохое соединение с Интернетом (со многими так и есть)? Далее, в ходе навигации, клиентам не требуется общаться с сервером, поскольку код для рендеринга на них уже есть, и при переходах на каждую новую страницу отклик получается быстрым. Именно здесь в дело вступили рендеринг на стороне сервера (SSR) и генерация статических сайтов (SSG). В таких инструментах как Next.js и Gatsby технологии SSR и SSG используются для генерации страниц на сервере и для отправки их на клиент в виде кода, написанного на HTML и JavaScript. Лучшее от двух миров. Клиент может сразу же отобразить этот HTML, так что пользователю просто будет на что посмотреть. Затем, когда загрузится JS, страница станет красивой и интерактивной. Бонус: этот HTML смогут прочитать поисковики, что, согласитесь, круто. На самом деле, это очень хорошо! Но остаётся решить ещё некоторые проблемы. Во-первых, при большинстве SSR/SSG подходов мы пересылаем на клиент весь JavaScript, нужный для генерации страницы, и здесь клиент вновь выполняет весь этот код и связывает этот HTML с только что загрузившимся JavaScript. (Кстати, этот союз часто называют «пропитыванием» (hydration) — этот термин часто употребляется в нашем ремесле.) В самом ли деле нужно отправлять и выполнять весь этот JavaScript? В самом ли деле требуется дублировать все операции рендеринга, просто, чтобы сайт пропитался? Во-вторых, что делать, если серверный рендеринг занимает слишком много времени? Может быть, выполняется слишком много кода, может быть, загрузка застряла из-за того, что мы отправили в базу данных медленный вызов и дожидаемся его окончания. И вот пользователю надоело ждать. Бум. Именно здесь нам и пригодятся серверные компоненты 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. Приберегу эту тему для следующего поста в блоге. До сих пор я рисовал очень приятную картинку. Если RSC настолько лучше CSR и SSR, то почему бы ими не пользоваться? Я задавался тем же вопросом, и на горьком опыте (как понятно из названия этого поста) убедился, что тут действительно есть подводные камни. Причём, немало. Вот три аспекта, которые дольше всего реализуются при миграции на серверные компоненты React. Оказывается, что по состоянию на настоящий момент сочетание CSS-в-JS не работает с серверными компонентами. Это больно. Переход с styled-components на Tailwind CSS, пожалуй, был самым крупным мероприятием в ходе нашей миграции на RSC, правда, мы думали, что эта морока стоит затраченных усилий. То есть, если вы когда-то пошли ва-банк и всё организовали в виде CSS-в-JS, то теперь вам придётся плотно поработать. Что ж, как минимум, это хорошая возможность мигрировать на платформу получше, правда? Можно обращаться к контексту React только в клиентских компонентах. Если вы хотите, чтобы данные совместно использовались между разными серверными компонентами, но без применения пропсов – то, пожалуй, придётся прибегнуть к старым добрым модулям. А теперь сюрприз: если вы хотите, чтобы какие-либо данные использовались только в пределах поддерева вашего приложения React, то именно в серверных компонентах предусмотрен отличный механизм для этой цели. (Если я неправ, и он есть где-то ещё – пожалуйста, поправьте меня. Мне его действительно не хватает.) На нашем сайте с документацией это была не такая серьёзная проблема. Те места, где мы активно использовали компоненты React, в то же время отличались наибольшей интерактивностью, и этот код так или иначе требовалось доставлять на клиент. Например, наша поисковая функция работает с разделением состояния, например, queryString и isOpen, в пределах всего дерева компонентов.  Но на нашем маркетинговом сайте всё вышло гораздо сложнее. У нас на маркетинговом сайте есть области, где совместно используется некоторая тема. Например, на следующем скриншоте каждый компонент в области над «подвалом» сайта должен «понимать», что он находится на зелёном фоне, поэтому в нём обязательно должна использоваться тёмно-зелёная граница. Как правило, для совместного использования такого состояния темы можно было бы прибегнуть к Context, но здесь мы имеем дело в основном со статическими компонентами, идеально сочетающимися с Server Components, технология Context здесь не вариант. Мы нашли обходной маневр, серьёзно задействовав специальные свойства CSS (пожалуй, так даже лучше, поскольку в таком случае проблемы оказываются в плоскости оформления, а не в плоскости данных). Но другим разработчикам, возможно, повезло меньше. На фундаментальном уровне RSC позволяют более гибко подходить к тому, где именно выполняется ваш код, и как выглядит процесс выборки данных. Но с гибкостью приходит сложность. Нет такого инструмента, который позволял бы полностью её замазать, поэтому рано или поздно вам придётся в ней разобраться, а далее иметь с нею дело и научиться объяснять её другим разработчикам. Всякий раз, когда мы подключали к работе с нашей базой кода нового сотрудника, всплывали такие вопросы: «Что выполняется на сервере?», «Что выполняется на клиенте?». Каждому PR-специалисту поступала обратная связь о случаях, когда какая-либо информация без нужды/случайно попадала на клиент. Я часто добавлял в мой код консольные логи, чтобы проверить, где будет выполняться логирование: на сервере или на клиенте. Причём, не заставляйте меня заговаривать о сложности кэширования. Ситуация улучшается, когда напрактикуешься и выработаешь надёжные паттерны. Давайте же об этом и поговорим. Как мы пользуемся серверными компонентами React? Как рекомендуем организовать поступательную миграцию? Как мы справляемся с нетривиальными вещами, не создавая нечитаемых комьев спагетти-кода?  Вам ещё не страшно? Считаете, игра стоит свеч? Что ж, давайте перейдём к делу и начнём с самых основ. На момент подготовки оригинала этой статьи существует всего одна реализация RSC, готовая к использованию в продакшене. Это новая app directory из Next.js 13. Можно выкатить и собственный фреймворк RSC, но, если вы из тех разработчиков, которые занимаются такими делами, то, вероятно, вы не стали бы читать этот пост. В любом случае, некоторые из сделанных ниже замечаний могут быть немного специфичны для Next.js. Возможно, мысленно визуализировать модель серверных компонентов не так легко, зато нам просто повезло, насколько там элементарный синтаксис. По умолчанию любой компонент, который вы пишете в новом каталоге app directory, предусмотренном в Next.js 13, будет серверным компонентом. Иными словами, никакая часть из кода вашей страницы не будет отправляться на клиент. Простейший серверный компонент Добавим в этот серверный компонент возможность async – и можно просто приступать к выборке данных! Вот как это может выглядеть: Серверный компонент с выборкой данных Остаётся добавить последний ингредиент, чтобы заставить серверные компоненты React работать на всю мощность. Если не хотите сидеть и ждать, пока завершится медленная выборка данных, то можно обёртывать серверные компоненты в React.Suspense. React покажет клиенту индикатор загрузки, предусмотренный в качестве резервной картинки, а как только сервер справится с выборкой данных, он потоком направит результаты на клиент. После этого клиент сможет убрать индикатор загрузки, заменив его полноценным компонентом. В нижеприведённом примере на клиенте будут видны надписи “loading comments” (загрузка комментариев) и “loading related videos.” (загрузка связанных видео). Когда сервер справится с выборкой компонентов, он отобразит компонент <Comments /> и потоком отправит на клиент тот компонент, который нужно отобразить. Аналогично будет сделано и со связанными видео. Серверный компонент с выборкой и потоковой передачей данных Применять React.Suspense выгодно не только потому, что можно по мере готовности данных потоком передавать их на клиент. React также может воспользоваться границами Suspense для расстановки приоритетов – так можно (исходя из действий пользователя) выбирать, какие части приложения пропитывать в первую очередь. Такой приём называется «выборочной пропиткой», но обсуждение этой темы, пожалуй, лучше оставить экспертам. Теперь допустим, что у вас есть определённый код, который нужно выполнять на клиенте. Например, у вас может быть слушатель onClick, либо вы реагируете на данные, сохранённые в useState. Компонент может быть поставлен одним из двух способов. Первый: добавляем “use client” в самом верху файла, и в таком случае данный модуль будет отправлен на клиент, после чего сможет реагировать на пользовательские действия. Простейший клиентский компонент Второй способ доставить компонент на клиент — импортировать его через клиентский компонент. Иными словами, если пометить компонент как “use client”, то на клиент будет доставлен не только этот компонент, но и всете компоненты, которые он импортирует. (Означает ли это, что серверный компонент не может быть потомком клиентского компонента? Нет, не означает, но тут всё несколько сложно. Подробнее об этом позже.) Возможно, вам будет удобно представить ситуацию таким образом: “use client” сообщает компоновщику пакетов, что именно здесь и пролегает граница между клиентом и сервером. Если так не проще, то просто забудьте этот тезис. Второй из вышеописанных способов может пригодиться для решения распространённой проблемы. Допустим, вы хотите работать с библиотекой, которая пока не поддерживает серверные компоненты React и поэтому не содержит директивы “use client”. Если вы хотите гарантировать, что эта библиотека будет доставлена на клиент, то импортируйте её из клиентского компонента – и она будет отправлена на клиент вместе с ним. Преобразуем библиотеку в клиентский компонент Давайте отступим немного назад и подытожим. Сегодня серверные компоненты – это передний край технологии React. С их помощью очень удобно выбирать данные и выполнять затратный код, который не требуется или не хочется отправлять на клиент: например, если нужно отобразить текст длинной статьи в блоге или подсветить синтаксис в блоке кода. В удобных случаях следует оставлять код в виде серверных компонентов, чтобы не раздувать тот пакет с кодом, который передаётся на клиент.  Клиентские компоненты – это тот React, который мы знаем и любим. Их можно отображать на стороне сервера, после чего они отправляются на клиент для последующего пропитывания и выполнения. Клиентские компоненты очень удобны в тех случаях, когда вы хотите реагировать на пользовательский ввод или менять состояние с течением времени.   Если ваше приложение было целиком сделано из клиентских компонентов, то оно будет работать точно как с обычными SSR-фреймворками. Поэтому никто вас не заставляет преобразовывать сразу всё приложение в серверные компоненты! Внедряйте их пошагово, сначала именно в тех участках приложения, где они принесут наибольшую пользу. А, если уж зашла речь о постепенном внедрении…  В данном акте драмы из зала обычно слышится: «Красиво! Но, по-видимому, это большой кусок работы, а у нас нет времени переписывать всю базу кода». Что ж, могу вас заверить, что это и не требуется. Вот как можно в три шага переделать большую часть вашего кода в серверные компоненты: Добавить  директиву “use client” в корень вашего приложения Поставить эту директиву настолько низко в дереве рендеринга, насколько это возможно  Если будут возникать проблемы с производительностью – использовать продвинутые      паттерны  Давайте разберём каждый из этих шагов в отдельности. В сущности, вот и всё. Если вы работаете с Next.js 13, то перейдите наа страницу верхнего уровня page.tsx, и там в самом верху поставьте “use client”. Ваша страница будет работать точно как и раньше, но только теперь вы будете готовы перенести её в мир серверных компонентов!  У вас есть какие-нибудь данные, которые нужно выбирать с сервера? Из клиентского компонента это делать нельзя, поэтому переходим к серверному компоненту. Давайте добавим его в качестве предка нашего серверного компонента. Серверный компонент будет выбирать нужные данные и передавать их на нашу страницу. Вот как это будет выглядеть: Далее берём эту директиву “use client” и перемещаем из самого высокоуровневого компонента в каждый из его потомков. В нашем примере речь идёт о подобном переносе из компонента <Client /> в компоненты <Player /> и <Title />. И повторить! Правда… поскольку ни у <Player />, ни у <Title /> нет потомков, в которые можно было бы положить директиву “use client”, давайте удалим их! С <Title /> никаких проблем, так как для <Title /> не требуется никакого клиентского кода, его можно передавать в виде чистого HTML. Тем временем, <Player /> выбрасывает ошибку. Отлично. Значит, ниже уже не пройти. Давайте восстановим “use client” в компоненте <Player />, чтобы исправить эту ошибку, и будем считать, что дело сделано. Видите? Получилось не так плохо. Мы перенесли наше приложение на серверные компоненты. Теперь, по мере добавления новых компонентов и рефакторинга старых, можем развивать приложение, держа в уме серверные компоненты. Кроме того, нам удалось немного ужать отправляемый пакет, не включая в него <Title /> ! В большинстве случаев должно хватить вариантов 1 и 2. Но, если вы заметите проблемы с производительностью, то можно выжать ещё некоторые оптимизации из вашего RSC-преобразования.  Например, когда мы переносили на RSC наш сайт с документацией, мы придерживались при этом двух паттернов, чтобы выиграть по максимуму. Во-первых, мы обёртывали ключевые серверные компоненты в Suspense, чтобы организовать потоковую передачу тех данных, выборка которых идёт медленно (как было продемонстрировано выше). Всё наше приложение генерируется в статическом виде (кроме одного динамического элемента – журнала изменений в боговой панели, его мы получаем из CMS). Обёртывая эту боковую панель в Suspense, мы выигрываем в следующем: приложению не приходится дожидаться, пока разрешится операция выборки CMS. Кроме того, мы воспользовались соглашением, действующим в loading.js из Next.js 13: здесь под капотом применяется комбинация Suspense и потоковой обработки. Вторая оптимизация, которую мы применили, заключалась в следующем: мы творчески переупорядочили клиентские и серверные компоненты и таким образом позаботились, чтобы на сервере остались самые большие наши библиотеки – например, Prism, которую мы используем для подсветки синтаксиса. А если уж речь зашла о творческом переупорядочивании клиентских и серверных компонентов…  Как же вам удаётся перемешивать клиентские и серверные компоненты? Выше мы говорили о том, что любой компонент, импортированный из клиентского компонента, сам становится клиентским компонентом. Так как же сделать серверный компонент потомком клиентского? Если коротко, передаём серверные компоненты как потомки или пропсы, а не импортируем их. Серверный компонент будет отображаться на сервере, сериализоваться и отправляться в ваш клиентский компонент.  На мой взгляд, во всей путанице с RSC именно этот приём сложнее всего уложить в голове. Но на практике становится проще. Давайте разберём его на нескольких примерах и для начала посмотрим, как всё сделать неправильно. Как НЕ НАДО смешивать клиентские и серверные компоненты  Импортировав ServerComponent в клиентском компоненте, мы отправили ServerComponent на клиент. О, нет! Чтобы всё сделать правильно, нужно подняться на уровень выше, до ближайшего серверного компонента – в данном случае, ServerPage — и уже здесь делать необходимую работу. Как смешивать клиентские и серверные компоненты Ни в коем случае! Но ниже описан паттерн, которым мы часто пользуемся, если хотим добиться, чтобы часть функционала нашего компонента осталась на сервере. Допустим, мы делаем компонент <CodeBlock />. Вероятно, мы захотим, чтобы подсветка синтаксиса так и осталась на сервере, чтобы нам не приходилось отправлять на клиент эту большую библиотеку. Но нам может понадобиться в этом компоненте и некоторая клиентская функциональность, чтобы пользователь мог переключаться при работе между разными фрагментами кода. Первым делом разобьём этот компонент на две половины: CodeBlock.server.js и CodeBlock.client.js. Первая импортирует вторую. (Имена здесь могут быть любыми; мы используем .server и .client, просто чтобы всё было максимально логично.) Теперь, когда у нас есть два этих компонента, давайте максимально облегчим их потребление, применив эту великолепную файловую структуру. Положим два этих файла в каталог, который называется CodeBlock, а также добавим в него файл index.js, имеющий следующий вид:  Теперь любой потребитель может импортировать CodeBlock из ‘components/CodeBlock.js’, а как клиентский, так и серверный компонент остаются прозрачными. Честно говоря, сначала мы просто добавили в наш код console.log на этапе разработки и проверили, откуда идут эти логи: с сервера или из браузера. На первом этапе и это вполне работало, но со временем мы нашли способ получше. Если вы хотите наверняка гарантировать, что ваш серверный компонент никогда не попадёт в пакет для отправки на клиент, то можно импортировать пакет только для сервера. Это особенно удобно, если вы хотите быть уверены, что большая библиотека или секретный ключ не окажутся где не положено. (Правда, если вы пользуетесь Next.js, он сам защитит вас от нечаянной отправки ваших переменных окружения.) Используя пакеты «только для сервера», мы также приобретали ещё одну тонкую, но важную выгоду: повышали читаемость кода и удобство его поддержки. Если человек, занятый поддержкой, видит в самом начале файла пометку server-only, он сразу знает, где именно работает этот файл, и может не держать в уме полную модель всего дерева компонентов. В конце концов, работа с серверными компонентами React имеет свою цену. Речь не только о коварных аспектах CSS-in-JS или контекста React. Важна и дополнительная сложность: важно понимать, что работает на сервере, а что на клиенте, понимать пропитывание, учитывать инфраструктурные издержки и, конечно же, управлять сложностью кода (особенно при смешивании клиентских и серверных компонентов). Каждая грань такой сложности – ещё одна лазейка для багов и проблем с поддержкой кода. При помощи фреймворков эту сложность можно купировать, но полностью устранить нельзя. Решая, хотите ли вы брать на вооружение RSC, взвесьте все эти достоинства и недостатки. Из хорошего – вы сможете уменьшить размер пакетов и, соответственно, ускорить выполнение, что может быть критически важно с точки зрения SEO. Также вы сможете внедрить продвинутые паттерны загрузки данных, при помощи которых можно оптимизировать самые сложные и нагруженные участки работы с данными. Джефф Эскаланте, пытаясь ответить на те же вопросы в своей лекции на конференции Reactathon, подытожил её следующей схемой: Если ваша команда готова вникать в сложности RSC, но взамен получить описанный выигрыш в производительности, то, возможно, RSC как раз для вас подойдут.