Skip to main content

RSC с нуля. Часть 1. Серверные компоненты

· 35 min read

Источник..

В этом техническом "глубоком погружении" (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>

Обратите внимание на то, как состояние инпута сбрасывается при навигации по блогу:

https://user-images.githubusercontent.com/810438/242691948-8829291e-89b4-44b9-8e77-8e10de42c9e1.mp4

Это может быть нормальным для простого блога, но если мы хотим иметь возможность разрабатывать более интерактивные приложения, такое поведение становится неприемлемым. Мы хотим, чтобы пользователь перемещался по страницам без потери локального состояния.

Исправим это в три этапа:

  1. Добавим некоторый клиентский JS для перехвата навигаций (чтобы иметь возможность повторно запрашивать содержимое без перезагрузки страницы).
  2. Научим сервер передавать по сети JSX вместо HTML для последующих навигаций.
  3. Научим клиента применять обновления 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 не страдает от этих недостатков, но мы не будем останавливаться на этом сейчас в целях сохранения простоты кода.

Шаг 5.3: применяем обновления JSX на клиенте

Строго говоря, нам не нужен React дял сравнения JSX. Наши узлы JSX содержат только встроенные браузерные компоненты, такие как <nav> и <footer>. Можно начать с библиотеки, которая не имеет концепции клиентских компонентов, и использовать ее для сравнения и применения обновлений JSX. Однако в дальнейшем нам потребуется богатая интерактивность, поэтому мы будем использовать React с самого начала.

Наше приложение рендерится на сервере в HTML. Для того, чтобы React мог управлять узлом DOM, который он не создавал (такой как узел DOM, созданный браузером из HTML), мы должны предоставить React начальный JSX, соответствующий этому узлу DOM. Представьте подрядчика, который просит взглянуть на план дома перед его ремонтом. Это позволяет безопасно выполнять изменения. Точно также React обходит DOM для определения того, какие части JSX каким узлам DOM соответствуют. Это позволяет React добавлять обработчики событий к узлам DOM, делая их интерактивными, или обновлять их в будущем. Они теперь гидратированы (hydrated), как растения, оживающие от воды.

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

hydrateRoot(document, <App />);

Проблема в том, что у нас на клиенте нет корневого компонента (такого как <App />). С точки зрения клиента, наше приложение - это один большой кусок JSX без единого компонента React в нем. Однако, все, что нужно React, это дерево JSX, соответствующее начальному HTML. Дерево "клиентского JSX" (такое как <html>...</html>), производству которого мы только что научили наш сервер, отлично для этого подойдет:

import { hydrateRoot } from 'react-dom/client';

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
// TODO: возвращаем дерево клиентского JSX <html>...</html>, совпадающее с начальным HTML
}

Это будет очень быстрым, поскольку сейчас в клиентском JSX нет компонентов. React почти мгновенно обойдет дерево DOM и дерево JSX и сформирует внутреннюю структуру данных, необходимую ему для дальнейшего обновления дерева.

Затем, при навигации пользователя, мы запрашиваем JSX для следующей страницы и обновляем DOM с помощью root.render:

async function navigate(pathname) {
currentPathname = pathname;
const clientJSX = await fetchClientJSX(pathname);
if (pathname === currentPathname) {
root.render(clientJSX);
}
}

async function fetchClientJSX(pathname) {
// TODO: запрашиваем и возвращаем клиентский JSX <html>...</html> для следующего роута
}

Это позволяет достичь нашей цели - обновления DOM также, как это обычно делает React, без уничтожения состояния.

Приступим к реализации этих двух функций.

Шаг 5.3.1: получаем JSX от сервера

Начнем с fetchClientJSX(), поскольку ее легче реализовать.

Вспомним, как работает наша конечная точка на сервере ?jsx:

async function sendJSX(res, jsx) {
const clientJSX = await renderJSXToClientJSX(jsx);
const clientJSXString = JSON.stringify(clientJSX);
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
}

На клиенте мы обращаемся к этой точке и передаем ответ в JSON.parse() для его преобразования обратно в JSX:

async function fetchClientJSX(pathname) {
const response = await fetch(pathname + "?jsx");
const clientJSXString = await response.text();
const clientJSX = JSON.parse(clientJSXString);
return clientJSX;
}

Если вы откроете этот пример в песочнице, то увидите, что при клике по ссылке и попытке отрендерить полученный от сервера JSX возникает такая ошибка:

Objects are not valid as a React child (found: object with keys {type, key, ref, props, _owner, _store}).

Вот почему так происходит. Объект, передаваемый JSON.stringify(), выглядит следующим образом:

{
$$typeof: Symbol.for("react.element"),
type: 'html',
props: {
// ...

Но если вы посмотрите на результат вызова JSON.parse() на клиенте, то увидите, что свойство $$typeof исчезает при преобразовании:

{
type: 'html',
props: {
// ...

Без $$typeof: Symbol.for("react.element") React на клиенте отказывается распознавать объект как валидный узел JSX.

Это специальный механизм защиты. По умолчанию React отказывается обрабатывать объекты JSON, полученные из сети, как теги JSX. Значения типа symbol (такие как Symbol.for('react.element')) удаляются при сериализации с помощью JSON.stringify(). Это защищает приложение от рендеринга JSX, который не был явно создан кодом нашего приложения.

Однако, эти узлы JSX создаются нами (на сервере), и мы хотим рендерить их на клиенте. Поэтому нам нужно придумать какой-то способ передачи свойства $$typeof: Symbol.for("react.element"), несмотря на его несериализуемость.

К счастью, это легко сделать. JSON.stringify() принимает функцию-заменитель (replacer function), позволяющую кастомизировать генерацию JSON. Заменяем Symbol.for('react.element') специальной строкой "$RE" на сервере:

async function sendJSX(res, jsx) {
// ...
const clientJSXString = JSON.stringify(clientJSX, stringifyJSX);
// ...
}

function stringifyJSX(key, value) {
if (value === Symbol.for("react.element")) {
// Мы не можем передавать символ, поэтому передаем нашу магическую строку.
return "$RE"; // Значение может быть любым. Я выбрал RE как аббревиатуру React Element.
} else if (typeof value === "string" && value.startsWith("$")) {
// Во избежание конфликтов добавляем еще один $ к строке, начинающейся с $.
return "$" + value;
} else {
return value;
}
}

Передаем JSON.parse() "функцию оживления" (reviver function) для замены "$RE" на Symbol.for('react.element') на клиенте:

async function fetchClientJSX(pathname) {
// ...
const clientJSX = JSON.parse(clientJSXString, parseJSX);
// ...
}

function parseJSX(key, value) {
if (value === "$RE") {
// Специальный маркер, добавленный на сервере.
// Восстанавливаем символ для обеспечения валидности JSX с точки зрения React.
return Symbol.for("react.element");
} else if (typeof value === "string" && value.startsWith("$$")) {
// Это строка, начинающаяся с $. Удаляем дополнительный $, добавленный сервером.
return value.slice(1);
} else {
return value;
}
}

Смотрите этот пример в песочнице.

Теперь при переходе между страницами обновления запрашиваются как JSX и применяются на клиенте!

Если ввести какое-нибудь значение в инпут и кликнуть по ссылке, вы увидите, что состояние инпута сохраняется для всех навигаций, кроме самой первой. Это объясняется тем, что React не обладает информацией о начальном JSX для страницы и не может подключиться к серверному HTML.

Шаг 5.3.2: преобразуем начальный JSX в HTML

У нас остался такой недописанный код:

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
return null; // TODO
}

Нам нужно гидратировать корневой узел с помощью начального клиентского JSX, но откуда его взять?

Наша страница рендерится на сервере в HTML, но для дальнейших навигаций мы должны сообщить React о том, каким был начальный JSX для страницы. Иногда это можно сделать через частичную реконструкцию HTML, но такое возможно далеко не во всех случаях, особенно при наличии интерактивных возможностей, которые появятся в следующей части этой серии. Мы также не хотим запрашивать его, поскольку это может привести к ненужному водопаду запросов (waterfall).

В традиционном SSR с React мы сталкиваемся с похожей проблемой, но для данных. Для гидратации и генерации начального JSX компонентам нужны данные. В нашем случае компоненты на странице отсутствуют (по крайней мере, те, которые запускаются в браузере), поэтому у нас нет необходимости выполнять какой-либо код, но у нас на клиенте также нет кода, который знает, как генерировать такой начальный JSX.

Для решения этой задачи мы будем исходить из предположения, что строка с начальным JSX доступна через глобальную переменную на клиенте:

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
const clientJSX = JSON.parse(window.__INITIAL_CLIENT_JSX_STRING__, reviveJSX);
return clientJSX;
}

Модифицируем функцию sendHTML на сервере для рендеринга приложения в виде клиентского JSX и добавления этого JSX в конец HTML:

async function sendHTML(res, jsx) {
let html = await renderJSXToHTML(jsx);

// Сериализуем JSX после HTML во избежание блокировки отрисовки (paint):
const clientJSX = await renderJSXToClientJSX(jsx);
const clientJSXString = JSON.stringify(clientJSX, stringifyJSX);
html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
html += `</script>`;
// ...

Наконец, вносим несколько мелких правок для генерации HTML для текстовых узлов, чтобы React мог их гидратировать.

Смотрите этот пример в песочнице.

Теперь значение, введенное в инпут, не теряется между навигациями.

https://user-images.githubusercontent.com/810438/242748034-66ec402c-60c5-4bc6-a31f-c5c953b38142.mp4

Наша цель достигнута! Разумеется, дело не в этом конкретном инпуте - важно, что приложение теперь может обновляться на месте на любой странице с сохранением любого состояния.

Обратите внимание: несмотря на то, что настоящая реализация RSC также преобразует JSX в HTML, существует несколько важных отличий. Производственная RSC отправляет части JSX по мере их генерации, а не один большой кусок в конце. Гидратация может запускаться при загрузке приложения - React начинает обходить дерево с помощью доступных частей JSX, не ожидая прибытия всех частей. RSC также позволяет пометить некоторые компоненты как клиентские (прим. пер.: например, директива "use client" в Next.js). Такие компоненты также рендерятся на сервере в HTML, но их код включается в сборку (bundle) для клиента. Для клиентских компонентов сериализуется только JSON для их пропов. В будущем React может добавить дополнительные механизмы для дедупликации (deduplication) содержимого между HTML и встроенной полезной нагрузкой.

Шаг 6: наводим порядок

Приблизим архитектуру нашего кода к реальному RSC. Мы пока не будем реализовывать сложные механизмы вроде стриминга, но исправим несколько недостатков и подготовимся к добавлению следующего набора возможностей.

Шаг 6.1: выполняем дедупликацию работы

Вспомним, как мы генерируем начальный HTML:

async function sendHTML(res, jsx) {
// <Router /> необходимо преобразовать в "<html>...</html>" (строку):
let html = await renderJSXToHTML(jsx);

// <Router /> также необходимо преобразовать в <html>...</html> (объект):
const clientJSX = await renderJSXToClientJSX(jsx);

Предположим, что jsx здесь - это <Router url="https://localhost:3000" />.

Сначала мы вызываем renderJSXToHTML(), которая рекурсивно вызывает Router и другие компоненты для создания строки HTML. Но нам также необходимо отправить начальный клиентский JSX, поэтому вслед за этим мы вызываем renderJSXToClientJSX(), которая снова вызывает Router и другие компоненты. Таким образом, каждый компонент вызывается дважды. Это не только замедляет работу, но также может приводить к разным результатам вызова этих функций, например, при рендеринге компонента Feed (новостная лента). Следовательно, поток данных (data flow) должен быть переосмыслен.

Что если сначала генерировать дерево клиентского JSX?

async function sendHTML(res, jsx) {
// 1. Сначала преобразуем <Router /> в <html>...</html> (объект):
const clientJSX = await renderJSXToClientJSX(jsx);

Все компоненты выполняются. Генерируем HTML на основе этого дерева:

async function sendHTML(res, jsx) {
// 1. Сначала преобразуем <Router /> в <html>...</html> (объект):
const clientJSX = await renderJSXToClientJSX(jsx);
// 2. Преобразуем этот <html>...</html> в "<html>...</html>" (строку):
let html = await renderJSXToHTML(clientJSX);
// ...

Теперь компоненты вызываются только один раз.

Смотрите этот пример в песочнице.

Шаг 6.2: используем React для рендеринга HTML

Кастомная реализация renderJSXToHTML() позволяла контролировать, как выполняются наши компоненты. Например, нам требовалось добавить поддержку асинхронных функций. Однако, поскольку теперь мы передаем в нее предварительно сгенерированное дерево JSX, renderJSXToHTML() можно заменить встроенной функцией React renderToString:

import { renderToString } from 'react-dom/server';

// ...

async function sendHTML(res, jsx) {
const clientJSX = await renderJSXToClientJSX(jsx);
let html = renderToString(clientJSX);
// ...

Смотрите этот пример в песочнице.

Обратите внимание на параллель с клиентским кодом. Несмотря на реализацию новых фич (таких как асинхронные компоненты), мы по-прежнему можем использовать интерфейсы React, такие как renderToString() или hydrateRoot(). Отличается только способ их использования.

В обычном серверном приложении React мы вызываем renderToString() и hydrateRoot() с корневым компонентом <App />. Сначала мы вычисляем (evaluate) "серверное" дерево JSX с помощью renderJSXToClientJSX() и передаем результат вызова этой функции интерфейсам React.

Для renderToString() и hydrateRoot() Router, BlogIndexPage и Footer никогда не существовали. Вызов компонентов приводит к тому, что в дереве остается только произведенный ими JSX.

Шаг 6.3: разделяем сервер на два

На предыдущем шаге мы отделили запуск компонентов от генерации HTML:

  • сначала renderJSXToClientJSX() запускает компоненты для производства клиентского JSX;
  • затем renderToString() преобразует клиентский JSX в HTML.

Поскольку эти шаги независимы, их необязательно выполнять в одном процессе или даже на одной машине. Разделим server.js на два файла:

  • server/rsc.js - этот сервер будет запускать компоненты. Если компоненты обращаются к БД, имеет смысл запускать его максимально близко к дата-центру для уменьшения задержки;
  • server/ssr.js - этот сервер будет генерировать HTML. Он может использовать граничные вычисления (edge) для генерации HTML и обслуживания статических ресурсов.

Эти серверы запускаются одновременно в package.json:

"scripts": {
"start": "concurrently \"npm run start:ssr\" \"npm run start:rsc\"",
"start:rsc": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/rsc.js",
"start:ssr": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/ssr.js"
},

В этом примере они запускаются на одной машине, но мы вполне можем "хостить" (host) их отдельно.

Сервер RSC рендерит компоненты. Он отвечает только за обработку их JSX:

// server/rsc.js
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
await sendJSX(res, <Router url={url} />);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8081);

function Router({ url }) {
// ...
}

// ...
// Другие компоненты
// ...

async function sendJSX(res, jsx) {
// ...
}

function stringifyJSX(key, value) {
// ...
}

async function renderJSXToClientJSX(jsx) {
// ...
}

Сервер SSR - это сервер, к которому обращаются пользователи. Он обращается к серверу RSC за JSX и либо отправляет этот JSX как строку (для переходов между страницами), либо преобразует его в HTML (для начальной загрузки):

// server/ssr.js
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === "/client.js") {
// ...
}
// Получаем сериализованый ответ JSX от сервера RSC
const response = await fetch("http://127.0.0.1:8081" + url.pathname);
if (!response.ok) {
res.statusCode = response.status;
res.end();
return;
}
const clientJSXString = await response.text();
if (url.searchParams.has("jsx")) {
// Если пользователь перемещается между страницами, отправляем этот сериализованый JSX как есть
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
} else {
// При начальной загрузке страницы преобразуем дерево в HTML
const clientJSX = JSON.parse(clientJSXString, parseJSX);
let html = renderToString(clientJSX);
html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
html += `</script>`;
// ...
res.setHeader("Content-Type", "text/html");
res.end(html);
}
} catch (err) {
// ...
}
}).listen(8080);

Смотрите этот пример в песочнице.

Разделение между RSC и "остальным миром" (SSR и клиентом) будет сохраняться на протяжении всей серии. Преимущества такого подхода станут очевидными в следующих частях, когда мы начнем добавлять "фичи" (features) в каждый из миров, связывая их вместе.

Строго говоря, технически возможно запускать RSC и SSR внутри одного процесса, но их модульные среды должны быть изолированными. Это продвинутая тема, которая выходит за рамки данной статьи.

Резюме

На сегодня это все!

Может показаться, что мы написали много кода, но, в действительности, это не так:

Внимательно их изучите. Для визуализации процесса нарисуем парочку схем.

Вот что происходит при начальной загрузке страницы:

А вот что происходит при навигации между страницами:

Наконец, определимся с терминологией:

  • мы будем говорить сервер React (или просто сервер) для обозначения сервера RSC. Компоненты, которые существуют только в этом сервере (на данный момент это все наши компоненты), называются серверными компонентами;
  • мы будем говорить клиент React (или просто клиент) для обозначения среды, потребляющей результат, сгенерированный сервером React. Как видите, SSR - это клиент React, как и браузер. Мы пока не поддерживаем компоненты на клиенте - они будут разработаны в будущем - но, в качестве спойлера, мы будем называть их клиентскими компонентами.

Вызовы

Если чтение этой статьи не удовлетворило ваше любопытство, поиграйте с финальным кодом.

Вот несколько идей для воплощения:

  • добавить произвольный фоновый цвет к <body> страницы и переход (transition) для него. При перемещении между страницами фоновый цвет должен анимироваться;
  • реализовать поддержку фрагментов (<>) в рендерере RSC. Для этого должно хватить нескольких строк кода, но необходимо выяснить, куда их поместить и что они должны делать;
  • форматировать посты блога как markdown с помощью компонента <Markdown> из пакета react-markdown. Да, существующий код позволяет это делать;
  • компонент <Markdown> поддерживает определение кастомной реализации различных тегов. Например, мы можем реализовать собственный компонент Image и передать его как <Markdown components={{ img: Image }}>. Создайте компонент Image, измеряющий размеры изображения (для этого можно использовать какой-нибудь пакет NPM) - автоматически вычисляющий width и height;
  • добавить раздел с комментариями к посту. Комментарии должны храниться в файле JSON на диске. Для отправки комментариев потребуется <form>. Кроме этого, можно расширить логику в client.js для перехвата отправки формы и предотвращения перезагрузки страницы. После отправки формы должен выполняться повторный запрос JSX страницы и обновление списка комментариев;
  • нажатие кнопки "Назад" сейчас всегда приводит к запросу свежего JSX. Измените логику в client.js таким образом, чтобы навигация "Вперед/Назад" использовала кэшированные ответы, но клик по ссылке всегда получал свежий ответ. Это позволит приблизиться к дефолтному поведению браузера;
  • при навигации между двумя постами сравнивается весь их JSX. Это не всегда имеет смысл - концептуально, это два разных поста. Например, если мы начали писать комментарий к одному посту и кликнули по ссылке на другой пост, мы не хотим, чтобы состояние комментария сохранялось только потому, что инпут находится в том же месте. Как можно решить эту задачу? (Подсказка: можно научить компонент Router обрабатывать страницы с разными URL как разные компоненты, обернув {page} во что-нибудь. Затем необходимо убедиться, что это "что-нибудь" не теряется при передаче по сети);
  • формат сериализации JSX сейчас очень многословный. Есть идеи, как сделать его более компактным? Можно взглянуть на производственный фреймворк RSC, такой как Next.js App Router или наше официальное демо RSC для вдохновения. Даже без стриминга, было бы здорово представлять элементы JSX более компактным способом;
  • предположим, вы захотели добавить поддержку клиентских компонентов. Как это сделать? С чего начать?

Развлекайтесь!