Это вторая статья о Server Side Rendering и изоморфных/универсальных приложениях на React. Первая под названием "Упрощаем универсальное/изоморфное приложение на React + Router + Redux + Express" была больше про кастомное решение, эта же статья нацелена больше на тех, кому не хочется заморачиваться, а хочется готовое решение, с коммьюнити, и вообще поменьше головной боли с настройкой, отладкой, подбором библиотек и т.д.


+


В данной статье будем рассматривать Next.JS, который обладает преимуществами в виде отсутствия конфигурации, серверного рендеринга и готовой экосистемы.


Из коробки Next.JS не умеет работать с Redux, поэтому в процессе написания пробного проекта я выделил получившийся общий код в отдельный репозиторий next-redux-wrapper, с помощью которого в этой статье мы и соберем приложение-пример на Next.JS + Redux.


О чем все это


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


Сейчас стремительно набирают популярность решения типа Create React App, которые постулируют подход "ноль конфигурации", все ставится одной командой, работает прямо из коробки, но содержит кучу ограничений, к тому же серверным рендерингом не обладает совсем. Можете ознакомиться с моей статьей на эту тему: Что взять за основу React приложения.


Для этого есть альтернативы типа Next.JS и Electrode. Почему бы не взять их и забыть о мучениях? Или не забыть? Может все только хуже станет. Но на самом деле все зависит от задачи, и чтобы быстро сляпать приложение обычно гибкости хватает, но вот некоторые ограничения, о которых стоит помнить, начиная работу с Next.JS:


  1. В нем нет React Router, роутинг из коробки очень тупой и даже путь с подстановками вида /path/:id/:foo не умеет (для этого есть отдельные решения), но хотя бы есть поддержка query.
  2. Не поддерживает импорт CSS/LESS/SASS и т.д., вместо этого там CSS in JSX, но можно добавить стили напрямую в документ, но тогда не будет работать их Hot Reload.
  3. Конфигурация Webpack (который используется внутри) хоть и может быть изменена вручную, но добавление Loader'ов сильно не рекомендуется.

Жизненный цикл Next.js


В процессе рендеринга страниц мини-роутер берет соответствующий файл из директории ./pages, берет из него default export и использует статичный метод getInitialProps из экспортированного компонента для того, чтоб забросить в него эти самые props. Метод может быть асинхронным, т.е. возвращать Promise. Этот метод вызывается как на сервере, так и на клиенте, разница только в количестве получаемых аргументов, например, на сервере есть req (который является NodeJS Request). И клиент и сервер получают нормализованные pathname и query.


Примерно так выглядит код для страницы:


export default class Page extends Component {
  getInitialProps({pathname, query}) {        
    return {custom: 'custom'}; // это станет начальным набором props у страницы
  }
  render() {
    return (
      <div>
        <div>Prop from getInitialProps {this.props.custom}</div>
      </div>
    )
  }
}

Или в функциональном стиле:


const Page = ({custom}) => (
  <div>
    <div>Prop from getInitialProps {this.props.custom}</div>
  </div>
);
Page.getInitialProps = ({pathname, query}) => ({        
  custom: 'custom'
});
export default Page;

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


getInitialProps({store, pathname, query}) {
  // component will read it from store's state when rendered
  store.dispatch({type: 'FOO', payload: 'foo'});
  // pass some custom props to component
  return {custom: 'custom'}; 
}

Но для того, чтобы это заработало Store нужно где-то создать, причем и на сервере и на клиенте, причем на клиенте Store как правило всегда один, а на сервере он должен быть создан для каждого запроса отдельно.


Помимо аргумента getInitialProps этот же Store должен быть предоставлен Redux Provider'у, чтобы все вложенные в него компоненты могли получить доступ к Store.


Для этой цели лучше всего подходит концепция Higher Order Components. В двух слова это функция которая принимает компонент в качестве аргумента и возвращает его обернутую версию, которая тоже является компонентом, например React Router withRouter(Cmp). Иногда функция может принимать аргументы, и возвращать другую функцию, которая уже примет компонент, это называется каррирование, например React Redux connect(mapStateToProps, mapDispathToProps)(Cmp). Обертка, которую мы собираемся ��спользовать, должна быть применена ко всем страницам, чтобы быть уверенным, что все начальные условия всегда одинаковы.


Создание приложения


Для начала, поставим все пакеты:


npm install next-redux-wrapper next@^2.0.0-beta redux react-redux --save

Создадим вещи, необходимые для Redux:


import React, {Component} from "react";
import {createStore} from "redux";

const reducer = (state = {foo: ''}, action) => {
    switch (action.type) {
        case 'FOO':
            return {...state, foo: action.payload};
        default:
            return state
    }
};

const makeStore = (initialState) => {
    return createStore(reducer, initialState);
};

Теперь обернем страницу в next-redux-wrapper:


import withRedux from "next-redux-wrapper";

const Page = ({foo, custom}) => (
  <div>
    <div>Prop from Redux {foo}</div>
    <div>Prop from getInitialProps {custom}</div>
  </div>
);

Page.getInitialProps = ({store, isServer, pathname, query}) => {
  store.dispatch({type: 'FOO', payload: 'foo'});
  return {custom: 'custom'}; 
};

// здесь мы берем makeStore и отдаем его обертке
Page = withRedux(makeStore, (state) => ({foo: state.foo}))(Page);

export default Page;

Диспатчить можно и Promise, в этом случае нужно будет дождаться его завершения и уже тогда вернуть initial props.


Вот, собственно, и все. Вся магия происходит внутри обертки, а снаружи мы видим чистую и красивую имплементацию. Полный пример можно посмотреть в репозитории Next.js: https://github.com/zeit/next.js/blob/master/examples/with-redux/README.md или посмотреть на пример в репозитории обертки: https://github.com/kirill-konshin/next-redux-wrapper/blob/master/pages/index.js.


Для запуска достаточно просто написать в консоли:


node_modules/.bin/next

P.S.


Я обнаружил один неприятный момент, если вы хотите использовать pages/_document.js (который позволяет собрать шаблон всей страницы) и диспатчить экшны из его getInitialProps, а также с конечной страницы, то может возникнуть race condition. Порядок вызова этих функций не особо контролируется и может так получиться, что они будут работать параллельно, поэтому в моменты когда рендерится шаблон страницы и сама страница состояние может быть разным.


Сама обертка этот сценарий поддерживает, потому что она сохраняет Store в самом запросе, а также гарантирует, что на клиенте Store всегда один. Однако авторы Next.js говорят, что это плохая практика иметь диспатчи в _document.js. Они предлагают вместо этого написать еше один HOC, уже кастомный, и делать все там, но это остается за рамками статьи.