javascript

Создаем изоморфное/универсальное приложение на Next.JS + Redux

  • пятница, 10 марта 2017 г. в 03:14:44
https://habrahabr.ru/post/323588/
  • Клиентская оптимизация
  • ReactJS
  • Node.JS
  • JavaScript


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


+


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


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


О чем все это


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


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


Для этого есть альтернативы типа 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, уже кастомный, и делать все там, но это остается за рамками статьи.