В этом техническом "глубоком погружении" (deep dive) мы с нуля реализуем очень простую версию серверных компонентов React.
Данный туториал будет состоять из трех частей (написана пока только эта).
Серьезно, это глубокое погружение!
Этот туториал не объясняет преимуществ серверных компонентов React или как разработать приложение с помощью RSC, или как разработать фреймворк с их помощью. Вместо этого, оно проведет вас через процесс их "изобретения" с нуля.
Эта статья предназначена для людей, которым нравится изучать новые технологии посредством их реализации с нуля. Поэтому предполагается, что вы имеете некоторый опыт в веб-разработке и знакомы с React.
Эта статья не является введением в серверные компоненты. Мы работаем над соответствующей документацией на сайте React. В настоящее время, если ваш фреймворк поддерживает серверные компоненты, пожалуйста, обратитесь к его документации.
В педагогических целях наша реализация будет гораздо менее эффективной, чем та, которая используется в React. В тексте будут отмечены возможности оптимизации, но нашим приоритетом будет концептуальная точность, а не эффективность.
Отправимся в прошлое...
Представим, что вы проснулись однажды утром и обнаружили, что на дворе снова 2003 год. Веб-разработка находится в зачаточном состоянии. Предположим, вы хотите создать персональный блог, отображающий содержимое текстовых файлов, лежащих на сервере. На PHP это могло бы выглядеть так:
<?php
$author = "Jae Doe";
$post_content = @file_get_contents("./posts/hello-world.txt");
?>
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr>
</nav>
<article>
<?php echo htmlspecialchars($post_content); ?>
</article>
<footer>
<hr>
<p><i>(c) <?php echo htmlspecialchars($author); ?>, <?php echo date("Y"); ?></i></p>
</footer>
</body>
</html>
Для повышения читаемости HTML допустим, что такие теги как <nav>
, <article>
и <footer>
тогда уже существовали.
Когда мы открываем http://locahost:3000/hello-world
в браузере, этот скрипт PHP возвращает HTML-страницу с постом блога из файла ./posts/hello-world.txt
. Аналогичный скрипт на современном Node.js может выглядеть так:
import { createServer } from 'http';
import { readFile } from 'fs/promises';
import escapeHtml from 'escape-html';
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
`<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
${escapeHtml(postContent)}
</article>
<footer>
<hr>
<p><i>(c) ${escapeHtml(author)}, ${new Date().getFullYear()}</i></p>
</footer>
</body>
</html>`
);
}).listen(8080);
function sendHTML(res, html) {
res.setHeader("Content-Type", "text/html");
res.end(html);
}
Смотрите этот пример в песочнице.
Представьте, что можете взять с собой в 2003 год рабочий движок Node.js и запустить этот код на сервере. Если вы захотите привнести в этом мир парадигму React, какие возможности вы добавите? И в каком порядке?
Шаг 1: изобретаем JSX
Первое, что бросается в глаза в приведенном выше коде, это прямая манипуляция строками. Мы должны вызывать escapeHtml(postContent)
во избежание обработки содержимого текстового файла как HTML.
Одним из способов решения этой проблемы является отделение логики от "шаблона" (template) и использование специального языка шаблонов (template language), позволяющего внедрять динамические значения для текста и атрибутов, обеззараживать (escape) текст и предоставляющего специфический для домена (domain-specific) синтаксис для условий и циклов. Такой подход использовался некоторыми наиболее популярными серверными фреймворками в 2000-х годах.
Тем не менее, ваши знания React могли бы вдохновить вас сделать следующее:
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
{postContent}
</article>
<footer>
<hr />
<p><i>(c) {author}, {new Date().getFullYear()}</i></p>
</footer>
</body>
</html>
);
}).listen(8080);
Выглядит похоже, но теперь наш шаблон больше не является строкой. Вместо кода для интерполяции строки (string interpolation), мы помещаем подмножество XML в JavaScript. Другими словами, мы только что "изобрели" JSX (JavaScript and XML). JSX позволяет держать разметку максимально близко к соответствующей логике рендеринга, но, в отличие от интерполяции строки, предотвращает ошибки вроде отсутствующих открывающих/закрывающих тегов HTML или рендеринга текстового содержимого без обеззараживания.
Под капотом JSX производит дерево объектов, которое выглядит так:
// Немного упрощенная версия
{
$$typeof: Symbol.for("react.element"), // Говорит React, что это элемент JSX (например, <html>)
type: 'html',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'head',
props: {
children: {
$$typeof: Symbol.for("react.element"),
type: 'title',
props: { children: 'My blog' }
}
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'body',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'nav',
props: {
children: [{
$$typeof: Symbol.for("react.element"),
type: 'a',
props: { href: '/', children: 'Home' }
}, {
$$typeof: Symbol.for("react.element"),
type: 'hr',
props: null
}]
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'article',
props: {
children: postContent
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'footer',
props: {
/* И т.д. */
}
}
]
}
}
]
}
}
Однако, мы должны отправлять браузеру HTML, а не дерево JSON (по крайней мере, на сегодняшний день).
Напишем функцию, которая преобразует JSX в строку HTML. Для этого необходимо определить, как разные типы узлов (nodes) (строка, число, массив или узел JSX с потомками (children)) должны превращаться в кусочки HTML:
function renderJSXToHTML(jsx) {
if (typeof jsx === "string" || typeof jsx === "number") {
// Это строка. Обеззараживаем ее и просто помещаем в HTML.
return escapeHtml(jsx);
} else if (jsx == null || typeof jsx === "boolean") {
// Это пустой узел. Ничего не помещаем в HTML.
return "";
} else if (Array.isArray(jsx)) {
// Это массив узлов. Преобразуем каждый узел в HTML и объединяем в одну строку.
return jsx.map((child) => renderJSXToHTML(child)).join("");
} else if (typeof jsx === "object") {
// Проверяем, является ли объект элементом React JSX (например, <div />).
if (jsx.$$typeof === Symbol.for("react.element")) {
// Преобразуем его в тег HTML.
let html = "<" + jsx.type;
for (const propName in jsx.props) {
if (jsx.props.hasOwnProperty(propName) && propName !== "children") {
html += " ";
html += propName;
html += "=";
html += escapeHtml(jsx.props[propName]);
}
}
html += ">";
html += renderJSXToHTML(jsx.props.children);
html += "</" + jsx.type + ">";
return html;
// Невозможно отрендерить объект.
} else throw new Error("Cannot render an object.");
// Не реализовано.
} else throw new Error("Not implemented.");
}
Смотрите этот пример в песочнице. Прим. пер.: обратите внимание на команду start
в package.json
(nodemon -- --experimental-loader ./node-jsx-loader.js ./server.js
). Выполнение этой команды приводит к тому, что перед запуском сервера код файла server.js
транспилируется с помощь ю Babel (@babel/plugin-transform-react-jsx
) - перед передачей функции renderJSXToHTML
JSX преобразуется в специальный объект. Увидеть этот объект можно, добавив console.log(jsx)
в начало renderJSXToHTML()
и нажав Open logs
в выпадающем меню окна превью.
Преобразование JSX в строку HTML известно как "рендеринг на стороне сервера" (SSR - Server-Side Rendering). Важно отметить, что RSC и SSR - две очень разные вещи (которые, как правило, используется вместе). В этой статье мы начали с SSR, поскольку это первая вещь, которую вы можете попытаться сделать в серверной среде. Но это лишь первый шаг, и в дальнейшем вы увидите существенные различия между ними.
Шаг 2: изобретаем компоненты
Следующей "желанной" возможностью, вероятно, являются компоненты. Независимо от того, где запускается код, на сервере или клиенте, имеет смысл разделить части UI (User Interface - пользовательский интерфейс) на кусочки, присвоить им названия и передавать им информацию с помощью пропов (props, properties - свойства).
Разделим предыдущий пример на два компонента под названием BlogPostPage
и Footer
:
function BlogPostPage({ postContent, author }) {
return (
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
{postContent}
</article>
<Footer author={author} />
</body>
</html>
);
}
function Footer({ author }) {
return (
<footer>
<hr />
<p>
<i>
(c) {author} {new Date().getFullYear()}
</i>
</p>
</footer>
);
}
Далее, заменим встроенное дерево JSX на <BlogPostPage postContent={postContent} author={author} />
:
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
<BlogPostPage
postContent={postContent}
author={author}
/>
);
}).listen(8080);
Если вы попробуете запустить этот код без обновления renderJSXToHTML()
, то итоговый HTML будет выглядеть сломанным:
<!-- Это не выглядит как валидный HTML... -->
<function BlogPostPage({postContent,author}) {...}>
</function BlogPostPage({postContent,author}) {...}>
Проблема в том, что функция renderJSXToHTML
(которая преобразует JSX в HTML) "предполагает", что jsx.type
- это всегда строка с названием тега HTML (например, "html"
, "footer"
или "p"
):
if (jsx.$$typeof === Symbol.for("react.element")) {
// Существующий код, обрабатывающий теги HTML (например, <p>).
let html = "<" + jsx.type;
// ...
html += "</" + jsx.type + ">";
return html;
}
Однако BlogPostPage
- это функция, поэтому выполнение "<" + jsx.type + ">"
"печатает" ее исходный код. Мы не хотим отправлять код функции в названии тега HTML. Вызовем функцию и сериализуем (serialize) возвращаемый ею JSX в HTML:
if (jsx.$$typeof === Symbol.for("react.element")) {
if (typeof jsx.type === "string") { // Это тег (такой как <div>)?
// Существующий код, обрабатывающий теги HTML (например, <p>).
let html = "<" + jsx.type;
// ...
html += "</" + jsx.type + ">";
return html;
} else if (typeof jsx.type === "function") { // Это компонент (такой как <BlogPostPage>)?
// Вызываем компонент с пропами и преобразуем возвращаемый им JSX в HTML.
const Component = jsx.type;
const props = jsx.props;
const returnedJsx = Component(props);
return renderJSXToHTML(returnedJsx);
// Не реализовано.
} else throw new Error("Not implemented.");
}
Теперь при генерации HTML элемент JSX, такой как <BlogPostPage author="Jae Doe" />
, вызывается как функция, которой в качестве аргумента передается { author: "Jae Doe" }
. Эта функция возвращает JSX, который снова передается в renderJSXToHTML()
.
Этого изменения достаточно для добавления поддержки компонентов и передачи пропов.
Смотрите этот пример в песочнице.
Шаг 3: добавляем роутинг
Теперь было бы неплохо добавить еще несколько страниц в блог.
Предположим, что по такому адресу, как /hello-world
, должна отображаться страница конкретного поста с содержимым файла ./posts/hello-world.txt
, а при запросе корневого URL (Uniform Resource Locator - единый указатель ресурсов) - длинная главная страница с содержимым всех постов. Это означает, что мы хотим добавить новую страницу BlogIndexPage
, имеющую общий со страницей BlogPostPage
макет (shared layout), но другое содержимое внутри.
На данный момент компонент BlogPostPage
представляет всю страницу целиком, начиная от корневого <html>
. Извлечем общие для страниц части UI (header
и footer
) из BlogPostPage
в переиспользуемый (reusable) компонент BlogLayout
:
function BlogLayout({ children }) {
const author = "Jae Doe";
return (
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<main>
{children}
</main>
<Footer author={author} />
</body>
</html>
);
}
Содержимым BlogPostPage
является то, что мы хотим поместить (slot) внутрь макета:
function BlogPostPage({ postSlug, postContent }) {
return (
<section>
<h2>
<a href={"/" + postSlug}>{postSlug}</a>
</h2>
<article>{postContent}</article>
</section>
);
}
Вот как будет выглядеть <BlogPostPage>
, вложенный в <BlogLayout>
:
Давайте также добавим новый компонент BlogIndexPage
, показывающий каждый пост в ./posts/*.txt
один за другим:
function BlogIndexPage({ postSlugs, postContents }) {
return (
<section>
<h1>Welcome to my blog</h1>
<div>
{postSlugs.map((postSlug, index) => (
<section key={postSlug}>
<h2>
<a href={"/" + postSlug}>{postSlug}</a>
</h2>
<article>{postContents[index]}</article>
</section>
))}
</div>
</section>
);
}
Мы также можем обернуть его в BlogLayout
, чтобы он имел такую же шапку и подвал:
Наконец, модифицируем код серверного обработчика (server handler) таким образом, чтобы он определял запрашиваемую страницу на основе URL, загружал для нее данные и рендерил ее внутри макета:
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
// Ищем совпадение между URL и страницей и загружаем данные для страницы.
const page = await matchRoute(url);
// Оборачиваем совпавшую страницу в общий макет.
sendHTML(res, <BlogLayout>{page}</BlogLayout>);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8080);
async function matchRoute(url) {
if (url.pathname === "/") {
// Запрашивается главная страница, на которой отображается содержимое всех постов.
// Читаем все файлы в директории постов и загружаем их содержимое.
const postFiles = await readdir("./posts");
const postSlugs = postFiles.map((file) => file.slice(0, file.lastIndexOf(".")));
const postContents = await Promise.all(
postSlugs.map((postSlug) =>
readFile("./posts/" + postSlug + ".txt", "utf8")
)
);
return <BlogIndexPage postSlugs={postSlugs} postContents={postContents} />;
} else {
// Запрашивается страница конкретного поста.
// Читаем соответствующий файл из директории постов.
const postSlug = sanitizeFilename(url.pathname.slice(1));
try {
const postContent = await readFile("./posts/" + postSlug + ".txt", "utf8");
return <BlogPostPage postSlug={postSlug} postContent={postContent} />;
} catch (err) {
throwNotFound(err);
}
}
}
function throwNotFound(cause) {
// Не найдено.
const notFound = new Error("Not found.", { cause });
notFound.statusCode = 404;
throw notFound;
}
Смотрите этот пример в песочнице.
Теперь мы можем перемещаться (navigate) по блогу. Тем не менее, код становится слегка многословным и громоздким. Давайте исправим это в следующем разделе.
Шаг 4: изобретаем асинхронные компоненты
Вы могли заметить, что эта часть компонентов BlogIndexPage
и BlogPostPage
выглядит почти одинаково:
Было бы здорово вынести это в переиспользуемый компонент. Однако, даже если извлечь логику рендеринга в отдельный компонент Post
, нам по-прежнему надо будет как-то передавать content
каждого поста:
function Post({ slug, content }) { // Кто-то должен передавать проп `content` из файла :-(
return (
<section>
<h2>
<a href={"/" + slug}>{slug}</a>
</h2>
<article>{content}</article>
</section>
)
}
Сейчас логика загрузки content
для постов дублируется здесь и здесь. Мы загружаем его за пределами иерархии компонентов, поскольку API (Application Programming Interface - интерфейс прикладного программирования) readFile
является асинхронным, мы не можем использовать его прямо в дереве компонентов (тот факт, что модуль fs
предоставляет синхронные методы не решает проблему, поскольку вместо чтения файлов мы можем обращаться к базе данных или вызывать асинхронную стороннюю библиотеку).
Или все-таки можем?
Если вы привыкли к React на стороне клиента, возможно, вы привыкли к идее о том, что API вроде fs.readFile
не могут вызываться в компонентах. Даже в случае с React SSR интуиция может подсказывать вам, что каждый компонент также должен иметь возможность запускаться в браузере, поэтому серверные API, вроде fs.readFile
, работать не бу дут.
Но если бы вы попытались объяснить это кому-то в 2003 году, они посчитали бы это ограничение довольно странным.
Будем решать проблемы по мере поступления. Пока у нас есть только сервер, поэтому у нас нет необходимости ограничивать компоненты кодом, который работает в браузере. Компонент вполне может быть асинхронным, поскольку сервер может подождать с генерацией HTML для него до тех пор, пока его данные не будут загружены, и он не будет готов к отображению.
Удалим проп content
и сделаем Post
асинхронной функцией, загружающей содержимое файла с помощью вызова await readFile()
:
async function Post({ slug }) {
let content;
try {
content = await readFile("./posts/" + slug + ".txt", "utf8");
} catch (err) {
throwNotFound(err);
}
return (
<section>
<h2>
<a href={"/" + slug}>{slug}</a>
</h2>
<article>{content}</article>
</section>
)
}
Аналогично сделаем BlogIndexPage
асинхронной функцией, отвечающей за перебор постов с пом ощью await readdir()
:
async function BlogIndexPage() {
const postFiles = await readdir("./posts");
const postSlugs = postFiles.map((file) =>
file.slice(0, file.lastIndexOf("."))
);
return (
<section>
<h1>Welcome to my blog</h1>
<div>
{postSlugs.map((slug) => (
<Post key={slug} slug={slug} />
))}
</div>
</section>
);
}
Поскольку Post
и BlogIndexPage
сами загружают данные для себя, мы можем заменить matchRoute()
компонентом <Router>
:
function Router({ url }) {
let page;
if (url.pathname === "/") {
page = <BlogIndexPage />;
} else {
const postSlug = sanitizeFilename(url.pathname.slice(1));
page = <BlogPostPage postSlug={postSlug} />;
}
return <BlogLayout>{page}</BlogLayout>;
}
Наконец, верхнеуровневый серверный обработчик может делегировать всю логику рендеринга компоненту <Router>
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
await sendHTML(res, <Router url={url} />);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8080);
Погодите, нам сначала нужно выполнять асинхронную работу внутри компонентов. Как это сделать?
Найдем место, где вызывается renderJSXToHTML()
:
} else if (typeof jsx.type === "function") {
const Component = jsx.type;
const props = jsx.props;
const returnedJsx = Component(props); // <--- Вот где мы вызываем компоненты
return renderJSXToHTML(returnedJsx);
} else throw new Error("Not implemented.");
Поскольку функции-компоненты теперь могут быть асинхронными, просто добавляем здесь ключевое слово await
:
// ...
const returnedJsx = await Component(props);
// ...
Это означает, что сама функция renderJSXToHTML
теперь также должна быть асинхронной:
async function renderJSXToHTML(jsx) {
// ...
}
Таким образом, любой компонент в дереве может быть асинхронным, а итоговый HTML "ждет" их разрешения (имеется ввиду разрешение промиса).
Обратите внимание на отсутствие в новом коде специальной логики по "подготовке" содержимого всех файлов для BlogIndexPage
в цикле. Компонент BlogIndexPage
по-прежнему рендерит массив компонентов Post
, но теперь каждый Post
знает, как читать содержимое соответствующего файла.
Смотрите этот пример в песочнице.
Обратите внимание, что эта реализация не является идеальной, поскольку каждый
await
является "блокирующим". Например, мы не можем даже начать отправку HTML до его полной генерации. В идеале, нам бы хотелось отправлять HTML по мере его генерации (передавать полезную нагрузку сервера в потоке (stream)). Мы сфокусируемся на потоке данных и оставим это без внимания. Однако, важно отметить, что потоковую передачу можно добавить позже без каких-либо изменений самих компонентов. Каждый компонент используетawait
для ожидания только собственных данных (что неизбежно), но родительские компоненты не должныawait
потомков, даже если они являютсяasync
. Вот почему React может рендерить родительские компоненты до завершения рендеринга дочерних компонентов.
Шаг 5: сохраняем состояние при навигации
В настоящее время наш сервер умеет только рендерить роут в строку HTML:
async function sendHTML(res, jsx) {
const html = await renderJSXToHTML(jsx);
res.setHeader("Content-Type", "text/html");
res.end(html);
}
Это отлично подходит для первой загрузки - браузер оптимизирован для максимально быстрого отображения HTML - но не идеально для навигаций. Мы бы хотели иметь возможность обновлять "только изменившиеся части" на месте (in-place), сохраняя клиентское состояние как внутри, так и вокруг этих частей (например, состояние инпута, видео, попапа и т.д.). Это также сделает мутации (такие как добавление комментария к посту) более плавными.
Для иллюстрации проблемы добавим <input />
в <nav>
внутри компонента BlogLayout
:
<nav>
<a href="/">Home</a>
<hr />
<input />
<hr />
</nav>
Обратите внимание на то, как состояние инпута сбрасывается при навигации по блогу:
Это может быть нормальным для простого блога, но если мы хотим иметь возможность разрабатывать более интерактивные приложения, такое поведение становится неприемлемым. Мы хотим, чтобы пользователь перемещался по страницам без потери локального состояния.
Исправим это в три этапа:
- Добавим некоторый клиентский JS для перехвата навигаций (чтобы иметь возможность повторно запрашивать содержимое без перезагрузки страницы).
- Научим сервер передавать по сети JSX вместо HTML для последующих навигаций.
- Научим клиента применять обновления JSX без уничтожения DOM (Document Object Model - объектная модель документа) (для этого мы будем использовать React).
Шаг 5.1: перехватываем навигации
Нам потребуется некоторая логика на клиенте, поэтому добавим тег <script>
в новом файле client.js
. В этом файле мы перезапишем дефолтное поведение для навигации по сайту таким образом, что клик по ссылке будет вызывать функцию navigate
:
async function navigate(pathname) {
// TODO
}
window.addEventListener("click", (e) => {
// Регистрирует только клики по ссылке.
if (e.target.tagName !== "A") {
return;
}
// Игнорируем "открыть в новом окне".
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
// Игнорируем внешние URL.
const href = e.target.getAttribute("href");
if (!href.startsWith("/")) {
return;
}
// Отключаем перезагрузку страницы браузером, но обновляем URL.
e.preventDefault();
window.history.pushState(null, null, href);
// Вызываем нашу кастомную логику.
navigate(href);
}, true);
window.addEventListener("popstate", () => {
// При нажатии пользователем "Вперед/Назад" также вызываем нашу кастомную логику.
navigate(window.location.pathname);
});
В функции navigate
запрашивается ответ HTML для следующего роута и обновляется DOM:
let currentPathname = window.location.pathname;
async function navigate(pathname) {
currentPathname = pathname;
// Запрашиваем HTML для роута, к которому выполняется переход.
const response = await fetch(pathname);
const html = await response.text();
if (pathname === currentPathname) {
// Извлекаем часть HTML, находящуюся внутри тега <body>.
const bodyStartIndex = html.indexOf("<body>") + "<body>".length;
const bodyEndIndex = html.lastIndexOf("</body>");
const bodyHTML = html.slice(bodyStartIndex, bodyEndIndex);
// Заменяем содержимое страницы.
document.body.innerHTML = bodyHTML;
}
}
Смотрите этот пример в песочнице.
Шаг 5.2: отправляем JSX по сети
Помните объект дерева JSX?
{
$$typeof: Symbol.for("react.element"),
type: 'html',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'head',
props: {
// И т.д.
Добавим в наш сервер новый режим. Когда URL запроса оканчивается на ?jsx
, в ответ отправляется дерево, а не HTML. Это позволит клиенту легко определить, какие части изменились, и точечно обновлять DOM. Это решит проблему сохранения состояния <input>
, но это не единственная причина, по которой мы так делаем. В следующей части (не в этой!) вы увидите, как это позволяет передавать новую информацию (не только HTML) от сервера клиенту.
Для нача ла изменим серверный код для вызова новой функции sendJSX
при наличии поискового параметра (search param) ?jsx
:
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === "/client.js") {
// ...
} else if (url.searchParams.has("jsx")) {
url.searchParams.delete("jsx"); // Очищаем URL, передаваемый <Router>
await sendJSX(res, <Router url={url} />);
} else {
await sendHTML(res, <Router url={url} />);
}
// ...
В sendJSX()
объект дерева преобразуется в строку JSON с помощью JSON.stringify(jsx)
для обеспечения возможности его передачи по сети:
async function sendJSX(res, jsx) {
const jsxString = JSON.stringify(jsx, null, 2);
res.setHeader("Content-Type", "application/json");
res.end(jsxString);
}
Мы будем говорить "отправка JSX", но на самом деле по сети отправляется не JSX (такой как "<Foo />"
). Мы берем объект дерева, произведенный JSX, и превращаем его в строку JSON. Однако формат транспортировки позже изменится (в настоящей реализации RSC используется другой формат, о котором мы поговорим в следующей части этой серии).
Взглянем на то, что передается по сети:
async function navigate(pathname) {
currentPathname = pathname;
const response = await fetch(pathname + "?jsx");
const jsonString = await response.text();
if (pathname === currentPathname) {
alert(jsonString);
}
}
Смотрите этот пример в песочнице. Если вы загрузите главную страницу (/
) и нажмете на ссылку, то увидите уведомление с таким объектом:
{
"key": null,
"ref": null,
"props": {
"url": "http://localhost:3000/hello-world"
},
// ...
}
Мы рассчитывали получить дерево JSX вроде <html>...</html>
, но что-то пошло не так.
Наш начальный JSX выглядел так:
<Router url="http://localhost:3000/hello-world" />
// {
// $$typeof: Symbol.for('react.element'),
// type: Router,
// props: { url: "http://localhost:3000/hello-world" } },
// ...
// }
Превращать этот JSX в JSON для клиента "слишком рано", поскольку мы не знаем, какой JSX Router
хочет отрендерить, а Router
существует только на сервере. Нужно вызвать компонент Router
для того, чтобы выяснить какой JSX необходимо отправить клиенту.
Если мы вызовем функцию Router
с { url: "http://localhost:3000/hello-world" }
в качестве пропа, то получим такой кусок JSX:
<BlogLayout>
<BlogIndexPage />
</BlogLayout>
Опять "слишком рано" преобразовывать этот JSX в JSON для клиента, поскольку мы не знаем, что хочет отрендерить BlogLayout
- и он существует только на сервере. Нужно вызвать BlogLayout
для того, чтобы выяснить, какой JSX он хочет передать клиенту и т.д.
Опытные пользователи React могут задаться вопросом: можем ли мы отправить этот код клиенту для выполнения? Ищите ответ в следующей части серии! Но даже это будет работать только для BlogLayout
, поскольку BlogIndexPage
вызывает fs.readdir()
.
По завершению этого процесса мы получаем дерево JSX, которое не содержит ссылок на серверный код. Например:
<html>
<head>...</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<main>
<section>
<h1>Welcome to my blog</h1>
<div>
...
</div>
</main>
<footer>
<hr />
<p>
<i>
(c) Jae Doe 2003
</i>
</p>
</footer>
</body>
</html>
Это похоже на дерево, которое можно передать в JSON.stringify()
и отправить клиенту.
Напишем функцию renderJSXToClientJSX
. Она принимает кусок JSX в качестве параметра и пытается "разрешить" его серверные части (путем вызова соответствующих компонентов) до тех пор, пока не останется только JSX, понятный клиенту.
Структурно эта функция похожа на renderJSXToHTML()
, но вместо HTML она обходит и возвращает объекты:
async function renderJSXToClientJSX(jsx) {
if (
typeof jsx === "string" ||
typeof jsx === "number" ||
typeof jsx === "boolean" ||
jsx == null
) {
// С этими типами не требуется делать ничего специального.
return jsx;
} else if (Array.isArray(jsx)) {
// Обрабатываем каждый элемент массива.
return Promise.all(jsx.map((child) => renderJSXToClientJSX(child)));
} else if (jsx !== null && typeof jsx === "object") {
if (jsx.$$typeof === Symbol.for("react.element")) {
if (typeof jsx.type === "string") {
// Это компонент (такой как <div />).
// Перебираем его пропы для того, чтобы убедиться в возможности их преобразования в JSON.
return {
...jsx,
props: await renderJSXToClientJSX(jsx.props),
};
} else if (typeof jsx.type === "function") {
// Это кастомный компонент React (такой как <Footer />).
// Вызываем функцию и повторяем процедуру для возвращаемого ею JSX.
const Component = jsx.type;
const props = jsx.props;
const returnedJsx = await Component(props);
return renderJSXToClientJSX(returnedJsx);
// Не реализовано.
} else throw new Error("Not implemented.");
} else {
// Это обычный объект (например, пропы или что-то внутри них).
// Перебираем каждое значение и обрабатываем его на случай, если в нем содержится JSX.
return Object.fromEntries(
await Promise.all(
Object.entries(jsx).map(async ([propName, value]) => [
propName,
await renderJSXToClientJSX(value),
])
)
);
}
// Не реализовано.
} else throw new Error("Not implemented");
}
Редактируем sendJSX()
для преобразования <Router />
в "клиентский JSX" перед его стрингификацией:
async function sendJSX(res, jsx) {
const clientJSX = await renderJSXToClientJSX(jsx);
const clientJSXString = JSON.stringify(clientJSX, null, 2);
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
}
Смотрите этот пример в песочнице.
Теперь клик по ссылке приводит к отображению уведомления с деревом, похожим на HTML - это означает, что мы готовы к его сравнению (diffing)!
Обратите внимание: наша цель - получить нечто работающее, многое осталось без внимания. Формат очень многословен и содержит много повторов, в настоящих RSC используются более компактный формат. Как и в случае с генерацией HTML, плохо, что "ожидается" весь ответ целиком. В идеале нам хотелось бы стримить JSX по частям по мере их готовности и склеивать их на клиенте. Мы также повторно отправляем части общего макета (такие как
<html>
и<nav>
) хотя знаем, что они не изменились. Несмотря на то, что возможность повторного запроса всего экрана (screen) на месте является важной, навигации внутри одного макета не должны приводить к повторному запросу макета. Производственная версия RSC не страдает от этих недостатков, но мы не будем останавливаться на этом сейчас в целях сохранения простоты кода.