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

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

Предисловие

Начнем с термина «Изоморфное приложение». Изоморфное приложение JavaScript - это просто приложение JavaScript, которое может работать как на стороне клиента, так и на стороне сервера (в большинстве случаев это одностраничное приложение, которое можно запускать на сервере). Мне не нравится слово «изоморфный», я считаю, что программисты должны бороться со сложностью (не только в коде), а слово «изоморфный» усложняет понимание, следовательно, увеличивает сложность :). Есть еще одно название изоморфного JavaScript - «Универсальный JavaScript», но, на мой взгляд, слово «универсальный» слишком общее. Итак, в этом посте я буду использовать слово «изоморфный» (даже если оно мне не нравится :)).

Как люди видят изоморфное приложение? В Интернете можно найти такие диаграммы:

Но в идеале это должно выглядеть немного иначе:

Я имею в виду, что нужно повторно использовать не менее 90% вашего кода. В больших приложениях он может быть даже больше 95%.

Если вы хотите разработать изоморфное приложение с нуля, начните с чтения этого руководства Ручное создание изоморфного приложения Redux (с любовью) ». Это действительно здорово!

О приложении

В WebbyLab мы используем React с момента его открытия в Facebook почти в каждом нашем проекте. Мы создали клон ключевой заметки, клон Excel, множество гибридных мобильных приложений, пользовательский интерфейс для системы социального мониторинга, систему модерации комментариев, систему бронирования билетов, множество пользовательских интерфейсов администратора и многое другое. Иногда мы делаем изоморфные приложения. Фундаментальное различие между обычным SPA и изоморфным SPA заключается в том, что в изоморфном SPA вы будете обрабатывать несколько запросов одновременно, поэтому вы должны каким-то образом иметь дело с глобальным зависимым от пользователя состоянием (например, текущий язык, состояние хранилищ потоков и т. Д.).

Itsquiz.com - один из наших проектов, написанных на ReactJS. itsquiz.com - это платформа для облачного тестирования с множеством удивительных функций. И одна из ключевых особенностей продукта - это общедоступный каталог викторин (он же quizwall), где любой пользователь может публиковать свои тесты и проходить другие. Например, вы можете пойти туда и проверить свои знания ReactJS.

Вы можете посмотреть 1-минутный промо-ролик, чтобы лучше понять идею продукта:

Вот основные требования к Quizwall:

  1. Контент доступен без авторизации.
  2. Он должен индексироваться поисковыми системами.
  3. Он должен иметь функции обмена в социальных сетях.
  4. Он должен поддерживать разные языки.
  5. Он должен работать быстро

Написание изоморфного приложения - самое простое и подходящее решение в этом случае.

Какие части вашего приложения должны быть изоморфными?

  1. Изоморфный вид
  2. Изоморфные стили
  3. Изоморфная маршрутизация
  4. Получение изоморфных данных
  5. Изоморфная конфигурация
  6. Изоморфная локализация

Пойдем по порядку.

Изоморфный вид (Радость # 1)

Это самая простая часть. Просто потому, что разработчики Facebook решили эту проблему уже в ReactJS. Единственное, что нам нужно сделать, это взять библиотеку React Js и использовать ее согласно документации.

Код клиента:

Код сервера:

Как видите, мы просто используем ReactDOM.renderToString вместо ReactDOM.render. Вот и все. Ничего сложного, вы можете найти это в любом уроке.

Изоморфные стили

Обычно в руководствах это не учитывается. И это первое место, где начинаешь чувствовать боль;).

Боль no 1: импорт стилей

Мы используем webpack и обычно импортируем стили, специфичные для компонента, в сам компонент. Например, если у нас есть компонент с именем Footer.jsx, тогда у нас будет меньше файла с именем Footer.less в той же папке. И Footer.jsx импортирует Footer.less. Компонент будет иметь класс по его имени («Нижний колонтитул»), и все стили будут помещены в пространство имен этого класса.

Вот небольшой пример:

Такой подход делает наш код более модульным. Более того, если мы импортируем компонент, он автоматически импортирует его зависимости (js-библиотеки, стили, другие активы). Webpack отвечает за обработку всех типов файлов. Итак, у нас есть автономные компоненты.

Этот подход отлично работает с webpack. Но это не будет работать в чистых nodejs, потому что вы не можете импортировать файлы «less». Итак, я начал искать решение.

Первым возможным было require.extensions, но

  1. Стабильность функции: 0
  2. Статус: «устарело»
  3. Не работает с babel-node. Я не уверен, почему требуется дополнительное расследование.

Итак, я начал искать другое решение. Самым простым было использование встроенных стилей.

Встроенные стили.

Я решил попробовать встроенные стили, потому что:

  1. У встроенных стилей нет проблем с серверным импортом. Их можно сохранять в файлах json.
  2. React поддерживает встроенные стили
  3. Встроенные стили решают множество проблем с CSS без хаков - React: CSS in JS by vjeux

При их использовании я столкнулся с несколькими проблемами:

  1. Вы должны эмулировать псевдо-атрибуты CSS, такие как :hover, :active, :focus, с помощью JavaScript.
  2. Вы должны управлять префиксами поставщиков самостоятельно
  3. Вы должны эмулировать медиа-запросы с помощью JavaScript
  4. Вам нужно каким-то образом объединить стили. (с css вы обычно просто упоминаете несколько имен классов)

Я нашел отличный инструмент для работы со встроенными стилями под названием Radium. Это отличный инструмент, который решает все упомянутые проблемы, если вы разрабатываете SPA.

Боль no 2: автоматическое добавление префиксов поставщика на основе DOM браузера

Мы перешли на Radium, но когда мы запускали наше приложение в изоморфном режиме, мы получали странные предупреждения.

«React ввел новую разметку, чтобы компенсировать то, что работает, но вы потеряли многие преимущества серверного рендеринга». Нет, мне нужны все преимущества серверного рендеринга. Мы запускаем один и тот же код на сервере и на клиенте, так почему же React генерирует разную разметку? Проблема заключается в автоматическом префиксе поставщика Radium. Radium создает элемент DOM для определения списка свойств CSS, которые должны иметь префиксы поставщика.

Вот проблема на Github « Префикс прерывает рендеринг сервера ». Да, теперь есть решение для этого: использование автопрефикса Radium на стороне клиента и обнаружение браузера пользовательским агентом и вставка различных префиксов (с встроенным префиксом стиля) для запросов из другого браузера на сервере. Я пробовал, но тогда решение оказалось ненадежным. Может, сейчас работает лучше (можете проверить самостоятельно :)).

Вторая проблема в том, что нельзя использовать медиа-запросы. На вашем сервере нет информации о размере окна вашего браузера, разрешении, ориентации и т. Д. Вот связанная проблема https://github.com/FormidableLabs/radium/issues/53.

Решение, которое работает

Решил вернуться на меньше и БЭМ, но с условным импортом.

Вы видите, что мы используем require вместо import, чтобы сделать его зависимым от времени выполнения, поэтому nodejs не потребует его, когда вы запускаете код на сервере.

Еще одна вещь, которую нам нужно сделать, - это определить process.env.BROWSER в нашей конфигурации webpack. Сделать это можно следующим образом:

Вы можете найти всю производственную конфигурацию на GitHub.

Альтернативное решение - создать плагин для babel, который будет просто возвращать {} на сервере. Я не уверен, что это возможно. Если сумеете создать babel-stub-plugin - будет круто.

ОБНОВЛЕНИЕ: мы перешли на альтернативное решение после перехода на Babel 6

Мы используем плагин babel-plugin-transform-require-ignore для Babel 6. Особая благодарность @morlay (Morlay Null) за этот плагин.

Все, что вам нужно, это настроить расширения файлов, которые должны игнорироваться babel в .babelrc.

и установите переменную среды BABEL_ENV='node' перед запуском приложения. Итак, вы можете запускать свое приложение вот так cross-env BABEL_ENV='node' nodemon server/runner.js.

Боль №3: пользовательский интерфейс материала использует префикс поставщика на основе модели DOM браузера

В порядке. Пойдем дальше. Мы справились со своими стилями. И вроде бы проблемы со стилями решены. Мы используем библиотеку компонентов Material UI для нашего UI, и нам это нравится. Но проблема в том, что он использует тот же подход к автопрефиксу поставщиков, что и Radium.

Поэтому нам пришлось перейти на Material Design Lite. Мы используем обертку react-mdl для React.

Отлично, вроде бы мы однозначно решили все проблемы, связанные со стайлингом… извините, не в этот раз.

Проблема №4: порядок загрузки активов

Webpack сгенерирует для вас пакет javascript и упакует все файлы CSS в один пакет. В SPA это не проблема - вы просто загружаете пакет и запускаете приложение. С изоморфным SPA все не так однозначно.

Во-первых, рекомендуется переместить bundle.js в конец разметки. В этом случае пользователь не будет ждать, пока загрузится большой (он может быть несколько мегабайт) bundle.js. Браузер немедленно отобразит HTML.

Это работает. Но перемещение bundle.js в конец также перемещает стили в конец (поскольку они упакованы в один и тот же комплект). Итак, браузер будет отображать разметку без CSS, а после этого он загрузит bundle.js (с CSS в нем) и только после этого применит стили. В этом случае пользовательский интерфейс будет мигать.

Поэтому правильный способ - разделить ваш пакет на две части и загрузить все в следующем порядке:

  1. Загрузить CSS
  2. Загрузить компоненты HTML-разметка
  3. Загрузить JS

И что самое интересное в webpack, так это то, что в нем много плагинов и загрузчиков. Мы используем extract-text-webpack-plugin для извлечения CSS в отдельный пакет.

Ваш конфиг будет похож на этот.

Вы можете найти всю конфигурацию производственного веб-пакета на GitHub.

Изоморфная маршрутизация

Радость # 2 - React Router

Маршрутизатор React, начиная с версии 1.0.0, отлично работает в изоморфной среде. Но есть много устаревших руководств, написанных, когда react-router-1.0.0 все еще находился в стадии бета-тестирования. Не волнуйтесь, в официальной документации есть рабочий пример маршрутизации на стороне сервера.

Получение изоморфных данных

Радость # 3 - Redux

Redux - еще одна библиотека, которая отлично работает в изоморфной среде. Основные проблемы с изоморфными приложениями:

  1. Сервер обрабатывает несколько запросов одновременно. Итак, у вас должно быть изолированное состояние для каждого запроса. В этом случае одноэлементный поток не хранится.
  2. Вы должны создавать новые магазины для каждого запроса.
  3. Вы должны сбросить все состояния хранилищ в конце обработки запроса и передать это состояние в браузер. Таким образом, браузер сможет заполнить существующие хранилища потоков полученным состоянием и повторно визуализировать дерево React.

С помощью redux это легко сделать:

  1. Всего один магазин
  2. response-redux использует контекст реакции для передачи хранилища, связанного с запросом, вниз по дереву компонентов React
  3. У Redux store есть удобный API для сброса и восстановления состояния магазина.

Здесь вы можете найти рабочий код

Получение данных

Вы должны написать код, который работает как на сервере, так и на клиенте. Обычно в SPA (даже не изоморфных) мы пишем уровень API, который также можно использовать на сервере. Этот уровень отвечает за все коммуникации с REST API. Его можно упаковать как отдельную библиотеку для использования в сторонних проектах.

Вот пример, который работает как на сервере, так и на клиенте:

Для выполнения HTTP-запроса вы можете использовать что-то вроде axios, но я предпочитаю isomorphic-fetch ​​(который использует whatwg-fetch из GitHub) в браузере или node-fetch ​​на сервере. fetch - это стандарт, который уже изначально поддерживается Firefox и Chrome.

Это легкая часть. Более сложная часть - не создать библиотеку API, а использовать ее в изоморфной среде.

Как обычно работает клиент

  1. Реагировать на рендеринг компонентов.
  2. Показать счетчик загрузки
  3. Получить все данные, зависимые от компонента (pgge)
  4. Обновите страницу (повторно визуализируйте компонент React с полученными данными)

Итак, идея проста. Пользователь будет ждать данных, но не будет ждать ответа пользовательского интерфейса. Таким образом, вы должны немедленно выполнить рендеринг без данных и показать счетчик и показать данные, когда они были извлечены.

Как обычно работает сервер

  1. Предварительно загрузить все необходимые данные для страницы
  2. Преобразовать страницу (с данными) в строку
  3. Отправить разметку HTML клиенту

Мы хотим написать один и тот же код для двух сценариев. Как мы с этим справимся?
Идея проста. Мы используем создателей действий, и они запускают выборку данных. Итак, мы должны описать все зависимости страниц - создателей действий, которые будут использоваться для выборки данных.

Изоморфная часть кода будет выглядеть так:

Итак, вы должны обернуть свой компонент другим, который будет отвечать за выборку данных. Для этого мы используем функцию connectDataFetchers. Требуется класс компонента React и массив ссылок на создателей действий.

Как это работает?

В браузере компонент-оболочка будет вызывать создателей действий в componentDidMount ловушке жизненного цикла. Итак, это обычная закономерность для SPA.

ВАЖНО: componentWillMount не подходит для этого, потому что он будет вызываться на клиенте и сервере. componentDidMount будет вызываться только на клиенте.

На сервере мы делаем это по-другому. У нас есть функция fetchComponentsData, которая принимает массив компонентов, которые вы собираетесь визуализировать, и вызывает статический метод fetchData для каждого из них. Одна важная вещь - это использование обещаний. Мы используем обещания, чтобы отложить рендеринг до тех пор, пока необходимые данные не будут получены и сохранены в хранилище redux.

connectDataFetchers предельно просто:

Производственная версия немного длиннее. Он передает информацию о локали и propTypes описан.

Итак, на сервере наш код выглядит так:

Вот и все серверное приложение - https://github.com/WebbyLab/itsquiz-wall/blob/master/server/app.js

Изоморфная конфигурация

Самый простой способ - использовать config.json и требовать его везде, где это необходимо. И вы можете обнаружить, что многие люди так поступают. На мой взгляд, это плохое решение для изоморфных SPA.

Что в этом плохого?

Проблема в том, что когда вам потребуется config.json, webpack упакует его в ваш пакет.

  1. Вы не можете изменить конфигурацию, не перестроив приложение. Для меня это не вариант, потому что я хочу использовать одну и ту же сборку при постановке, а затем и на производстве, единственная разница - это параметры конфигурации.
  2. У вас может быть несогласованное состояние конфигурации. Бэкэнд видит новый, но фронтенд не видит изменений, потому что конфиг упакован в бандл.

Решение состоит в том, чтобы оставить конфигурацию вне пакета и поместить ее в какую-то глобальную переменную, которая может быть установлена ​​в index.html.

Загружаем конфиг на сервер и возвращаем в index.html

ВАЖНО: сериализация initialState с JSON.strigify сделает ваше приложение уязвимым для атак XSS !!! Вместо этого вам следует использовать serialize-javascript!

Но зависеть от глобальной переменной в вашем коде - не лучшая идея. Итак, мы создаем модуль config.js, который просто экспортирует глобальную переменную. А наш код зависит от модуля config.js. Наш config.js должен быть изоморфным, поэтому на сервере нам просто нужен файл json.

и мы используем config.js в нашем изоморфном коде следующим образом:

Изоморфная локализация

Очень мало руководств объясняют, как работать с локализацией в обычном SPA. Никакие учебники вообще не говорят, как работать с локализацией в изоморфной среде. В общем, для большинства разработчиков это не проблема, потому что нет необходимости поддерживать другие языки, кроме английского. Но это действительно важная тема, поэтому я решил описать вопросы локализации в отдельном посте. Это будет непрерывное руководство по локализации приложений React (включая вопросы изоморфизма).

Статистика

Универсальный (изоморфный) код - 2396 SLOC (93,3%)

Индивидуальный код клиента - 33 SLOC (1,2%)

Код для сервера - 139 SLOC (5,4%)

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

Первоначально опубликовано на blog.webbylab.com.