Глава 9 Серверные компоненты React

Серверный рендеринг

Книга
React. К вершинам мастерства

Fluent React: Build Fast, Performant, and Intuitive Web Applications

Глубокое погружение во внутреннее устройство React: JSX и продвинутые паттерны, виртуальный DOM и реконциляция, серверный рендеринг, конкурентный режим и серверные компоненты.

RSC-рендеринг vs SSR

Два отдельных процесса: один превращает компоненты в дерево элементов, другой — дерево в HTML. Наведите на узел, чтобы увидеть, как он выглядит на примере <div>Hi!</div>.

RSC — рендеринг серверных компонентов
SSR — серверный рендеринг
Серверные компоненты async function
Что это
const App = async () => {
  return <div>Hi!</div>;
};

Обычная функция, которую можно ожидать через await. Её код остаётся на сервере и в бандл не попадает.

RSC-рендеринг
Дерево элементов React объект JavaScript
Что это
{
  $typeof: Symbol(react.element),
  type: "div",
  props: { children: "Hi!" }
}

Не HTML и не DOM, а описание того, каким должен быть DOM. Это данные — их можно инспектировать и сериализовать.

серверный рендеринг
HTML-поток строка / stream
Что это
ReactDOMServer.renderToString(tree)
// → "<div>Hi!</div>"

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

по сети
Клиент DOM + гидратация
Что это
<div id="root"><div>Hi!</div></div>
hydrateRoot(document, <App />)

Браузер показывает готовую разметку, а React подключает к ней обработчики событий и динамику.

Голубая рамка — RSC-рендеринг, янтарная — SSR. Дерево элементов принадлежит обеим зонам: им заканчивается первый процесс и начинается второй

Теджас Кумар
«По сути, серверные компоненты и серверный рендеринг можно рассматривать как два отдельных независимых процесса, где один процесс заботится исключительно о рендеринге компонентов на сервере и генерировании дерева элементов React, а другой процесс — серверный рендеринг — использует это дерево элементов React и преобразует его в HTML-разметку, которая может быть передана клиентам по сети.» Раздел «Серверный рендеринг», стр. 299

А что по коду?

Обычный express-сервер: раздаёт бандл, на любой маршрут собирает дерево, рендерит его в строку и отдаёт HTML.

server.js
const express = require("express");
const path = require("path");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const App = require("./src/App");

const app = express();

app.use(express.static(path.join(__dirname, "build")));
app.get("*", async (req, res) => {
const rscTree = await turnServerComponentsIntoTreeOfElements(<App />);
const html = ReactDOMServer.renderToString(rscTree);
res.send(` <!DOCTYPE html> <html> <head> <title>My React App</title> </head> <body> <div id="root">${html}</div> <script src="/static/js/main.js"></script> </body> </html> `);
}); app.listen(3000, () => { console.log("Server listening on port 3000"); });

Подсвеченные области кликабельны — справа появится пояснение.

Два процесса в пяти строках

Вся глава — про две строки в середине обработчика: сначала компоненты превращаются в дерево элементов, затем дерево — в HTML. Нажмите на подсвеченную область, чтобы разобрать её отдельно.

Что делает turnServerComponentsIntoTreeOfElements

По сути это набор условий: функция смотрит, что ей передали, и решает, как с этим быть. Пройдём вызов по шагам — для <div>Hi!</div>.

renderJSXToClientJSX.js
async function turnServerComponentsIntoTreeOfElements(jsx) {
if (typeof jsx === "string" || typeof jsx === "number" || typeof jsx === "boolean" || jsx == null) { return jsx; }
if (Array.isArray(jsx)) { return await Promise.all(jsx.map(child => renderJSXToClientJSX(child))); }
if (jsx != null && typeof jsx === "object") {
if (jsx.$$typeof === Symbol.for("react.element")) {
if (typeof jsx.type === "string") { return { ...jsx, props: await renderJSXToClientJSX(jsx.props) }; }
if (typeof jsx.type === "function") { const Component = jsx.type; const props = jsx.props; const returnedJsx = await Component(props); return await renderJSXToClientJSX(returnedJsx); }
throw new Error("Not implemented.");
} else { return Object.fromEntries( await Promise.all( Object.entries(jsx).map(async ([propName, value]) => [ propName, await renderJSXToClientJSX(value), ]) ) ); }
} throw new Error("Not implemented."); }
стек вызовов <div>Hi!</div> { children: "Hi!" } "Hi!"

Сериализация

Элемент React — ещё не DOM-узел, а инструкция, каким должен быть DOM. Сериализация превращает эти инструкции в строку, пригодную для передачи по сети.

{
  $$typeof: Symbol(react.element),
  type: "div",
  props: { children: "Hi!" }
}
'{"$$typeof":"react.element",
  "type":"div",
  "props":{"children":"Hi!"}}'
Теджас Кумар
«Сериализация элементов React является фундаментальным аспектом обеспечения правильного и эффективного отображения вашего приложения при начальной загрузке, поскольку каждый вывод, отображаемый сервером, должен соответствовать клиентскому, чтобы React мог корректно согласовывать и дифференцировать данные.» Раздел «Сериализация», стр. 306

Навигация: мягкие переходы вместо перезагрузки

Нажмите «Перейти» в каждой колонке — разница видна по тому, что обновляется, и по счётчику справа.

Теджас Кумар
«Но мы можем добиться большего: с помощью RSC мы можем реализовать мягкую навигацию, при которой состояние сохраняется между переходами по маршруту. Мы делаем это, отправляя серверу URL, по которому хотим перейти, и сервер отправляет нам обратно дерево JSX для этой страницы. Затем React в браузере повторно отображает страницу с новым деревом JSX, и у нас будет новая страница без обновления всей страницы.» Раздел «Навигация», стр. 308
client.js
// один слушатель на window — делегирование событий
window.addEventListener("click", (event) => {
  if (event.target.tagName !== "A") return;
  event.preventDefault();
  navigate(event.target.href);
});

Как реализовать мягкую навигацию в RSC?

// клиент
async function navigate(url) { const response = await fetch(url, { headers: { "jsx-only": true }, });
const element = JSON.parse(await response.text(), (key, value) => { if (key === "$$typeof") return Symbol.for("react.element"); return value; });
root.render(element); }
// корень получаем при первоначальной гидратации
const root = hydrateRoot(document, <App />);
Теджас Кумар
«Мы получаем корень от React при первоначальной гидратации страницы и можем использовать этот root для отображения в него новых элементов. Вот так работает React "под капотом". Мы просто применяем тот же API, который React использует внутри. Это хорошо: значит, мы не делаем ничего особенного или хакерского, мы просто используем общедоступный API React.» Раздел «Навигация», стр. 309

Серверная сторона: заголовок «jsx-only»

Один и тот же маршрут app.get("*") server.js
app.get("*", async (req, res) => {
  const rscTree = await turnServerComponentsIntoTreeOfElements(
    <Router url={req.url} />
  );

  if (req.headers["jsx-only"]) {
    res.end(JSON.stringify(rscTree, replacer));
  } else {
    res.end(renderHTML(renderToString(rscTree)));
  }
});
обслуживает два сценария и различает их по наличию заголовка.
браузер Клиент
GET /bio
express Сервер
заголовка нет · первая загрузка
rscTree → renderToString → HTML-шаблон
<!DOCTYPE html> … <div id="root">…</div>
<script src="/static/js/main.js">

Полный конвейер: дерево, разметка, каркас страницы и бандл.

jsx-only: true · мягкая навигация
res.end(JSON.stringify(rscTree, replacer))
// без HTML-обёртки и без renderToString

Этап HTML пропускается: у клиента уже есть работающее приложение, ему нужно только новое дерево.

Теджас Кумар
«Вы обратили внимание, что при наличии этого заголовка мы отправляем не JSON, а просто строку? Делаем мы так потому, что нам нужно выполнить JSON.parse на стороне клиента, а JSON.parse ожидает строку, а не объект JSON. Это всего лишь особенность API, но это не так уж и плохо.» Раздел «Навигация», стр. 310

Не всё может быть серверным: пример Counter

Может ли этот компонент быть серверным?

Counter.js
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}
причина 1

useState — API только клиента

Сервер не знает начальное значение count для конкретного пользователя, а «состояние» в серверной среде разделяется многими клиентами — это путаница данных и риск раскрыть чужое. Вдобавок setState — функция, а функции не сериализуемы.

причина 2

onClick — серверы не интерактивны

«Нельзя щёлкнуть по запущенному процессу на сервере», поэтому обработчик события в серверном компоненте — невозможное состояние. Пропсы серверных компонентов должны быть сериализуемы, а функция — нет.

Нажмите на код — разберём по причинам

Теджас Кумар
«Этот компонент никогда не сможет быть серверным по двум причинам. Он использует useState, который является API только клиентской части. … Этот компонент использует onClick, который имеет смысл только для API клиентской части. Это связано с тем, что серверы не интерактивны: вы не можете щёлкнуть по запущенному процессу на сервере, поэтому onClick в серверных компонентах — это что-то вроде невозможного состояния.» Раздел «Внесение обновлений», стр. 311–312

Как нам использовать возможности RSC с этим компонентом?

Никак — пока компонент один. Чтобы возможности серверных компонентов заработали, код нужно разделить на две части: серверную и клиентскую.

Counter.js
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

Нажмите — разделим компонент на части

серверный компонент
ServerCounter.js
function ServerCounter() {
  return (
    <div>
      <h1>Hi friends, look at my nice counter!</h1>
      <p>About me: I like to count things…</p>
      <InteractiveClientPart />
    </div>
  );
}
клиентский компонент
InteractiveClientPart.js
"use client";

function InteractiveClientPart() {
  const [count, setCount] = useState(0);
  return (
    <>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </>
  );
}
только эта часть приложения на самом деле будет доступна нашим пользователям как часть пакета JavaScript
Теджас Кумар
«Мы только что выделили крошечную часть нашего приложения, которая должна быть интерактивной, и только эта часть приложения на самом деле будет доступна нашим пользователям как часть пакета JavaScript, а остальное — нет. Как следствие, мы отправляем по сети пакеты JavaScript значительно меньшего размера, что означает более быструю загрузку и более высокую производительность для наших пользователей как с точки зрения ресурсов процессора, так и с точки зрения сети.» Раздел «Внесение обновлений», стр. 312–313

Как React понимает, где клиентский компонент?

Три состояния одного дерева: что написал разработчик, что собрал сервер и что получилось в браузере. Наведите на любой узел — покажется, чем он является на этом шаге.

1 · модули разработчика
App серверный Обычная функция: выполняется на сервере, её код в клиентский бандл не попадает вообще.
ProductInfo серверный Директивы нет — компонент остаётся серверным по умолчанию и живёт в серверном графе.
Reviews серверный Может ходить в базу и читать файлы: ключи и запросы остаются на сервере, наружу уедет только результат.
AddToCart "use client" Граница графов Строка в начале файла — метка для сборщика: этот модуль и всё, что он импортирует, уходят в клиентский граф и попадают в бандл. Компонент получает право на состояние и обработчики событий — и обязанность быть скачанным. клиентский
рендеринг на сервере
2 · дерево с сервера
<div>… элементы App уже выполнен: вместо компонента в дереве лежат готовые элементы — данные, а не функции.
<section>… элементы То же и здесь: серверный компонент выполнился, от него остался результат.
<ul>… элементы Отзывы уже прочитаны из базы: клиент получит разметку, а не запрос.
ссылка на модуль заполнитель Вместо компонента — адрес модуля
type: {
  $$typeof: Symbol(react.module.reference),
  name: "default",
  filePath: "./src/AddToCart.js",
  id: "add-to-cart-1234"
}
Сервер не выполняет клиентский компонент — он оставляет «дыру» с адресом модуля от сборщика: «дойдёшь до этой точки — возьми вот этот модуль».
бандл в браузере
3 · в браузере
<div>… разметка Пришло готовым: браузеру нечего пересчитывать, кода этого компонента у него нет.
<section>… разметка Тоже пришло готовым — в бандле этих строк нет.
<ul>… разметка Список отзывов уже отрисован сервером.
AddToCart модуль из бандла Дыра заполнена Клиентский React находит ссылку, берёт модуль по её id из бандла и подставляет настоящий компонент — с состоянием и обработчиками.
Теджас Кумар
«Компонент InteractiveClientPart является клиентским компонентом, поэтому он не отображается на сервере. Вместо этого сервер отображает заполнитель для клиентского компонента, который является ссылкой на конкретный модуль, созданный компоновщиком клиентского пакета.» Раздел «Что "под капотом"?», стр. 313–314

Клиентские компоненты тоже выполняются на сервере

«Выполнить компонент» — значит вызвать его функцию с пропсами и получить элемент React. Не «стать интерактивным в браузере», а просто вызвать функцию. Наведите на блок — покажется, что лежит в памяти на этом шаге.

1 · на серверевыполняются, возвращают элементы
Серверный компонент вызвали функцию — получили объект
ProductInfo() вернул объект
{
  $$typeof: Symbol(react.element),
  type: 'section',
  props: { children: [ … ] }
}
Ни разметки, ни HTML — обычный объект в памяти, описание того, что нужно нарисовать.
Клиентский компонент здесь же и точно так же
AddToCart() вернул такой же объект
{
  $$typeof: Symbol(react.element),
  type: 'button',
  props: { children: 'В корзину' }
}
Это следующий проход — рендеринг HTML: там, где в дереве стояла ссылка на модуль, подставляется сам компонент и вызывается. useState вернёт начальное значение, onClick просто ляжет в props — нажимать пока некому.
2 · на сервереэлементы сходятся в одно дерево
Один большой объект все элементы React — и клиентские, и серверные
Одно дерево на всё приложение
{
  type: 'div',
  props: { children: [
    { type: 'section', … },  // серверный
    { type: 'button',  … }   // клиентский
  ] }
}
По объекту не видно, кто его породил: элементы обоих видов лежат в одном дереве вперемешку.
3 · передачадерево уезжает строкой
Строка объект превращается в строку и отправляется клиенту
То же дерево, только текстом
"<div><section>
  <h2>Ноутбук</h2><p>128 900 ₽</p>
</section>
<button>В корзину</button></div>"
Браузер показывает её сразу — ещё до того, как скачается хоть один килобайт JavaScript. Рядом едет payload со ссылками на клиентские модули.
4 · в браузередальше работает одна половина
Серверные компоненты никогда не выполняются на клиенте
В браузере от них только это
<section>
  <h2>Ноутбук</h2>
  <p>128 900 ₽</p>
</section>
Просто разметка. Кода ProductInfo в бандле нет — пересчитать её браузеру нечем.
Клиентские компоненты выполняются исключительно на клиенте
А этот оживает
hydrateRoot(root, tree)
// AddToCart() вызывается снова —
// теперь с настоящими useState и onClick
Модуль берётся из бандла, функция вызывается ещё раз — уже в браузере. Все следующие перерисовки происходят только здесь.
Вот граница, где проявляется отличие серверных компонентов от клиентских: до неё выполнялись оба, после — только клиентские.
Теджас Кумар
«Существует распространённое заблуждение, будто серверные компоненты выполняются только на сервере, а клиентские — только на клиенте. Это не совсем так. … Во время серверного рендеринга клиентские компоненты выполняются на сервере и выводят объекты, представляющие элементы React. Затем эти элементы преобразуются в строку HTML и отправляются клиенту, где браузер отображает HTML-разметку.» Раздел «Нюансы», стр. 316–317

Что далее

Преимущества Пройдено
Антон Помазков
Антон Помазков
Артём Никифоров