Создаем изоморфное/универсальное приложение на Next.JS + Redux
- пятница, 10 марта 2017 г. в 03:14:44
Это вторая статья о 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:
/path/:id/:foo
не умеет (для этого есть отдельные решения), но хотя бы есть поддержка query
.В процессе рендеринга страниц мини-роутер берет соответствующий файл из директории ./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
Я обнаружил один неприятный момент, если вы хотите использовать pages/_document.js
(который позволяет собрать шаблон всей страницы) и диспатчить экшны из его getInitialProps
, а также с конечной страницы, то может возникнуть race condition. Порядок вызова этих функций не особо контролируется и может так получиться, что они будут работать параллельно, поэтому в моменты когда рендерится шаблон страницы и сама страница состояние может быть разным.
Сама обертка этот сценарий поддерживает, потому что она сохраняет Store в самом запросе, а также гарантирует, что на клиенте Store всегда один. Однако авторы Next.js говорят, что это плохая практика иметь диспатчи в _document.js
. Они предлагают вместо этого написать еше один HOC, уже кастомный, и делать все там, но это остается за рамками статьи.