Отрисовка на стороне сервера приложения React 18 с использованием Express и Babel
Как преобразовать проект Create React App в рендеринг с серверной стороны с использованием Express и Babel: renderToString, hydrateRoot, MemoryRouter, а также как Remix и Gatsby реализуют SSR.
Источники: A Hands-on Guide for a Server-Side Rendering React 18 App
Подготовка к отрисовке на стороне сервера для React сильно изменилась с появлением React 18. Приведённая ниже инструкция рассчитана на React 18, Create React App 5 и React Router 6; она начинается с готового проекта Create React App, который постепенно преобразуется. Отрисовка должна выполняться на сервере, поэтому требуется процесс Node. Обычный Node подойдёт, но nodemon удобен во время разработки, так как он перезапускает приложение при любых изменениях в файлах. Глобальная установка выглядит следующим образом:
% npm install -g nodemon
CSR против SSR в нескольких строках
При клиентской обработке контента (CSR) браузер получает практически пустую HTML-страницу, а JavaScript заполняет её содержимым. Первая загрузка может быть медленной, но последующее навигирование происходит быстро, а на сервер приходится малая нагрузка, поскольку работу выполняют библиотеки JavaScript. Недостатком является оптимизация под поисковые системы, так как роботы не находят статического контента для анализа.
При серверной обработке контента браузер получает уже полностью сгенерированную сервером HTML-страницу. Это способствует улучшению SEO, а первая отрисовка происходит быстро. Однако в качестве компромисса изменения часто требуют полной перезагрузки страницы, что может увеличить нагрузку на сервер.
Здесь помогут несколько связанных терминов:
- Приложения с одной страницей (SPA) используют технологию CSR. Вместо отправки отдельных HTML-файлов для каждого маршрута они динамически генерируют содержимое каждого маршрута прямо в браузере.
SSR на самом деле появился раньше, чем CSR. Он вернулся в современной форме, основанной на универсальном JavaScript, и именно это сейчас обычно имеют в виду, говоря о SSR.
Базовое приложение CSR
Создайте новый проект:
% yarn create react-app react-ssr
% cd react-ssr
Начните с команды npm start. В вкладке Elements браузера вращающийся логотип и текст отображаются как HTML, сгенерированный JavaScript.
Это типичный пример CSR: скрипты генерируют содержимое HTML. Вкладка Network показывает, что на самом деле отправил сервер.
В теле страницы находится пакет JavaScript, но нет реального содержимого, поэтому поисковым роботам практически нечего индексировать.
Размещение готовой версии приложения
Для SSR требуется готовая версия приложения, поэтому первым шагом является создание небольшого сервера Express, который будет предоставлять скомпилированный результат работы React. Ранее рассмотренная настройка React для производственного использования заложила основу. Файл сервера существует в двух форматах: один — CommonJS, server/index.js, а другой — ES-модуль, server/index.mjs.
Выполните команду npm run build, чтобы сгенерировать результаты сборки, затем запустите сервер с помощью nodemon server или nodemon server/index.mjs. Вкладка Network теперь отображает один пакет JavaScript (строка 12) и пустой маркап (строка 17). Пока ничего не генерируется на стороне сервера, поэтому это всё ещё CSR.
Три шага для SSR в Express
Чтобы реализовать SSR внутри сервера Express, необходимо выполнить три шага.
Шаг 1: гидратация на клиенте. ReactDOM.hydrateRoot() похож на ReactDOM.createRoot(), имея сигнатуру hydrateRoot(container, element[, options]). Он предназначен для контейнера, HTML которого уже был сгенерирован ReactDOMServer. Поскольку узел уже содержит маркировку, отрендеренную на сервере, React сохраняет её и добавляет только обработчики событий, что обеспечивает быструю первоначальную загрузку. Этот вызов находится на строке 7 в файле src/index.js.
Шаг 2: отрендеривание на сервере. React поставляет объект ReactDOMServer для преобразования компонентов в статическую маркировку, так что браузер получает страницу, уже заполненную данными. Поскольку код React представляет собой универсальный JavaScript, одинаковые компоненты работают как на стороне сервера, так и на стороне клиента.
Шаг 3: учет специфических потребностей страницы. Как покажут последующие разделы, каждый маршрут может требовать особого внимания.
Помощь Node в понимании JSX
Существует несколько пакетов, которые могут обеспечить работу SSR. @babel/register — один из самых простых. Установите его вместе с babel-plugin-transform-assets:
% yarn add -D @babel/register
% yarn add -D babel-plugin-transform-assets
Оба попадают в devDependencies:
"devDependencies": {
"@babel/register": "^7.18.9",
"babel-plugin-transform-assets": "^1.0.2"
}
@babel/register компилирует файлы в реальном времени и может настраиваться для преобразования JSX и ресурсов. Существует одно ограничение: он не может компилировать нативные ES-модули Node.js в реальном времени, поскольку пока нет стабильного API для интеграции с процессом загрузки ES-модулей. Именно поэтому в экспериментах с SSR здесь используются файлы CommonJS.
React 18 переработал свои серверные API и переместил их в папку react-dom/server. Функция renderToString по-прежнему работает, но её поддержка Suspense ограничена. Более новые API, в том числе renderToPipeableStream, полностью поддерживают Suspense на сервере вместе с технологией потокового SSR.
Разбор сервера SSR
Обновленный файл server/index.js объясняется ниже по номерам строк.
- Строки с 1 по 15 настраивают Babel с помощью гука
require, что позволяет компилировать файлы по мере их загрузки.
@babel/preset-env позволяет коду использовать самую новую версию JavaScript без необходимости вручную выбирать синтаксические преобразования для каждой целевой среды. @babel/preset-react автоматически импортирует функции, необходимые для компиляции JSX.babel-plugin-transform-assets, который определяет, как преобразуются статические медиафайлы. Без него тег svg вызывает ошибку SyntaxError: Unexpected token '<'.React, строка 17 — библиотеки ReactDOMServer, а строка 18 — значения по умолчанию из файла src/App.js.Здесь важен порядок. Вызов app.use() на строке 47 должен выполняться после app.get() на строках 25–45. Если бы он шёл первым, он бы обслуживал статические файлы, включая index.html, для корневого маршрута, и app.get() так и не смог бы выполниться.
На строке 26 записывается URL каждого запроса. При сборке проекта Create React App список выглядит следующим образом:
Request URL = /
Request URL = /static/js/main.b92cc87b.js
Request URL = /static/css/main.073c9b0a.css
Request URL = /static/media/logo.06e73328.svg
Request URL = /static/css/main.073c9b0a.css.map
Request URL = /static/js/main.b92cc87b.js.map
Request URL = /static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg
Request URL = /favicon.ico
Request URL = /manifest.json
Request URL = /logo192.png
Строки с 27 по 29 обеспечивают отображение только корневого маршрута с помощью app.get(), в то время как статические ресурсы передаются дальше следующему мидлвэру на строке 47.
На строке 30 вызывается метод ReactDOMServer.renderToString(element) для генерации HTML на сервере. Было бы естественно написать ReactDOMServer.renderToString() напрямую, но тогда JSX пришлось бы находиться в отдельном файле, отличном от того, где настроен @babel-register, поэтому используется отдельный элемент.
На строке 31 выводится маркировка, сгенерированная сервером:
<div class="App"><header class="App-header"><img src="static/media/logo.06e73328.svg" class="App-logo" alt="logo"/><p>Edit <code>src/App.js</code> and save to reload.</p><a class="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React</a></header></div>
В строке 33 загружается файл index.html для производственной среды, а строки с 34 по 44 читают его содержимое. Если ошибок нет, сгенерированный маркап (строка 42) вставляется в корневой тег, и готовый файл index.html становится ответом на первый запрос.
Проверка работы
Запустите команду nodemon server и снова посмотрите на вкладку Network. Теперь загружаемый документ содержит маркап, сгенерированный сервером. Строки с 17 по 27 тела страницы включают всю страницу, что даёт инструментам SEO реальный материал для анализа.
Для более строгого теста откройте chrome://settings/content/javascript и выключите JavaScript.
Страница продолжает отображаться без JavaScript.
Это и есть SSR.
Добавление маршрутов
Одностраничное приложение создаётся легко, поэтому следующий вопрос касается нескольких маршрутов. Начните с установки библиотеки react-router-dom:
% yarn add react-router-dom
Он добавляется в dependencies:
"dependencies": {
"react-router-dom": "^6.11.2"
}
Затем отредактируйте файл src/App.js. В первой строке импортируется библиотека React, необходимая для SSR. Во второй строке импортируется MemoryRouter вместо BrowserRouter. Браузерный маршрутизатор использует API истории HTML5 pushState, которого нет в Node.js. MemoryRouter хранит историю URL в памяти, не влияя на строку адреса, что удобно для тестирования и работы в средах, отличных от браузера. Ещё одним вариантом является StaticRouter из пакета react-router-dom/server, однако он не предназначен для сложных маршрутов. Строки с 34 по 40 содержат код, использующий MemoryRouter.
Снова соберите проект командой npm run build, запустите сервер с помощью nodemon server и откройте адрес http://localhost:8080.
SSR работает, но есть одна особенность: адрес в строке адреса не обновляется.
Где ручной SSR становится проблематичным
Маршрутизация работает с учетом этого ограничения, но остаются другие проблемы, такие как загрузка данных и использование Redux. Работа на стороне сервера менее проста, чем на стороне клиента, и каждая страница может требовать особого обработки.
Нет необходимости решать все это вручную, поскольку многие фреймворки и библиотеки включают поддержку SSR. Remix — это фреймворк полного стека, который начинается с основ веб-технологий и охватывает до пользовательского интерфейса, включая SSR и другие функции без лишнего кода. Код клиента вызывает метод hydrateRoot на 4-й строке, а код сервера — метод renderToPipeableStream на 4-й строке.
В React 18 была введена функция renderToPipeableStream(element, options). При передаче ей элемента React она генерирует исходный HTML и возвращает поток. У этого потока есть метод pipe(res) для дальнейшей передачи данных и метод abort() для отмены операции. Полностью поддерживается механизм Suspense, а HTML с отложенным содержимым передаётся в виде потока без неожиданного появления элементов — промежутки заполняются встроенными тегами <script>.
На вкладке Network тело страницы-индекса Remix уже содержит полную версию страницы, поэтому реализован механизм SSR.
Gatsby, Next.js, NuxtJS, Quasar и SvelteKit также предоставляют функции SSR и связанные с ними возможности из коробки без необходимости написания дополнительного кода, хотя каждый из них определяет SSR немного по-своему. На вкладке Network страница-индекса Gatsby также отображает полное тело страницы.
Gatsby — это быстрый и надежный генератор статических сайтов, который использует React для отрисовки статического контента и позволяет изменять статический HTML во время его отрисовки на сервере.
SSR или статическая отрисовка
Когда страница содержит только статические данные, статическая отрисовка происходит быстрее. Если ответ динамический, лучше использовать SSR. Иногда наилучшим решением является гибридный подход. SSR выполняется по мере необходимости, каждый раз при запросе файла, в то время как статическая отрисовка происходит один раз во время сборки. Оба подхода удобны для SEO, поскольку в теле HTML в обоих случаях содержится полный контент.
Итоги
Настройка SSR для Create React App сводится к трём элементам:
- Отображение маркировки, отрисованной на сервере, с помощью
ReactDOM.hydrateRoot(). - Отрисовка компонентов в статическую маркировку с использованием объекта
ReactDOMServer, чьи API обеспечивают как SSR, так и статическую отрисовку.
Для реального проекта разумным выбором будет фреймворк или библиотека, которые уже выполняют эту функцию.
Want to Connect?
If you are interested, check out my directory of web development articles.