image

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

Мы испытали это на собственном опыте в том проекте, над которым я сейчас работаю. Сегодня у нас есть полнофункциональное SSR-решение, которое работает в продакшене уже почти три года.

В этом примере вы найдете:

  • Конкретные советы по решению проблем, порой возникающих при внедрении SSR
  • Описание нашего подхода, позволившего нам разобраться в SSR с нуля и постепенно внедрить эту технологию в продакшене.
  • Логику обоснования наших решений.

Введение


В этом примере рассказано, как мы внедрили SSR с помощью React в приложении электронной коммерции для одного из крупнейших телекоммуникационных операторов Норвегии, Telia. Приложение создавалось почти 3 года. и в нашей команде 6 разработчиков.

Нам потребовалось много часов, чтобы создать надежное SSR-решение, которое удобно поддерживать. Обучение, эксперименты и внедрение – все это требует времени. Рассказывая вам, как мы все продумали, а также останавливаясь на Делясь нашим мыслительным процессом и конкретных деталях реализации, я хочу помочь вам сэкономить время и силы, требуемые для успешного внедрения SSR.

Это честный пример. Я знакомлю вас с принятыми нами решениями, которые оказались неокончательными. При разработке программного обеспечения вы редко создаете «правильную» архитектуру с первой попытки, но вы пробуете что-то новое и постоянно совершенствуете свою кодовую базу.

Один из ключевых выводов из этого рассказа – «не усложняйте». Самое важное, что мы узнали за 3 года работы над приложением, так это что простота очень помогает при реализации и поддержке SSR-решения.

Если вам нужно почитать вводную статью о SSR – рекомендую мой пост «Getting started with React SSR».

Стек


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

  • На бекенде мы используем node и express.
  • Во фронтенде мы используем React и Redux.
  • Мы используем webpack для сборки и запускаем приложение на AWS.

Сайт


Приложение представляет собой интернет-магазин одной из крупнейших телекоммуникационных компаний Норвегии – Telia. Приложение находится по адресу nettbutikk.telia.no

image

В интернет-магазине вы можете приобрести оборудование, в частности, телефоны, планшеты и аксессуары. Основным предложением является подписка, которую можно приобрести отдельно или в комплекте с оборудованием.

История React и SSR в нашем проекте


Давайте начнем с того, как мы внедрили SSR еще в 2015 году, когда начинали проект. Может быть интересно разобраться, какие ошибки мы допустили и как их исправили. Если вы просто хотите посмотреть, как наше решение выглядит сегодня, вы можете пропустить этот раздел.
Когда мы начинали этот проект, нас было трое опытных программистов, и нам было поручено переделать имеющийся интернет-магазин. Мы выбрали для решения этой задачи Node.js /React. Ни у кого из нас еще не было опыта работы с React, мы только почитали туториалы и проделали нескольких простых экспериментов.

Выбор Reflux (Redux еще не существовал)


В то время многие разработчики внедряли Flux с нуля, а Redux еще не существовал. Мы нашли библиотеку под названием Reflux, это была одна из реализаций Flux. Нам показалось разумным не изобретать велосипед, поэтому мы выбрали именно это решение.

Одна из проблем Reflux, связанная с SSR, заключается в том, что хранилища там реализованы как одиночки и не предназначены для работы на сервере. Итак, у нас было несколько гнусных багов,, когда состояние данных оказывалось частично разделяемым между пользователями.
Когда мы открыли для себя Redux, мы быстро заинтересовались, каков он в деле., и мы захотели начать его использовать. Сегодня мы перенесли на Redux большую часть кодовой базы, и прямо сейчас работаем над удалением последних остатков Reflux!

image

Запрашивание клиентских пакетов с бекенда


Чтобы получить доступ к фронтенду из бекенда, мы перешли к обязательному использованию клиентских пакетов (вначале мы не использовали ES6). Это был обходной путь, позволяющий обойтись без транспиляции бекенда, чтобы иметь возможность парсить JSX.

Недостаток этого решения заключался в том, что нам приходилось генерировать клиентский пакет при каждом изменении кода во время разработки. А это медленно. Мы придумали костыль: отключать SSR в режиме разработки. Догадываетесь, к чему это привело? Когда мы случайно сломали SSR, мы этого просто не заметили, потому что в режиме разработки такого не происходило. Так в продакшене оказалась развернута масса экземпляров с неработающим SSR.
Мы отказались от использования сгенерированного клиентского пакета на бекенде. Вместо этого мы запускаем весь бекенд через babel. Таким образом, мы можем без проблем запускать SSR в режиме разработки. Кроме того, стало намного проще судить о SSR, чем при включении пакета. Подробнее о том, как мы транспилируем JSX, поговорим позже.

Сегодня


Теория SSR довольно проста: вы просто отображаете свои компоненты React в бекенде и отправляете сгенерированный HTML на клиент. Но на практике приходится еще много о чем позаботиться.

Давайте пройдемся по всем областям кодовой базы, на которые влияет SSR.

Транспиляция бекенда


Чтобы иметь возможность выполнять рендеринг на стороне сервера, бекенд должен интерпретировать JSX так же, как и фронтенд. Для этого мы запускаем код бекенда через babel так же, как мы делаем это на фронтенде.

Для этого вы можете использовать либо webpack, либо только babel. Мы начали с реализации с использованием webpack, но затем перешли на использование babel только потому, что так стало намного проще, и мы поняли, что webpack на бекенде нам не нужен ни для чего на… кроме babel.

Мы следующим образом запускаем babel следующим образом для сборки, которая пойдет в продакшен:

babel . --out-dir ../dist/ --source-maps --copy-files

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

require("babel-core/register")
require("./server.js")

Когда требуется babel-core/register, он автоматически компилирует необходимые файлы на лету. И мы запускаем этот файл с помощью nodemon следующим образом:

nodemon server.babel.js

Nodemon автоматически перезапускает сервер при внесении изменений в код.

Шаблонизация на бекенде


Генерация HTML из компонентов React на сервере выполняется в одну строку:

const app = ReactDomServer.renderToString(component)

Мы помещаем этот сгенерированный HTML в HTML-шаблон, где мы определяем теги html, body и т. д. Мы делаем это с помощью шаблонизатора под названием handlebars.

Упрощенная версия нашего шаблона выглядит так:

<!DOCTYPE html>
<html>
  <head>
  </head>
<body>
  <script>
	window.__INITIAL_STATE = {{{initialState}}};
  </script>
  <div id="app">{{{app}}}</div>
</body>
</html>

Сгенерированный HTML вводится в {{{app}}}. Здесь мы также монтируем компоненты React на фронтенде.

Используем шаблон из express вот так:

return res.render("appTemplate", {
  app,
  initialState,
})

Первый аргумент – это имя шаблона. Второй аргумент – это переменные для внедрения в шаблон.

Вы заметили эту переменную initialState? Дальше рассказано, что это такое!

Извлечение данных при начальной загрузке


Приложение представляет собой интернет-магазин, который показывает информацию о продукте. Эта информация хранится в системах бекенда, и оттуда мы получаем ее через REST API. Мы, конечно же, хотим, чтобы данные при загрузке страницы доставались как можно быстрее.

Мы не делаем никаких вызовов Ajax в наших компонентах при загрузке страницы. Вместо этого мы используем Redux и определяем начальное состояние, которое используем при создании хранилища на бекенде. Исходное состояние извлекается из наших серверных API и содержит информацию о продукте, данные о заказах в корзине, информацию о подписке и т. д.

Мы создаем наш магазин на Redux с InitialStateна бекэнде следующим образом:

const initialState = {
  // данные – например, информация о товаре, конфигурация, т.д.
}
 
// Состояние intitialState отправляется в createStore:
const store = createStore(reducers, initialState)
 
const componentWithStore = (
  <Provider store={store}>
	<Component />
  </Provider>
)

const html = ReactDomServer.renderToString(componentWithStore)

Переменная initialState также отправляется на фронтенд в виде глобальной переменной, чтобы приложение на стороне клиента получало точно такое же предвыбранное состояние. Код выглядит так:

const initialState = window.__INITIAL_STATE // то же самое исходное состояние, что использовалось в версии с рендерингом на стороне сервера
 
const store = createStore(reducer, initialState)
 
ReactDOM.render(
  <Provider store={store}>
	<Component />
  </Provider>,
  element
)

Это решение для получения данных о загрузке страницы как для HTML, отображаемого на стороне сервера, так и для клиента — самое быстрое и простое в обслуживании, которое мы нашли. Этот подход очень хорошо разобран в документации Redux.

CSS


Мы используем старомодное решение для CSS: less, и пишем его вне нашего JS-приложения. Никаких причудливых css-in-js.

Мы отделили нашу сборку LESS от webpack. Мы вызываем less в рамках нашего процесса сборки, который, в свою очередь, вызывается из скрипта NPM. Однако в режима разработки все немного иначе. Мы запускаем LESS и CSS через webpack, чтобы добиться горячей перезагрузки CSS.

Наша конфигурация CSS – из тех вещей, что были настроены в самом начале проекта, и с тех пор мы ее ни разу не трогали. У нас не было никаких проблем с SSR при применении этого простого решения.

Маршрутизация


Наш интернет-магазин – не одностраничное приложение (SPA). Это означает, что мы делаем всю маршрутизацию на бекенде.

Процесс создания новой веб-страницы таков:

  1. Создать новую запись в таблице маршрутов
  2. Создать новый контроллер
  3. Извлечь данные из бекендов и инициализировать состояние, отобразить компоненты React и отправить в шаблонизатор (как описано ранее).

Как видите, для настройки новых маршрутов нужно написать некоторое количество стереотипного кода (даже несмотря на то, что мы в основном избавились от такого кода на этапе 3.)

Но одно преимущество этого решения заключается в том, что оно простое и легко осмысливается.

Разделение кода


Реализация ленивой загрузки пакетов, равно как и SSR – сложная задача.

Поэтому мы решили обойтись одним лишь SSR.

Мы разделяем наш пакет. У нас есть один пакет с зависимостями и один с боевым кодом. Это не сказывается на сложности SSR.

Избегайте ссылок на window, document и т. д. на сервере


При выполнении рендеринга на стороне сервера бекенд теперь должен при необходимости запускаться через node. Node и браузер не идентичны на 100%. Одно из основных отличий между ними состоит в том, что node не использует глобальные переменные window и document. Таким образом, если код фронтенда зависит от определяемого окна или документа, вы получите ошибки времени выполнения на бекенде. Эти ошибки выглядят примерно так:

ReferenceError: window is not defined

Самый простой способ решить эту проблему – использовать if, чтобы избежать запуска этих строк кода на бекенде. Эти строки никоим образом не влияют на рендеринг компонентов React, поэтому мы можем спокойно пропустить их выполнение.

У нас есть функции isRunningOnClientside и isRunningOnServerside, которые мы определили в служебной библиотеке. Они реализованы так:

function isRunningOnServerSide() {
  return typeof window === "undefined"
}

isRunningOnClientSide() {
	return typeof(window) !== "undefined";
};

Всякий раз, когда нам нужно сослаться на какую-то сущность, важную только на клиенте, мы подстраховываемся при помощи if, например,

function sendToGa(data) {
  if (util.isRunningOnServerSide()) {
	return
  }
  window.dataLayer.push(data)
}

Обратите внимание, что мы используем паттерн с охранными выражениями, который выделяет основной поток кода и позволяет заблаговременно перестраховаться от потока ошибок. Это также снижает потребность во вложенных операторах if.
Вот несколько примеров переменных, от которых мы защищаемся на стороне сервера:

  • window.location.search
  • window.history.replaceState
  • this.isTouchEnabled = 'ontouchstart' in document.documentElement;
  • Google-аналитика (window.dataLayer, window.google_tag_manager)
  • Facebook-аналитика (window.fbq)

Чтобы определиться, когда добавлять защиту, действуем примерно так:

  1. Пишем код
  2. Следить в консоли, не возникают ли ошибки, если видим ошибку, связанную с тем, что не определены окно или документ, то
  3. Включаем охранное выражение

Мы также стараемся использовать только те зависимости, которые работают с SSR. Обычно нам приходится выяснять их работоспособность путем тестирования.

Как насчет Next.js для выполнения SSR?


Next.js – это фреймворк для приложений React, отображаемых на стороне сервера. Мы оценили, но решили не использовать его.

Когда мы начали работать над этим проектом, Next.js еще не существовало. Он был выпущен, когда кодовая база уже была довольно большой. Переписать большое приложение для использования Next.js – большая задача. И у нас есть другие области нашей кодовой базы, которые дают нам гораздо больше пользы для масштабного рефакторинга.

Даже если бы Next.js существовал на момент запуска описываемого проекта, я не думаю, что мы бы его использовали. Принимая технологические решения для критически важного приложения, над которым небольшая команда будет работать в течение многих лет, я хочу подумать о следующем:

  • Технология должна остаться актуальной в перспективе и иметь активное сообщество, особенно в будущем.
  • Нельзя замыкаться в одном фреймворке. Должна быть возможность изменить любую библиотеку/фреймворк/платформу.

Я думаю, что Next.js будет по-прежнему актуален через 5 лет, но меня беспокоит то, что от Next.js будет трудно отказаться, если в будущем это потребуется. Это фреймворк на любителя, диктующий вам, как писать код. Поэтому в нем легко замкнуться, и в приложении такого лучше избегать.

Вывод


Было непросто создать такое SSR-решение, которым мы остались довольны., было непростым делом. Мы потратили много времени на то, чтобы понять, как работает SSR, и какая реализация лучше всего подходит для нашего конкретного случая. Мы также потерпели много неудач на этом пути проб и ошибок.

Я считаю, что ключ к нашему успеху с SSR заключается в том, что мы смогли не усложнять код.

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

Вы хотите реализовать SSR в своем приложении? Начните отсюда.

Комментарии (1)


  1. webdevium
    09.09.2022 14:34
    +1

    Я так и не понял логику в том, чтоб сначала «избавиться» от серверного рендеринга и перейти на браузерный, а потом городить велосипед и заново учить систему генерировать отображение на сервере. Сюр.