В этом техническом "глубоком погружении" (deep dive) мы с нуля реализуем очень простую версию серверных компонентов React. Данный туториал будет состоять из трех частей (написана пока только эта). Руководства, шпаргалки, вопросы и другие материалы по JavaScript, TypeScript, React, Next.js, Node.js, Express, Prisma, GraphQL, Docker и другим технологиям, связанным с разработкой веб-приложений. Этот туториал не объясняет преимуществ серверных компонентов React или как разработать приложение с помощью RSC, или как разработать фреймворк с их помощью. Вместо этого, оно проведет вас через процесс их "изобретения" с нуля. Эта статья предназначена для людей, которым нравится изучать новые технологии посредством их реализации с нуля. Поэтому предполагается, что вы имеете некоторый опыт в веб-разработке и знакомы с React. Эта статья не является введением в серверные компоненты. Мы работаем над соответствующей документацией на сайте React. В настоящее время, если ваш фреймворк поддерживает серверные компоненты, пожалуйста, обратитесь к его документации. В педагогических целях наша реализация будет гораздо менее эффективной, чем та, которая используется в React. В тексте будут отмечены возможности оптимизации, но нашим приоритетом будет концептуальная точность, а не эффективность. Представим, что вы проснулись однажды утром и обнаружили, что на дворе снова 2003 год. Веб-разработка находится в зачаточном состоянии. Предположим, вы хотите создать персональный блог, отображающий содержимое текстовых файлов, лежащих на сервере. На PHP это могло бы выглядеть так: Для повышения читаемости HTML допустим, что такие теги как <nav>, <article> и <footer> тогда уже существовали. Когда мы открываем http://locahost:3000/hello-world в браузере, этот скрипт PHP возвращает HTML-страницу с постом блога из файла ./posts/hello-world.txt. Аналогичный скрипт на современном Node.js может выглядеть так: Смотрите этот пример в песочнице. Представьте, что можете взять с собой в 2003 год рабочий движок Node.js и запустить этот код на сервере. Если вы захотите привнести в этом мир парадигму React, какие возможности вы добавите? И в каком порядке? Первое, что бросается в глаза в приведенном выше коде, это прямая манипуляция строками. Мы должны вызывать escapeHtml(postContent) во избежание обработки содержимого текстового файла как HTML. Одним из способов решения этой проблемы является отделение логики от "шаблона" (template) и использование специального языка шаблонов (template language), позволяющего внедрять динамические значения для текста и атрибутов, обеззараживать (escape) текст и предоставляющего специфический для домена (domain-specific) синтаксис для условий и циклов. Такой подход использовался некоторыми наиболее популярными серверными фреймворками в 2000-х годах. Тем не менее, ваши знания React могли бы вдохновить вас сделать следующее: Выглядит похоже, но теперь наш шаблон больше не является строкой. Вместо кода для интерполяции строки (string interpolation), мы помещаем подмножество XML в JavaScript. Другими словами, мы только что "изобрели" JSX (JavaScript and XML). JSX позволяет держать разметку максимально близко к соответствующей логике рендеринга, но, в отличие от интерполяции строки, предотвращает ошибки вроде отсутствующих открывающих/закрывающих тегов HTML или рендеринга текстового содержимого без обеззараживания. Под капотом JSX производит дерево объектов, которое выглядит так: Однако, мы должны отправлять браузеру HTML, а не дерево JSON (по крайней мере, на сегодняшний день). Напишем функцию, которая преобразует JSX в строку HTML. Для этого необходимо определить, как разные типы узлов (nodes) (строка, число, массив или узел JSX с потомками (children)) должны превращаться в кусочки HTML: Смотрите этот пример в песочнице. Прим. пер.: обратите внимание на команду 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, поскольку это первая вещь, которую вы можете попытаться сделать в серверной среде. Но это лишь первый шаг, и в дальнейшем вы увидите существенные различия между ними. Следующей "желанной" возможностью, вероятно, являются компоненты. Независимо от того, где запускается код, на сервере или клиенте, имеет смысл разделить части UI (User Interface — пользовательский интерфейс) на кусочки, присвоить им названия и передавать им информацию с помощью пропов (props, properties — свойства). Разделим предыдущий пример на два компонента под названием BlogPostPage и Footer: Далее, заменим встроенное дерево JSX на <BlogPostPage postContent={postContent} author={author} />: Если вы попробуете запустить этот код без обновления renderJSXToHTML(), то итоговый HTML будет выглядеть сломанным: Проблема в том, что функция renderJSXToHTML (которая преобразует JSX в HTML) "предполагает", что jsx.type — это всегда строка с названием тега HTML (например, "html", "footer" или "p"): Однако BlogPostPage — это функция, поэтому выполнение "<" + jsx.type + ">" "печатает" ее исходный код. Мы не хотим отправлять код функции в названии тега HTML. Вызовем функцию и сериализуем (serialize) возвращаемый ею JSX в HTML: Теперь при генерации HTML элемент JSX, такой как <BlogPostPage author="Jae Doe" />, вызывается как функция, которой в качестве аргумента передается { author: "Jae Doe" }. Эта функция возвращает JSX, который снова передается в renderJSXToHTML(). Этого изменения достаточно для добавления поддержки компонентов и передачи пропов. Смотрите этот пример в песочнице. Теперь было бы неплохо добавить еще несколько страниц в блог. Предположим, что по такому адресу, как /hello-world, должна отображаться страница конкретного поста с содержимым файла ./posts/hello-world.txt, а при запросе корневого URL (Uniform Resource Locator — единый указатель ресурсов) — длинная главная страница с содержимым всех постов. Это означает, что мы хотим добавить новую страницу BlogIndexPage, имеющую общий со страницей BlogPostPage макет (shared layout), но другое содержимое внутри. На данный момент компонент BlogPostPage представляет всю страницу целиком, начиная от корневого <html>. Извлечем общие для страниц части UI (header и footer) из BlogPostPage в переиспользуемый (reusable) компонент BlogLayout: Содержимым BlogPostPage является то, что мы хотим поместить (slot) внутрь макета: Вот как будет выглядеть <BlogPostPage>, вложенный в <BlogLayout>: 
 Давайте также добавим новый компонент BlogIndexPage, показывающий каждый пост в ./posts/*.txt один за другим: Мы также можем обернуть его в BlogLayout, чтобы он имел такую же шапку и подвал: 
 Наконец, модифицируем код серверного обработчика (server handler) таким образом, чтобы он определял запрашиваемую страницу на основе URL, загружал для нее данные и рендерил ее внутри макета: Смотрите этот пример в песочнице. Теперь мы можем перемещаться (navigate) по блогу. Тем не менее, код становится слегка многословным и громоздким. Давайте исправим это в следующем разделе. Вы могли заметить, что эта часть компонентов BlogIndexPage и BlogPostPage выглядит почти одинаково: 
 
 Было бы здорово вынести это в переиспользуемый компонент. Однако, даже если извлечь логику рендеринга в отдельный компонент Post, нам по-прежнему надо будет как-то передавать content каждого поста: Сейчас логика загрузки content для постов дублируется здесь и здесь. Мы загружаем его за пределами иерархии компонентов, поскольку API (Application Programming Interface — интерфейс прикладного программирования) readFile является асинхронным, мы не можем использовать его прямо в дереве компонентов (тот факт, что модуль fs предоставляет синхронные методы не решает проблему, поскольку вместо чтения файлов мы можем обращаться к базе данных или вызывать асинхронную стороннюю библиотеку). Или все-таки можем? Если вы привыкли к React на стороне клиента, возможно, вы привыкли к идее о том, что API вроде fs.readFile не могут вызываться в компонентах. Даже в случае с React SSR интуиция может подсказывать вам, что каждый компонент также должен иметь возможность запускаться в браузере, поэтому серверные API, вроде fs.readFile, работать не будут. Но если бы вы попытались объяснить это кому-то в 2003 году, они посчитали бы это ограничение довольно странным. Будем решать проблемы по мере поступления. Пока у нас есть только сервер, поэтому у нас нет необходимости ограничивать компоненты кодом, который работает в браузере. Компонент вполне может быть асинхронным, поскольку сервер может подождать с генерацией HTML для него до тех пор, пока его данные не будут загружены, и он не будет готов к отображению. Удалим проп content и сделаем Post асинхронной функцией, загружающей содержимое файла с помощью вызова await readFile(): Аналогично сделаем BlogIndexPage асинхронной функцией, отвечающей за перебор постов с помощью await readdir(): Поскольку Post и BlogIndexPage сами загружают данные для себя, мы можем заменить matchRoute() компонентом <Router>: Наконец, верхнеуровневый серверный обработчик может делегировать всю логику рендеринга компоненту <Router> Погодите, нам сначала нужно выполнять асинхронную работу внутри компонентов. Как это сделать? Найдем место, где вызывается renderJSXToHTML(): Поскольку функции-компоненты теперь могут быть асинхронными, просто добавляем здесь ключевое слово await: Это означает, что сама функция renderJSXToHTML теперь также должна быть асинхронной: Таким образом, любой компонент в дереве может быть асинхронным, а итоговый HTML "ждет" их разрешения (имеется ввиду разрешение промиса). Обратите внимание на отсутствие в новом коде специальной логики по "подготовке" содержимого всех файлов для BlogIndexPage в цикле. Компонент BlogIndexPage по-прежнему рендерит массив компонентов Post, но теперь каждый Post знает, как читать содержимое соответствующего файла. Смотрите этот пример в песочнице. В настоящее время наш сервер умеет только рендерить роут в строку HTML: Это отлично подходит для первой загрузки — браузер оптимизирован для максимально быстрого отображения HTML — но не идеально для навигаций. Мы бы хотели иметь возможность обновлять "только изменившиеся части" на месте (in-place), сохраняя клиентское состояние как внутри, так и вокруг этих частей (например, состояние инпута, видео, попапа и т.д.). Это также сделает мутации (такие как добавление комментария к посту) более плавными. Для иллюстрации проблемы добавим <input /> в <nav> внутри компонента BlogLayout: Обратите внимание на то, как состояние инпута сбрасывается при навигации по блогу: Это может быть нормальным для простого блога, но если мы хотим иметь возможность разрабатывать более интерактивные приложения, такое поведение становится неприемлемым. Мы хотим, чтобы пользователь перемещался по страницам без потери локального состояния. Исправим это в три этапа: Нам потребуется некоторая логика на клиенте, поэтому добавим тег <script> в новом файле client.js. В этом файле мы перезапишем дефолтное поведение для навигации по сайту таким образом, что клик по ссылке будет вызывать функцию navigate: В функции navigate запрашивается ответ HTML для следующего роута и обновляется DOM: Смотрите этот пример в песочнице. Помните объект дерева JSX? Добавим в наш сервер новый режим. Когда URL запроса оканчивается на ?jsx, в ответ отправляется дерево, а не HTML. Это позволит клиенту легко определить, какие части изменились, и точечно обновлять DOM. Это решит проблему сохранения состояния <input>, но это не единственная причина, по которой мы так делаем. В следующей части (не в этой!) вы увидите, как это позволяет передавать новую информацию (не только HTML) от сервера клиенту. Для начала изменим серверный код для вызова новой функции sendJSX при наличии поискового параметра (search param) ?jsx: В sendJSX() объект дерева преобразуется в строку JSON с помощью JSON.stringify(jsx) для обеспечения возможности его передачи по сети: Мы будем говорить "отправка JSX", но на самом деле по сети отправляется не JSX (такой как "<Foo />"). Мы берем объект дерева, произведенный JSX, и превращаем его в строку JSON. Однако формат транспортировки позже изменится (в настоящей реализации RSC используется другой формат, о котором мы поговорим в следующей части этой серии). Взглянем на то, что передается по сети: Смотрите этот пример в песочнице. Если вы загрузите главную страницу (/) и нажмете на ссылку, то увидите уведомление с таким объектом: Мы рассчитывали получить дерево JSX вроде <html>...</html>, но что-то пошло не так. Наш начальный JSX выглядел так: Превращать этот JSX в JSON для клиента "слишком рано", поскольку мы не знаем, какой JSX Router хочет отрендерить, а Router существует только на сервере. Нужно вызвать компонент Router для того, чтобы выяснить какой JSX необходимо отправить клиенту. Если мы вызовем функцию Router с { url: "http://localhost:3000/hello-world" } в качестве пропа, то получим такой кусок JSX: Опять "слишком рано" преобразовывать этот JSX в JSON для клиента, поскольку мы не знаем, что хочет отрендерить BlogLayout — и он существует только на сервере. Нужно вызвать BlogLayout для того, чтобы выяснить, какой JSX он хочет передать клиенту и т.д. Опытные пользователи React могут задаться вопросом: можем ли мы отправить этот код клиенту для выполнения? Ищите ответ в следующей части серии! Но даже это будет работать только для BlogLayout, поскольку BlogIndexPage вызывает fs.readdir(). По завершению этого процесса мы получаем дерево JSX, которое не содержит ссылок на серверный код. Например: Это похоже на дерево, которое можно передать в JSON.stringify() и отправить клиенту. Напишем функцию renderJSXToClientJSX. Она принимает кусок JSX в качестве параметра и пытается "разрешить" его серверные части (путем вызова соответствующих компонентов) до тех пор, пока не останется только JSX, понятный клиенту. Структурно эта функция похожа на renderJSXToHTML(), но вместо HTML она обходит и возвращает объекты: Редактируем sendJSX() для преобразования <Router /> в "клиентский JSX" перед его стрингификацией: Смотрите этот пример в песочнице. Теперь клик по ссылке приводит к отображению уведомления с деревом, похожим на HTML — это означает, что мы готовы к его сравнению (diffing)! Строго говоря, нам не нужен 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, созданным на сервере. Это может выглядеть так: Проблема в том, что у нас на клиенте нет корневого компонента (такого как <App />). С точки зрения клиента, наше приложение — это один большой кусок JSX без единого компонента React в нем. Однако, все, что нужно React, это дерево JSX, соответствующее начальному HTML. Дерево "клиентского JSX" (такое как <html>...</html>), производству которого мы только что научили наш сервер, отлично для этого подойдет: Это будет очень быстрым, поскольку сейчас в клиентском JSX нет компонентов. React почти мгновенно обойдет дерево DOM и дерево JSX и сформирует внутреннюю структуру данных, необходимую ему для дальнейшего обновления дерева. Затем, при навигации пользователя, мы запрашиваем JSX для следующей страницы и обновляем DOM с помощью root.render: Это позволяет достичь нашей цели — обновления DOM также, как это обычно делает React, без уничтожения состояния. Приступим к реализации этих двух функций. Начнем с fetchClientJSX(), поскольку ее легче реализовать. Вспомним, как работает наша конечная точка на сервере ?jsx: На клиенте мы обращаемся к этой точке и передаем ответ в JSON.parse() для его преобразования обратно в JSX: Если вы откроете этот пример в песочнице, то увидите, что при клике по ссылке и попытке отрендерить полученный от сервера JSX возникает такая ошибка: Вот почему так происходит. Объект, передаваемый JSON.stringify(), выглядит следующим образом: Но если вы посмотрите на результат вызова JSON.parse() на клиенте, то увидите, что свойство $$typeof исчезает при преобразовании: Без $$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" на сервере: Передаем JSON.parse() "функцию оживления" (reviver function) для замены "$RE" на Symbol.for('react.element') на клиенте: Смотрите этот пример в песочнице. Теперь при переходе между страницами обновления запрашиваются как JSX и применяются на клиенте! Если ввести какое-нибудь значение в инпут и кликнуть по ссылке, вы увидите, что состояние инпута сохраняется для всех навигаций, кроме самой первой. Это объясняется тем, что React не обладает информацией о начальном JSX для страницы и не может подключиться к серверному HTML. У нас остался такой недописанный код: Нам нужно гидратировать корневой узел с помощью начального клиентского JSX, но откуда его взять? Наша страница рендерится на сервере в HTML, но для дальнейших навигаций мы должны сообщить React о том, каким был начальный JSX для страницы. Иногда это можно сделать через частичную реконструкцию HTML, но такое возможно далеко не во всех случаях, особенно при наличии интерактивных возможностей, которые появятся в следующей части этой серии. Мы также не хотим запрашивать его, поскольку это может привести к ненужному водопаду запросов (waterfall). В традиционном SSR с React мы сталкиваемся с похожей проблемой, но для данных. Для гидратации и генерации начального JSX компонентам нужны данные. В нашем случае компоненты на странице отсутствуют (по крайней мере, те, которые запускаются в браузере), поэтому у нас нет необходимости выполнять какой-либо код, но у нас на клиенте также нет кода, который знает, как генерировать такой начальный JSX. Для решения этой задачи мы будем исходить из предположения, что строка с начальным JSX доступна через глобальную переменную на клиенте: Модифицируем функцию sendHTML на сервере для рендеринга приложения в виде клиентского JSX и добавления этого JSX в конец HTML: Наконец, вносим несколько мелких правок для генерации HTML для текстовых узлов, чтобы React мог их гидратировать. Смотрите этот пример в песочнице. Теперь значение, введенное в инпут, не теряется между навигациями. Наша цель достигнута! Разумеется, дело не в этом конкретном инпуте — важно, что приложение теперь может обновляться на месте на любой странице с сохранением любого состояния. Приблизим архитектуру нашего кода к реальному RSC. Мы пока не будем реализовывать сложные механизмы вроде стриминга, но исправим несколько недостатков и подготовимся к добавлению следующего набора возможностей. Вспомним, как мы генерируем начальный HTML: Предположим, что jsx здесь — это <Router url="https://localhost:3000" />. Сначала мы вызываем renderJSXToHTML(), которая рекурсивно вызывает Router и другие компоненты для создания строки HTML. Но нам также необходимо отправить начальный клиентский JSX, поэтому вслед за этим мы вызываем renderJSXToClientJSX(), которая снова вызывает Router и другие компоненты. Таким образом, каждый компонент вызывается дважды. Это не только замедляет работу, но также может приводить к разным результатам вызова этих функций, например, при рендеринге компонента Feed (новостная лента). Следовательно, поток данных (data flow) должен быть переосмыслен. Что если сначала генерировать дерево клиентского JSX? Все компоненты выполняются. Генерируем HTML на основе этого дерева: Теперь компоненты вызываются только один раз. Смотрите этот пример в песочнице. Кастомная реализация renderJSXToHTML() позволяла контролировать, как выполняются наши компоненты. Например, нам требовалось добавить поддержку асинхронных функций. Однако, поскольку теперь мы передаем в нее предварительно сгенерированное дерево JSX, renderJSXToHTML() можно заменить встроенной функцией React renderToString: Смотрите этот пример в песочнице. Обратите внимание на параллель с клиентским кодом. Несмотря на реализацию новых фич (таких как асинхронные компоненты), мы по-прежнему можем использовать интерфейсы React, такие как renderToString() или hydrateRoot(). Отличается только способ их использования. В обычном серверном приложении React мы вызываем renderToString() и hydrateRoot() с корневым компонентом <App />. Сначала мы вычисляем (evaluate) "серверное" дерево JSX с помощью renderJSXToClientJSX() и передаем результат вызова этой функции интерфейсам React. Для renderToString() и hydrateRoot() Router, BlogIndexPage и Footer никогда не существовали. Вызов компонентов приводит к тому, что в дереве остается только произведенный ими JSX. На предыдущем шаге мы отделили запуск компонентов от генерации HTML: Поскольку эти шаги независимы, их необязательно выполнять в одном процессе или даже на одной машине. Разделим server.js на два файла: Эти серверы запускаются одновременно в package.json: В этом примере они запускаются на одной машине, но мы вполне можем "хостить" (host) их отдельно. Сервер RSC рендерит компоненты. Он отвечает только за обработку их JSX: Сервер SSR — это сервер, к которому обращаются пользователи. Он обращается к серверу RSC за JSX и либо отправляет этот JSX как строку (для переходов между страницами), либо преобразует его в HTML (для начальной загрузки): Смотрите этот пример в песочнице. Разделение между RSC и "остальным миром" (SSR и клиентом) будет сохраняться на протяжении всей серии. Преимущества такого подхода станут очевидными в следующих частях, когда мы начнем добавлять "фичи" (features) в каждый из миров, связывая их вместе. Строго говоря, технически возможно запускать RSC и SSR внутри одного процесса, но их модульные среды должны быть изолированными. Это продвинутая тема, которая выходит за рамки данной статьи. На сегодня это все! Может показаться, что мы написали много кода, но, в действительности, это не так: Внимательно их изучите. Для визуализации процесса нарисуем парочку схем. Вот что происходит при начальной загрузке страницы: 
 А вот что происходит при навигации между страницами: 
 Наконец, определимся с терминологией: Если чтение этой статьи не удовлетворило ваше любопытство, поиграйте с финальным кодом. Вот несколько идей для воплощения: Развлекайтесь! JavaScript Developer