[Перевод] Подробно о том, как работают React Server Components
React Server Components (RSC) — интересная новая фича в React. Есть вероятность, что в ближайшем будущем она сильно повлияет на скорость загрузки страниц, размер бандлов и то, как мы будем писать приложения на React. Мы в Plasmic (место работы автора) делаем визуальный конструктор для React и очень заботимся о производительности. Многие из наших клиентов используют Plasmic для создания маркетинговых сайтов и сайтов электронной коммерции, и производительность там критически важна. Так что хотя RSC — пока что ранняя экспериментальная функция React 18, мы разобрались, как она работает под капотом. Об этом и расскажем в статье.
Что такое React Server Components?
React Server Components позволяют серверу и клиенту совместно работать при рендеринге вашего приложения React. Рассмотрим типичное дерево React-элементов: обычно это компоненты, которые рендерят другие компоненты и т.д. RSC позволяет рендерить некоторые компоненты дерева на сервере, а другие — в браузере.
Вот небольшая иллюстрация. Она показывает конечную цель: дерево компонентов, где оранжевые компоненты рендерятся на сервере, а синие — на клиенте.
Разве это не SSR?
React Server Component не является Server Side Rendering! Это немного сбивает с толку, потому что и там и там в названии есть «сервер», и оба выполняют «работу» на сервере. Но гораздо проще понимать их как две отдельные ортогональные функции. Использование RSC не требует использования SSR, и наоборот.
SSR симулирует среду для рендеринга дерева React в html, он не делает различий между серверными и клиентскими компонентами и рендерит их одинаково.
Однако можно комбинировать SSR и RSC, чтобы выполнять рендеринг на стороне сервера с серверными компонентами и правильно hydrate-ить их в браузере.
В чем польза?
До RSC все компоненты React были «клиентскими» — все они работают в браузере.
Когда браузер открывает React-приложение, он загружает код для всех нужных компонентов, строит дерево элементов и рендерит его в DOM (или «гидрейтит» (hydrate) DOM, если вы используете SSR). Браузер — хорошее место для этого, потому что он позволяет создавать интерактив в приложении. Вы можете устанавливать обработчики событий, отслеживать состояние, изменять дерево компонентов в ответ на события и эффективно обновлять DOM. Казалось бы, зачем что-то рендерить на сервере?
Вот некоторые преимущества этого подхода по сравнению с браузером:
Сервер ближе к данным — будь то базы данных, GraphQL-эндпоинты или файловая система. Сервер может напрямую забрать нужные вам данные, не обращаясь куда-то по апишке, и обычно он «ближе» к источникам данных, поэтому может получать данные быстрее.
Сервер может «дешевле» использовать «тяжелые» модули — например, npm-пакет для рендеринга разметки в html — потому что серверу не нужно загружать зависимости каждый раз, когда они используются, в отличие от браузера, который должен загружать весь используемый код.
Короче. React Server Components позволяют серверу и браузеру делать то, что у них получается лучше всего.
Серверные компоненты ориентируются на фетчинг данных и рендеринг контента, а клиентские компоненты могут сосредоточиться на интерактивности. Результат — более быстрая загрузка страниц, меньший размер бандлов и более довольные пользователи :)
Верхнеуровневая картина
Сначала разберемся, как это работает.
Мои дети любят украшать кексы, но не любят их печь. Попросить их приготовить и украсить кексы с нуля было бы кошмаром. Мне бы пришлось дать им муку, сахар, пачки масла, подпустить к духовке, прочитать кучу инструкций и потратить целый день.
Но — внезапно — я могу сделать «задачи» по выпечке намного быстрее! Если я сделаю часть работы заранее — испеку кексы, приготовлю глазурь, а затем раздам детям это вместо ингредиентов — они сразу приступят к самой веселой части — украшению! А мне не нужно волноваться о том, что им придется работать с духовкой. Победа!
Для такого разделения труда RSC и предназначены — позвольте серверу заранее сделать то, что он может сделать лучше, прежде чем передать все остальное браузеру. При этом серверу придется передавать меньше — вместо целого пакета муки и этой чёртовой духовки гораздо удобнее передать 12 маленьких кексов!
Рассмотрите дерево React для вашей страницы, где некоторые компоненты будут рендериться на сервере, а некоторые — на клиенте.
Вот один упрощенный способ представить верхнеуровневую стратегию: сервер просто рендерит «серверные» компоненты, как обычно, превращая ваши компоненты React в нативные html-элементы — как div
и p
. Но всякий раз, когда он встречает «клиентский» компонент, предназначенный для рендеринга в браузере, то просто выводит плейсхолдер с инструкциями заполнить его правильным клиентским компонентом и пропсами. Затем браузер берет вывод плейсхолдера, заполняет места клиентскими компонентами и вуаля! Готово.
Вообще-то это работает не совсем так, скоро мы погрузимся в детали. Но держать в голове общую картину все равно полезно.
Разделение на серверные и клиентские компоненты
Для начала — что такое вообще «серверный компонент»? Какие компоненты «для сервера», а какие «для клиента»?
Команда React определила это на основе расширения файла, в котором записан компонент: если он заканчивается на .server.jsx
, он содержит серверные компоненты; если на .client.jsx
, то клиентские компоненты. Если ни то ни другое, он содержит компоненты, которые можно использовать и как серверные, и как клиентские.
Это определение прагматично — и людям, и сборщику легко отличить их. В особенности сборщикам, потому что теперь они могут по-разному обрабатывать клиентские компоненты, проверяя имена файлов. Вообще сборщик играет важную роль в обеспечении работы RSC, мы позже это увидим.
Поскольку серверные компоненты работают на сервере, а клиентские — на клиенте, существует множество ограничений, что каждый из них может делать. Но самое важное — клиентские компоненты не могут импортировать серверные! Это связано с тем, что серверные компоненты не могут быть запущены в браузере и могут иметь код, который не работает в браузере. Если бы клиентские компоненты зависели от серверных, то в конце концов мы бы включили эти недопустимые зависимости в браузерные сборки.
Последний пункт может запутать, потому что означает, что такие клиентские компоненты запрещены:
// ClientComponent.client.jsx
// NOT OK:
import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
return (
)
}
Но если клиентские компоненты не могут импортировать серверные — и, следовательно, не могут включать их — как получается дерево, в котором серверные и клиентские компоненты чередуются друг с другом, как на рисунке в начале статьи? Как можно расположить серверные компоненты (оранжевые) под клиентскими (синими)?
Хотя вы не можете импортировать и рендерить серверные компоненты из клиентских, вы все равно можете применять композицию компонентов. То есть клиентский компонент по-прежнему может принимать чилдренов в качестве пропсов, которые являются просто абстрактными ReactNode
, а эти ReactNode
могут рендериться серверными компонентами. Например:
// ClientComponent.client.jsx
export default function ClientComponent({ children }) {
return (
Hello from client land
{children}
)
}
// ServerComponent.server.jsx
export default function ServerComponent() {
return Hello from server land
}
// OuterServerComponent.server.jsx
// OuterServerComponent can instantiate both client and server
// components, and we are passing in a as
// the children prop to the ClientComponent.
import ClientComponent from './ClientComponent.client'
import ServerComponent from './ServerComponent.server'
export default function OuterServerComponent() {
return (
)
}
Это ограничение сильно влияет на организацию структуры компонентов для использования преимуществ RSC.
Жизненный цикл рендера RSC
Давайте углубимся в детали. Что на самом деле происходит, когда вы пытаетесь рендерить RSC?
1. Сервер получает запрос на рендеринг
Сервер должен выполнять некоторый рендеринг. Поэтому жизнь страницы, использующей RSC, всегда начинается на сервере в ответ на некоторый вызов API для рендеринга компонента React.
«Корневой» компонент всегда является серверным, который может рендерить другие серверные или клиентские компоненты. На основе информации из запроса сервер определяет, какой серверный компонент и какие пропсы использовать.
Запрос обычно приходит в виде запроса страницы по определенному URL. Хотя, например, Shopify Hydrogen имеет более специализированные методы, а в официальном демо команды React показана сырая реализация.
2. Сервер сериализует корневой элемент компонента в JSON
Конечная цель — преобразовать исходный корневой серверный компонент в дерево базовых HTML-тегов и «плейсхолдеров» клиентских компонентов. Затем мы сериализуем это дерево, отправляем его в браузер, а браузер десериализует, заполняет клиентские плейсхолдеры реальными компонентами и рендерит конечный результат.
Итак, следуя приведенному выше примеру — предположим, что мы хотим рендерить
. Можем ли мы просто выполнить JSON.stringify(
, чтобы получить сериализованное дерево элементов?
Почти, но не совсем. Вспомните, что на самом деле представляет собой элемент React — объект с полем type
, представляющим собой или строку — для базового тега html
, например "div"
— или функцию — для инстанса React-компонента.
// React element for oh my
> React.createElement("div", { title: "oh my" })
{
$$typeof: Symbol(react.element),
type: "div",
props: { title: "oh my" },
...
}
// React element for oh my
> function MyComponent({children}) {
return {children};
}
> React.createElement(MyComponent, { children: "oh my" });
{
$$typeof: Symbol(react.element),
type: MyComponent // reference to the MyComponent function
props: { children: "oh my" },
...
}
Когда у вас есть элемент компонента, а не базовый элемент HTML-тега, поле type
ссылается на функцию компонента, а функции не сериализуются в JSON.
Чтобы все правильно преобразовать в JSON, React передает специальную функцию-заменитель в JSON.stringify()
, которая правильно обрабатывает эти ссылки на функции компонентов — resolveModelToJSON ()в ReactFlightServer.js.
В частности, всякий раз, когда React видит элемент, который нужно сериализовать, то:
если это базовый HTML-тег (поле
type
представляет собой строку типа »div
»), он уже сериализуем!если это серверный компонент, вызывается его функция (хранится в поле
type
) с пропсами и сериализуется результат. Это эффективно «рендерит» серверный компонент. Задача в том, чтобы превратить все серверные компоненты в базовые HTML-тегиесли это клиентский компонент, то… он тоже уже сериализуем! Поле
type
на самом деле уже указывает на module reference object, а не на функцию компонента. Удивились?
Что такое объекты «ссылки на модуль»?
RSC вводит новое возможное значение для поля type
элемента React — «ссылка на модуль». Вместо функции компонента это сериализуемая «ссылка» на нее.
Например, ClientComponent
может выглядеть примерно так:
{
$$typeof: Symbol(react.element),
// The type field now has a reference object,
// instead of the actual component function
type: {
$$typeof: Symbol(react.module.reference),
// ClientComponent is the default export...
name: "default",
// from this file!
filename: "./src/ClientComponent.client.js"
},
props: { children: "oh my" },
}
Но где происходит эта ловкость рук, когда мы преобразовываем ссылки на функции клиентского компонента в сериализуемые объекты «ссылки на модуль»?
Как оказалось, именно сборщик выполняет этот фокус! Команда React опубликовала официальную поддержку RSC для Webpack в react-server-dom-webpack
в качестве webpack-loader или node-register. Когда серверный компонент импортирует что-то из файла*.client.jsx
, вместо объекта импорта он получает только ссылку на модуль, содержащий имя файла и название для экспорта. Ни одна функция клиентского компонента никогда не будет частью React-дерева, построенного на сервере.
Рассмотрим снова пример выше, где мы пытаемся сериализовать
. Мы получим JSON типа такого:
{
// The ClientComponent element placeholder with "module reference"
$$typeof: Symbol(react.element),
type: {
$$typeof: Symbol(react.module.reference),
name: "default",
filename: "./src/ClientComponent.client.js"
},
props: {
// children passed to ClientComponent, which was .
children: {
// ServerComponent gets directly rendered into html tags;
// notice that there's no reference at all to the
// ServerComponent - we're directly rendering the `span`.
$$typeof: Symbol(react.element),
type: "span",
props: {
children: "Hello from server land"
}
}
}
}
Сериализуемое React-дерево
В конце этого процесса мы надеемся получить дерево React, которое выглядит на сервере примерно так:
Все пропсы должны быть сериализуемыми
Раз мы сериализуем все дерево компонентов в JSON, все пропсы, которые вы передаете клиентским компонентам или базовым тегам html, также должны быть сериализуемыми. Это означает, что из серверного компонента вы не можете передать обработчик событий в качестве пропса.
// NOT OK: server components cannot pass functions as a prop
// to its descendents, because functions are not serializable.
function SomeServerComponent() {
return
}
Однако обратите внимание: во время процесса RSC, когда мы сталкиваемся с клиентским компонентом, мы не вызываем функции клиентского компонента и не «спускаемся» в клиентские компоненты.
Итак, если у вас есть клиентский компонент, который создает другой клиентский компонент:
function SomeServerComponent() {
return Hello world! ;
}
function ClientComponent1({children}) {
// It is okay to pass a function as prop from client to
// client components
return {children} ;
}
ClientComponent2
вообще не отображается в этом дереве RSC в JSON. Вместо этого мы увидим только элемент со ссылкой на модуль и пропсы для ClientComponent1
. Таким образом, для ClientComponent1
является совершенно законным передавать обработчик событий в качестве пропса для ClientComponent2
.
3. Браузер восстанавливает дерево React
Браузер получает исходящий JSON с сервера и теперь должен начать воссоздавать дерево компонентов для отображения в браузере. Всякий раз, когда мы сталкиваемся с элементом, где type
является ссылкой на модуль, мы хотим заменить ее ссылкой на реальную функцию клиентского компонента.
Эта работа снова требует помощи нашего сборщика. Именно он заменил функции клиентских компонентов ссылками на модули на сервере и теперь знает, как заменить ссылки на модули реальными функциями клиентских компонентов в браузере.
Реконструированное дерево React будет выглядеть примерно так, только с нативными тегами и загруженными клиентскими компонентами:
Затем мы просто рендерим и коммитим это дерево в DOM, как обычно.
Работает ли это с Suspense?
Да! Suspense играет неотъемлемую роль во всех вышеперечисленных шагах.
Мы намеренно умалчиваем о Suspense в этой статье, потому что Suspense сама по себе является огромной темой на отдельную статью. Если вкратце, Suspense позволяет выдавать промис из ваших компонентов, когда им нужно что-то, что еще не готово: данные, lazy-load других компонентов и т. д. Эти промисы перехватываются на «границе Suspense» — всякий раз, когда промис выдается из рендеринга поддерева Suspense, React приостанавливает рендеринг этого поддерева до тех пор, пока промис не будет разрешен, а затем повторяет попытку.
Когда мы вызываем функции серверного компонента на сервере для генерации выходных данных RSC, эти функции могут генерировать промис при получении необходимых им данных. Когда мы сталкиваемся с таким промисом, мы выводим плейсхолдер. Как только промис разрешен, мы снова пытаемся вызвать функцию серверного компонента и в случае успеха вывести завершенный фрагмент. На самом деле мы создаем поток вывода RSC, приостанавливая, когда появляются промисы, и передавая дополнительные чанки по мере их разрешения.
Точно так же в браузере. Мы передаем исходящий RSC JSON из нашего вышеуказанного вызова fetch()
. Этот процесс также может закончиться генерацией промиса, если он встретит плейсхолдер suspense в выводе (где сервер обнаружил промис) и еще не увидел содержимое плейсхолдера в потоке (подробнее здесь). Или он также может выдать промис, если встретит ссылку на модуль клиентского компонента, но при этом функция этого клиентского компонента еще не загружена в браузер — в этом случае сборщик в рантайме должен будет получить нужные чанки.
Благодаря Suspense сервер может стримить вывод RSC, пока серверные компоненты получают данные, а браузер последовательно получает вывод и рендерит компоненты, извлекая бандлы клиентских компонентов динамически, по мере необходимости.
Формат передачи данных RSC
Но что именно выводит сервер? Почуяли неладное при словах «JSON» и «поток»? И правильно. Так что же представляют данные, которые сервер стримит в браузер?
Это простой формат с одним JSON blob в каждой строке, c тегом ID. Вот вывод RSC для нашего примера
:
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server land"}]}]
В приведенном выше фрагменте:
строка, начинающаяся с
M
, определяет ссылку на модуль клиентского компонента с информацией, необходимой для поиска функции компонента в клиентских бандлах.строка, начинающаяся с
J
, определяет фактическое дерево элементов React, где такие элементы, как@1
, ссылаются на клиентские компоненты, определенныеM
-строками
Этот формат легко стримить — как только клиент прочитает всю строку, он может разобрать кусок JSON и сразу его обработать. Если бы сервер столкнулся с границами suspense во время рендеринга, вы бы увидели несколько J
строк, соответствующих каждому фрагменту по мере его разрешения.
Давайте сделаем наш пример немного интереснее…
// Tweets.server.js
import { fetch } from 'react-fetch' // React's Suspense-aware fetch()
import Tweet from './Tweet.client'
export default function Tweets() {
const tweets = fetch(`/tweets`).json()
return (
{tweets.slice(0, 2).map((tweet) => (
-
))}
)
}
// Tweet.client.js
export default function Tweet({ tweet }) {
return alert(`Written by ${tweet.username}`)}>{tweet.body}
}
// OuterServerComponent.server.js
export default function OuterServerComponent() {
return (
)
}
Как выглядит поток RSC в этом случае?
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":[["$","span",null,{"children":"Hello from server land"}],["$","$2",null,{"fallback":"Loading tweets...","children":"@3"}]]}]
M4:{"id":"./src/Tweet.client.js","chunks":["client8"],"name":""}
J3:["$","ul",null,{"children":[["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}],["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}]]}]
Строка J0
теперь имеет дополнительный дочерний элемент — новую границу Suspense
, где children
указывают на ссылку @3
. Здесь интересно то, что @3
еще не определен!
Когда сервер заканчивает загрузку твитов, он выводит строки для M4
— которые определяют ссылку модуля на компонент Tweet.client.js
— и J3
— которые определяют другое дерево элементов React, которое должно быть заменено на то, где находится @3
(и снова обратите внимание, что J3
children ссылаются на компонент Tweet
, определенный в M4
).
Еще один интересный момент. Бандлер автоматически помещает ClientComponent
и Tweet
в два отдельных пакета, что позволяет браузеру отложить загрузку Tweet
на потом.
Использование формата RSC
Как превратить этот поток RSC в настоящие React-элементы в браузере? react-server-dom-webpack
содержит энтрипоинты, которые принимают ответ RSC и воссоздают дерево элементов. Вот упрощенная версия того, как может выглядеть корневой клиентский компонент:
import { createFromFetch } from 'react-server-dom-webpack'
function ClientRootComponent() {
// fetch() from our RSC API endpoint. react-server-dom-webpack
// can then take the fetch result and reconstruct the React
// element tree
const response = createFromFetch(fetch('/rsc?...'))
return {response.readRoot() /* Returns a React element! */}
}
Вы просите react-server-dom-webpack
прочитать ответ RSC от endpoint API. Затемresponse.readRoot()
возвращает элемент, который обновляется по мере обработки потока ответа. Прежде чем какой-либо из потоков будет считан, он немедленно выдаст promise, потому что содержимое еще не готово. Затем, когда он обрабатывает первый J0
, он создает соответствующее дерево элементов React и резолвит promise. React возобновляет рендеринг, но сталкивается с еще не готовой ссылкой @3
. Выдается другой promise. И как только он считывает J3
, этот promise разрешается, и React снова возобновляет рендеринг, на этот раз до завершения. Следовательно, по мере того, как мы будем передавать ответ RSC, мы будем продолжать обновлять и рендерить дерево элементов, которое у нас есть, в фрагментах, определенных границами Suspense, до завершения.
Не проще ли вывести простой HTML?
Зачем изобретать совершенно новый формат передачи? Задача клиента — восстановить дерево элементов React. Гораздо проще решить еее из этого формата, чем из html, где нам пришлось бы анализировать html для создания элементов React. Обратите внимание, что восстановление дерева элементов React важно, так как это позволяет нам объединять последующие изменения в дереве React с минимальными коммитами в DOM.
Плюсы перед фетчингом данных из клиентских компонентов
Если нам все равно нужно сделать запрос API к серверу, чтобы получить содержимое, действительно ли это лучше, чем делать запрос только на получение данных —, а затем полностью выполнять рендеринг в клиенте, как обычно?
В конечном счете это зависит от того, что вы рендерите. С помощью RSC вы получаете денормализованные, «обработанные» данные, которые напрямую преобразуются в то, что вы показываете пользователю. Поэтому вы выигрываете, если рендерите только небольшой фрагмент данных, которые вы бы извлекали — или если сам рендеринг требует много кода, который вы не хотели бы загружать в браузер. И если для рендеринга требуется несколько выборок данных, которые зависят друг от друга каскадно, то лучше, чтобы выборка происходила на сервере, где задержка получения данных намного ниже, чем в браузере.
Как насчет рендеринга на стороне сервера?
С React 18 можно комбинировать SSR и RSC, чтобы можно было генерировать html на сервере, а затем выполнять hydrate с помощью RSC в браузере.
Обновление того, что, отрендерил сервер
Что, если нужно, чтобы серверные компоненты рендерили что-то новое — например, если вы переключаетесь между просмотром страницы одного продукта на другой?
Опять же, поскольку рендеринг происходит на сервере, требуется еще один вызов API на сервер, чтобы получить новое содержимое в формате RSC. Хорошая новость заключается в том, что как только браузер получает новое содержимое, он может построить новое дерево элементов React и выполнить обычную синхронизацию с предыдущим деревом React, чтобы определить минимальные обновления, необходимые для DOM, при этом сохраняя обработчики событий и состояния в клиентских компонентах. Для клиентских компонентов это обновление ничем не отличалось бы от того, если бы оно происходило полностью в браузере.
На данный момент вы должны повторно рендерить все дерево React из корневого серверного компонента, хотя в будущем будет возможно делать это для поддеревьев.
Зачем использовать метафреймворк для RSC?
Команда React заявила, что изначально, вместо непосредственного использования в простых проектах React, RSC предназначался для применения через метафреймворки — такие как Next.js или Shopify Hydrogen. Но почему? Что дает фреймворк?
Может облегчить жизнь, хотя и необязательно. Метафреймворки предоставляют более удобные надстройки и абстракции, поэтому вам не придется думать о стриминге RSC на сервере и его использовании в браузере. Метафреймворки также поддерживают рендеринг на стороне сервера, и они выполняют работу по обеспечению того, чтобы сгенерированный сервером html мог быть правильно загидрейчен, если вы используете серверные компоненты.
Для правильной работы с клиентскими компонентами в RSC нужно поработать со сборщиками. Уже есть интеграция с webpack, и Shopify работает над интеграцией vite. Эти плагины должны быть частью репозитория React, потому что многие части, необходимые для RSC, не публикуются в виде публичных npm. Однако после разработки эти части должны быть пригодны для использования без фреймворков.
Заключение: готов ли RSC к использованию?
Сейчас React Server Components доступны в качестве экспериментальной функции в Next.js и в текущей версии Developer Preview для Shopify Hydrogen, но ни один из них не готов к использованию в продакшне.
Хотя нет никаких сомнений в том, что React Server Component еще станет важной частью React. Это возможность более быстрой загрузки страниц, меньших бандлов и более короткого time-to-interactive.