что происходитСервер зовёт функцию и ждёт дерево элементов
→на входе <div>Hi!</div>
Дальше идём по шагам: слева подсвечен код, который выполняется сейчас, здесь — что он проверяет и с каким ответом.
Fluent React: Build Fast, Performant, and Intuitive Web Applications
Глубокое погружение во внутреннее устройство React: JSX и продвинутые паттерны, виртуальный DOM и реконциляция, серверный рендеринг, конкурентный режим и серверные компоненты.
Два отдельных процесса: один превращает компоненты в дерево элементов,
другой — дерево в HTML. Наведите на узел, чтобы увидеть,
как он выглядит на примере <div>Hi!</div>.
const App = async () => { return <div>Hi!</div>; };
Обычная функция, которую можно ожидать через await. Её код остаётся на сервере и в бандл не попадает.
{
$typeof: Symbol(react.element),
type: "div",
props: { children: "Hi!" }
}
Не HTML и не DOM, а описание того, каким должен быть DOM. Это данные — их можно инспектировать и сериализовать.
ReactDOMServer.renderToString(tree) // → "<div>Hi!</div>"
Разметка, которую можно отдать по сети сразу — до того, как браузер скачает и выполнит JavaScript.
<div id="root"><div>Hi!</div></div> hydrateRoot(document, <App />)
Браузер показывает готовую разметку, а React подключает к ней обработчики событий и динамику.
Голубая рамка — RSC-рендеринг, янтарная — SSR. Дерево элементов принадлежит обеим зонам: им заканчивается первый процесс и начинается второй
«По сути, серверные компоненты и серверный рендеринг можно рассматривать как два отдельных независимых процесса, где один процесс заботится исключительно о рендеринге компонентов на сервере и генерировании дерева элементов React, а другой процесс — серверный рендеринг — использует это дерево элементов React и преобразует его в HTML-разметку, которая может быть передана клиентам по сети.» Раздел «Серверный рендеринг», стр. 299
Обычный express-сервер: раздаёт бандл, на любой маршрут собирает дерево, рендерит его в строку и отдаёт HTML.
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. Нажмите на подсвеченную область, чтобы разобрать её отдельно.
По сути это набор условий: функция смотрит, что ей передали,
и решает, как с этим быть. Пройдём вызов по шагам —
для <div>Hi!</div>.
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>
Дальше идём по шагам: слева подсвечен код, который выполняется сейчас, здесь — что он проверяет и с каким ответом.
проверяемЭто примитив или массив?
нетв jsx объект элемента { $$typeof, type, props }
Строка, число, boolean и null вернулись бы сразу как есть, массив пошёл бы по детям. Ни то ни другое — идём дальше.
проверяемЭто элемент React?
дау объекта есть маркер $$typeof
Все элементы React — объекты, но не все объекты — элементы React. Отличает их символ Symbol.for("react.element"): через JSON его не подделать.
проверяемКакой у элемента type — строка или функция?
строка"div" — обычный DOM-тег
Вызывать нечего: сам элемент вернётся как есть. Но пропсы нужно обойти — в них могут прятаться компоненты, поэтому функция зовёт саму себя. Уходим на проход 2 с { children: "Hi!" }.
проверяемЭто примитив или массив?
нетв jsx теперь пропсы { children: "Hi!" }
Новый вызов начинается с тех же двух проверок — функция не помнит, откуда её позвали, и разбирается с тем, что пришло.
проверяемЭто элемент React?
нетмаркера нет: $$typeof → undefined
Пропсы — обычный объект, ветка элемента не срабатывает. Управление уходит в else.
что происходитОбходим каждое значение пропсов
→children: "Hi!" уходит на проход 3
Объект разбирается по парам «ключ — значение», и на каждое значение функция зовётся снова. Значение здесь одно — строка «Hi!».
проверяемЭто примитив?
да"Hi!" — строка
Дно рекурсии: примитив возвращается сам собой. Глубже идти некуда — вызовы начинают разворачиваться обратно.
что вернулосьПропсы собрались обратно
→{ children: "Hi!" }
Пары складываются в объект — уже с обойдёнными значениями. Проход 2 отработал и возвращает результат тому, кто его позвал.
что вернулосьЭлемент получил обойдённые пропсы
→{ ...jsx, props: { children: "Hi!" } }
Первый вызов дождался своих пропсов и возвращает элемент с ними. Стек пуст — рекурсия закончилась.
результатОдно дерево элементов вместо трёх вызовов
→{ $$typeof: Symbol(react.element), type: "div", props: { children: "Hi!" } }
Элемент, его пропсы и строка внутри свернулись в один объект. Ни функций, ни компонентов в нём не осталось — только данные. Само по себе это ещё не строка: дерево лежит в памяти сервера, а дальше его либо сериализуют и отправляют клиенту, либо отдают в renderToString и получают HTML.
Элемент React — ещё не DOM-узел, а инструкция, каким должен быть DOM. Сериализация превращает эти инструкции в строку, пригодную для передачи по сети.
{
$$typeof: Symbol(react.element),
type: "div",
props: { children: "Hi!" }
}
'{"$$typeof":"react.element",
"type":"div",
"props":{"children":"Hi!"}}'
Нельзя: JSON символов не знает. Поэтому при сериализации маркер подменяют строкой, а при разборе на клиенте возвращают обратно.
JSON.stringify(element, (key, value) => { if (key === "$$typeof") return "react.element"; return value; });
JSON.parse(text, (key, value) => { if (key === "$$typeof") return Symbol.for("react.element"); return value; });
«Сериализация элементов React является фундаментальным аспектом обеспечения правильного и эффективного отображения вашего приложения при начальной загрузке, поскольку каждый вывод, отображаемый сервером, должен соответствовать клиентскому, чтобы React мог корректно согласовывать и дифференцировать данные.» Раздел «Сериализация», стр. 306
Нажмите «Перейти» в каждой колонке — разница видна по тому, что обновляется, и по счётчику справа.
Браузер запрашивает страницу целиком: обновляется всё, включая шапку и подвал, а введённые данные и скролл сбрасываются.
Клик перехватывается, сервер отдаёт дерево JSX для маршрута: перерисовывается только контент, состояние остаётся на месте.
«Но мы можем добиться большего: с помощью RSC мы можем реализовать мягкую навигацию, при которой состояние сохраняется между переходами по маршруту. Мы делаем это, отправляя серверу URL, по которому хотим перейти, и сервер отправляет нам обратно дерево JSX для этой страницы. Затем React в браузере повторно отображает страницу с новым деревом JSX, и у нас будет новая страница без обновления всей страницы.» Раздел «Навигация», стр. 308
// один слушатель на window — делегирование событий window.addEventListener("click", (event) => { if (event.target.tagName !== "A") return; event.preventDefault(); navigate(event.target.href); });
// клиент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 />);
Вся «магия» навигации собрана из официальных API:
fetch, JSON.parse с reviver
и root.render(). Нажмите на шаг, чтобы
разобрать его отдельно.
«Мы получаем корень от React при первоначальной гидратации страницы
и можем использовать этот root для отображения в него
новых элементов. Вот так работает React "под капотом". Мы просто
применяем тот же API, который React использует внутри. Это хорошо:
значит, мы не делаем ничего особенного или хакерского, мы просто
используем общедоступный API React.»
Раздел «Навигация», стр. 309
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))); } });обслуживает два сценария и различает их по наличию заголовка.
rscTree → renderToString → HTML-шаблон <!DOCTYPE html> … <div id="root">…</div> <script src="/static/js/main.js">
Полный конвейер: дерево, разметка, каркас страницы и бандл.
res.end(JSON.stringify(rscTree, replacer)) // без HTML-обёртки и без renderToString
Этап HTML пропускается: у клиента уже есть работающее приложение, ему нужно только новое дерево.
«Вы обратили внимание, что при наличии этого заголовка мы отправляем не JSON, а просто строку? Делаем мы так потому, что нам нужно выполнитьJSON.parseна стороне клиента, аJSON.parseожидает строку, а не объект JSON. Это всего лишь особенность API, но это не так уж и плохо.» Раздел «Навигация», стр. 310
Может ли этот компонент быть серверным?
function Counter() { const [count, setCount] = useState(0); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>+</button> </div> ); }
Сервер не знает начальное значение count
для конкретного пользователя, а «состояние» в серверной
среде разделяется многими клиентами — это путаница данных
и риск раскрыть чужое. Вдобавок setState —
функция, а функции не сериализуемы.
«Нельзя щёлкнуть по запущенному процессу на сервере», поэтому обработчик события в серверном компоненте — невозможное состояние. Пропсы серверных компонентов должны быть сериализуемы, а функция — нет.
Нажмите на код — разберём по причинам
«Этот компонент никогда не сможет быть серверным по двум причинам. Он используетuseState, который является API только клиентской части. … Этот компонент используетonClick, который имеет смысл только для API клиентской части. Это связано с тем, что серверы не интерактивны: вы не можете щёлкнуть по запущенному процессу на сервере, поэтомуonClickв серверных компонентах — это что-то вроде невозможного состояния.» Раздел «Внесение обновлений», стр. 311–312
Никак — пока компонент один. Чтобы возможности серверных компонентов заработали, код нужно разделить на две части: серверную и клиентскую.
function Counter() { const [count, setCount] = useState(0); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>+</button> </div> ); }
Нажмите — разделим компонент на части
function ServerCounter() { return ( <div> <h1>Hi friends, look at my nice counter!</h1> <p>About me: I like to count things…</p> <InteractiveClientPart /> </div> ); }
"use client"; function InteractiveClientPart() { const [count, setCount] = useState(0); return ( <> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>+</button> </> ); }
«Мы только что выделили крошечную часть нашего приложения, которая должна быть интерактивной, и только эта часть приложения на самом деле будет доступна нашим пользователям как часть пакета JavaScript, а остальное — нет. Как следствие, мы отправляем по сети пакеты JavaScript значительно меньшего размера, что означает более быструю загрузку и более высокую производительность для наших пользователей как с точки зрения ресурсов процессора, так и с точки зрения сети.» Раздел «Внесение обновлений», стр. 312–313
Три состояния одного дерева: что написал разработчик, что собрал сервер и что получилось в браузере. Наведите на любой узел — покажется, чем он является на этом шаге.
type: {
$$typeof: Symbol(react.module.reference),
name: "default",
filePath: "./src/AddToCart.js",
id: "add-to-cart-1234"
}
Сервер не выполняет клиентский компонент — он
оставляет «дыру» с адресом модуля от сборщика:
«дойдёшь до этой точки — возьми вот этот модуль».
«Компонент InteractiveClientPart является клиентским
компонентом, поэтому он не отображается на сервере. Вместо этого
сервер отображает заполнитель для клиентского компонента, который
является ссылкой на конкретный модуль, созданный компоновщиком
клиентского пакета.»
Раздел «Что "под капотом"?», стр. 313–314
«Выполнить компонент» — значит вызвать его функцию с пропсами и получить элемент React. Не «стать интерактивным в браузере», а просто вызвать функцию. Наведите на блок — покажется, что лежит в памяти на этом шаге.
{
$$typeof: Symbol(react.element),
type: 'section',
props: { children: [ … ] }
}
Ни разметки, ни HTML — обычный объект в памяти,
описание того, что нужно нарисовать.
{
$$typeof: Symbol(react.element),
type: 'button',
props: { children: 'В корзину' }
}
Это следующий проход — рендеринг HTML: там, где
в дереве стояла ссылка на модуль, подставляется
сам компонент и вызывается. useState
вернёт начальное значение, onClick
просто ляжет в props — нажимать пока некому.
{
type: 'div',
props: { children: [
{ type: 'section', … }, // серверный
{ type: 'button', … } // клиентский
] }
}
По объекту не видно, кто его породил: элементы
обоих видов лежат в одном дереве вперемешку.
"<div><section>
<h2>Ноутбук</h2><p>128 900 ₽</p>
</section>
<button>В корзину</button></div>"
Браузер показывает её сразу — ещё до того, как
скачается хоть один килобайт JavaScript. Рядом
едет payload со ссылками на клиентские модули.
<section> <h2>Ноутбук</h2> <p>128 900 ₽</p> </section>Просто разметка. Кода
ProductInfo
в бандле нет — пересчитать её браузеру нечем.
hydrateRoot(root, tree) // AddToCart() вызывается снова — // теперь с настоящими useState и onClickМодуль берётся из бандла, функция вызывается ещё раз — уже в браузере. Все следующие перерисовки происходят только здесь.
«Существует распространённое заблуждение, будто серверные компоненты выполняются только на сервере, а клиентские — только на клиенте. Это не совсем так. … Во время серверного рендеринга клиентские компоненты выполняются на сервере и выводят объекты, представляющие элементы React. Затем эти элементы преобразуются в строку HTML и отправляются клиенту, где браузер отображает HTML-разметку.» Раздел «Нюансы», стр. 316–317
Антон Помазков
Антон Помазков
Артём Никифоров
Артём Никифоров
Артём Никифоров