Разработка изоморфного приложения глазами моей жены
Это продолжение статьи про разработку изоморфного приложения с нуля на React.js. В этой части мы добавим несколько страниц, bootstrap, роутинг, концепцию Flux и ее популярную реализацию Redux.
Оглавление
1) Собираем базовый стек изоморфного приложения
2) Делаем простое приложение с роутингом и bootstrap
3) Реализуем взаимодействие с API и авторизацию
Итак, в первой части мы закончили на том, что разработали простой компонент HelloWorld и собрали окружение для сборки и контроля качества кода. Настало время сделать полноценный сайт, а это значит, что мы добавим еще несколько страниц, свяжем их ссылками и реализуем изоморфный роутинг.
1. Добавляем в проект react-bootstrap
Это очень популярная библиотека, которая позволяет использовать элементы bootstrap в стиле React.
Например, вместо конструкций вида
<div className="nav navbar">
мы сможем писать
<Nav navbar>
Также не придется использовать JavaScript-код оригинального bootstrap, ведь он уже реализован в компонентах react-bootstrap.
Устанавливаем react-bootstrap
npm i --save react-bootstrap
Вносим изменения в проект
Выделим виджет HelloWorld из App.jsx в отдельный компонент. Напоминаю, что App.jsx — это точка входа в изоморфную часть приложения, и мы ее скоро перепишем в виде layout'а, внутри которого будут отображаться запрошенные пользователем страницы.
Рефакторинг
- Создадим папку src/components/HelloWorldPage
- Переименуем App.jsx в HelloWorldPage.jsx, App.css в HelloWorldPage.css
- Переместим файлы HelloWorldPage.jsx и HelloWorldPage.css в папку src/components/HelloWorldPage
mkdir src/components/HelloWorldPage
mv src/components/App.jsx src/components/HelloWorldPage/HelloWorldPage.jsx
mv src/components/App.css src/components/HelloWorldPage/HelloWorldPage.css
- Внесем изменения в HelloWorldPage.jsx
--- import './App.css';
+++ import './HelloWorldPage.css';
- Создадим файл index.js со следующим содержанием
src/components/HelloWorldPage/index.js
import HelloWorldPage from './HelloWorldPage';
export default HelloWorldPage;
Этот шаг позволит нам импортировать наш компонент так
import HelloWorldPage from 'components/HelloWorldPage';
вместо
import HelloWorldPage from 'components/HelloWorldPage/HelloWorldPage';
Это аккуратнее и упрощает сопровождение исходного кода приложения.
Создаем App.jsx
- Создаем папку App
- В папке App создаем два файла: index.js и App.jsx
mkdir src/components/App
src/components/App/index.js
import App from './App';
export default App;
src/components/App/App.jsx
import React, { Component } from 'react';
import Grid from 'react-bootstrap/lib/Grid';
import Nav from 'react-bootstrap/lib/Nav';
import Navbar from 'react-bootstrap/lib/Navbar';
import NavItem from 'react-bootstrap/lib/NavItem';
import HelloWorldPage from 'components/HelloWorldPage';
class App extends Component {
render() {
return (
<div>
<Navbar>
<Navbar.Header>
<Navbar.Brand>
<span>Hello World</span>
</Navbar.Brand>
<Navbar.Toggle />
</Navbar.Header>
<Navbar.Collapse>
<Nav navbar>
<NavItem>Время</NavItem>
<NavItem>Счетчики</NavItem>
</Nav>
</Navbar.Collapse>
</Navbar>
<Grid>
<HelloWorldPage />
</Grid>
</div>
);
}
}
export default App;
Важное примечание: обратите внимание, что я явно указываю, какие компоненты react-bootstrap я импортирую. Это поможет webpack в процессе сборки включить только используемую в проекте часть react-bootstrap, а не всю библиотеку целиком, как случилось бы, если бы я написал
import { Grid, Nav, Navbar, NavItem } from 'react-bootstrap';
Важно отметить, что этот маневр работает только в тех случаях, когда используемая библиотека поддерживает модульность. Например, react-bootstrap и lodash к таким относятся, а jquery и momentjs — нет.
Как видно из кода, приведенный выше компонент не работает со state и не использует component workflow callbacks (например, componentWillMount и componentDidMount). Это означает, что его можно переписать в виде так называемого Pure Sateless Function Component.
В будущем компоненты, написанные таким образом, будут иметь преимущество в производительности (спасибо теории функционального программирования и концепции pure functions), а чем более производителен каждый компонент в отдельности, тем более производительное приложение у нас получится в итоге.
Пока же реакт оборачивает подобные компоненты в обычные ES6-классы, но с одним приятным бонусом:
По умолчанию компонент обновляется всегда при получении новых значений props и/или state даже в тех случаях, когда они полностью совпадают с предыдущими. Это не всегда необходимо. У разработчика есть возможность самостоятельно реализовать метод shouldComponentUpdate(nextProps, nextState), который возвращает либо true, либо false. С помощью него вы сами можете явно указать Реакту, в каких случаях вы хотите, чтобы компонент перерисовался, а в каких — нет.
Если же компонент реализован как Pure Stateless Function Component, то Реакт сам в состоянии определить необходимость обновления внешнего вида компонента без явной реализации shouldComponentUpdate, то есть мы получаем больший профит, приложив меньше усилий.
Примечание: код ниже является учебным примером такого компонента. Так как в будущем мы внесем изменения в App.jsx, и он перестанет быть pure stateless компонентом, не следует переносить этот пример в наш проект.
Примечание 2: в нашем проекте я буду реализовывать все компоненты в виде ES6-классов, даже там, где возможно и правильно было бы реализовать их в виде Pure Stateless Functions Components, чтобы не усложнять содержание статьи.
import React from 'react';
import Grid from 'react-bootstrap/lib/Grid';
import Nav from 'react-bootstrap/lib/Nav';
import Navbar from 'react-bootstrap/lib/Navbar';
import NavItem from 'react-bootstrap/lib/NavItem';
import HelloWorldPage from './HelloWorldPage';
function App() {
return (
<div>
<Navbar>
<Navbar.Header>
<Navbar.Brand>
<span>Hello World</span>
</Navbar.Brand>
<Navbar.Toggle />
</Navbar.Header>
<Navbar.Collapse>
<Nav navbar>
<NavItem>Время</NavItem>
<NavItem>Счетчики</NavItem>
</Nav>
</Navbar.Collapse>
</Navbar>
<Grid>
<HelloWorldPage />
</Grid>
</div>
);
}
export default App;
Самое время посмотреть, что изменилось в браузере. И… да, у bootstrap нет стилей. Разработчики react-bootstrap сознательно не включили их в дистрибутив, так как все равно вы будете использовать собственную тему. Поэтому идем на любой сайт с темами для bootstrap, например bootswatch.com, и скачиваем понравившуюся. Сохраним ее в src/components/App/bootstrap.css. Я рекомендую сохранить именно полноценную версию, так как ее проще кастомизировать, а минификацию потом все равно сделает webpack.
Примечание: можно скачать мою тему с репозитория на github.
Внесем изменение в App.jsx
src/components/App/App.jsx
+++ import './bootstrap.css';
Я не хочу сейчас акцентировать внимание на настройке работы с glyphicons, тем более, что мы не будем использовать их в проекте, поэтому просто удалим их из стилей.
src/components/App/bootstrap.css
--- @font-face {
--- font-family: 'Glyphicons Halflings';
--- src: url('../fonts/glyphicons-halflings-regular.eot');
src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');
--- }
Возвращаемся в браузер, и теперь все должно выглядеть хорошо.
Примечание: если при перезагрузке страницы вас раздражает, что сначала появляется старая версия страницы, а новая лишь спустя пару секунд — просто перезапустите nodemon.
2. Добавляем несколько страниц и роутинг.
2.1. Сделаем две заглушки
- Создаем папки src/components/CounterPage и src/components/TimePage
- Напишем код заглушек
src/components/CounterPage/index.js
import CounterPage from './CounterPage';
export default CounterPage;
src/components/CounterPage/CounterPage.jsx
import React, { Component } from 'react';
class CounterPage extends Component {
render() {
return <div>Заглушка для счетчиков</div>;
}
}
export default CounterPage;
src/components/TimePage/index.js
import TimePage from './TimePage';
export default TimePage;
src/components/TimePage/TimePage.jsx
import React, { Component } from 'react';
class TimePage extends Component {
render() {
return <div>Заглушка для времени</div>;
}
}
export default TimePage;
2.2. Добавим роутинг
Для роутинга мы будем использовать библиотеку react-router.
npm i --save react-router
Чтобы она заработала, необходимо внести в наш проект следующие изменения:
- Определим файл с routes. В нем мы укажем соответствие между URL и компонентами, которые должны быть отрендерены.
- В серверной части приложения веб-сервер express передаст URL запроса функции match из react-router. Она либо вернет renderProps, которые мы сможем использовать для рендеринга запрошенного пользователем контента, либо сообщит, что совпадений нет, и тогда мы вернем страницу с 404 ошибкой.
- В клиентскую часть приложения мы тоже внесем изменения, чтобы библиотека react-router смогла отслеживать изменения URL. Если новый URL соответствует одному из настроенных путей, то клиентский JavaScript обновит контент страницы без обращения к серверу. Если же новый URL не соответствует ни одному из настроенных путей, то браузером будет выполнен классический переход по ссылке.
2.2.1. Файл routes
src/routes.jsx
import React from 'react';
import { IndexRoute, Route } from 'react-router';
import App from 'components/App';
import CounterPage from 'components/CounterPage';
import HelloWorldPage from 'components/HelloWorldPage';
import TimePage from 'components/TimePage';
export default (
<Route component={App} path='/'>
<IndexRoute component={HelloWorldPage} />
<Route component={CounterPage} path='counters' />
<Route component={TimePage} path='time' />
</Route>
);
Обратите внимание, что мы по факту экспортируем компонент Реакта. IndexRoute является аналогом index.html или index.php в вебе: если часть пути опущена, то будет выбрана именно она.
Примечание: компоненты Route и IndexRoute могут быть вложены в другие Route сколько угодно раз. В нашем примере мы ограничимся двумя уровнями.
Таким образом мы определили следующее соответствие
URL '/' => компонент вида <HelloWorldPage />
URL '/counter' => <CounterPage />
URL '/time' => <TimePage />
В нашем приложении компонент App должен играть роль лейаута, поэтому необходимо "научить" его рендерить вложенные (children) компоненты.
src/components/App/App.jsx
--- import React, { Component } from 'react';
+++ import React, { Component, PropTypes } from 'react';
import Grid from 'react-bootstrap/lib/Grid';
import Nav from 'react-bootstrap/lib/Nav';
import Navbar from 'react-bootstrap/lib/Navbar';
import NavItem from 'react-bootstrap/lib/NavItem';
--- import HelloWorldPage from 'components/HelloWorldPage';
import './bootstrap.css';
+++ const propTypes = {
+++ children: PropTypes.node
+++ };
class App extends Component {
render() {
return (
<div>
<Navbar>
<Navbar.Header>
<Navbar.Brand>
<span>Hello World</span>
</Navbar.Brand>
<Navbar.Toggle />
</Navbar.Header>
<Navbar.Collapse>
<Nav navbar>
<NavItem>Время</NavItem>
<NavItem>Счетчики</NavItem>
</Nav>
</Navbar.Collapse>
</Navbar>
<Grid>
+++ {this.props.children}
--- <HelloWorldPage />
</Grid>
</div>
);
}
}
+++ App.propTypes = propTypes;
export default App;
2.2.2 Добавим роутинг в серверную часть приложения
src/server.js
--- import App from 'components/App';
+++ import { match, RouterContext } from 'react-router';
+++ import routes from './routes';
app.use((req, res) => {
--- const componentHTML = ReactDom.renderToString(<App />);
+++ match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
+++ if (redirectLocation) { // Если необходимо сделать redirect
+++ return res.redirect(301, redirectLocation.pathname + redirectLocation.search);
+++ }
+++ if (error) { // Произошла ошибка любого рода
+++ return res.status(500).send(error.message);
+++ }
+++ if (!renderProps) { // Мы не определили путь, который бы подошел для URL
+++ return res.status(404).send('Not found');
+++ }
+++ const componentHTML = ReactDom.renderToString(<RouterContext {...renderProps} />);
+++ return res.end(renderHTML(componentHTML));
+++ });
--- return res.end(renderHTML(componentHTML));
});
Примечание: функция match принимает в качестве первого параметра JavaScript объект с ключами routes и location. Я использую shorthand notation ES6, полный вариант выглядел бы так
{ routes: routes, location: req.url},
где routes мы импортируем из файла routes.jsx. В качестве второго параметра match принимает callback функцию, которая и отвечает за рендеринг.
Временно отключим клиентский JavaScript
src/client.js
// ReactDOM.render(<App />, document.getElementById('react-view'));
Теперь протестируем роутинг в браузере — наша страница выглядит все также, хотя мы и избавились от явного вложения компонента HelloWorldPage в контейнер App. Двигаемся дальше.
Добавим ссылки на другие страницы. Обычно это делается так:
import { Link } from 'react-router';
<Link to='/my-fancy-path'>Link text</Link>
Однако, нам надо оформить в виде ссылок компоненты NavItem. Для этого воспользуемся библиотекой react-router-bootstrap.
npm i --save react-router-bootstrap
src/components/App/App.jsx
+++ import { Link } from 'react-router';
+++ import LinkContainer from 'react-router-bootstrap/lib/LinkContainer';
--- <span>Hello World</span>
+++ <Link to='/'>Hello World</Link>
--- <NavItem>Время</NavItem>
+++ <LinkContainer to='/time'>
+++ <NavItem>Время</NavItem>
+++ </LinkContainer>
--- <NavItem>Счетчики</NavItem>
+++ <LinkContainer to='/counters'>
+++ <NavItem>Счетчики</NavItem>
+++ </LinkContainer>
Протестируем серверный роутинг.
Перезапустим nodemon. В браузере откроем Developer Tools, вкладку Network.
Теперь можно оценить результаты нашего труда и покликать на ссылки в меню навигации. Заметим, что запросы уходят на сервер, где обрабатываются express. Он, в свою очередь, рендерит и возвращает браузеру HTML-код запрошенной страницы. Сейчас наше приложение работает в точности как классическое веб-приложение.
В случае, если клиентский JavaScript не успеет загрузиться и инициализироваться, либо в нем окажется ошибка, наше приложение будет все еще успешно работать, в чем мы только что смогли убедиться.
2.2.3 Добавим роутинг в клиентскую часть приложения.
src/client.js
--- import App from 'components/App';
+++ import { browserHistory, Router } from 'react-router';
+++ import routes from './routes';
--- // ReactDOM.render(<App />, document.getElementById('react-view'));
+++ const component = (
+++ <Router history={browserHistory}>
+++ {routes}
+++ </Router>
+++ );
+++ ReactDOM.render(component, document.getElementById('react-view'));
Примечание: обратите внимание, что теперь компонент Router стал корневым компонентом нашего приложения. Он отслеживает изменения URL и формирует контент страницы на основе настроенных нами routes.
Вернемся в браузер и еще раз покликаем по ссылкам, внимательно наблюдая за вкладкой Network инструмента Developer Tools. На этот раз страница не перезагружается, запросы к серверу не уходят, а клиентский JavaScript раз за разом рендерит запрошенную страницу. Все работает!
Промежуточный результат
Мы добавили несколько страниц и успешно настроили клиентский и серверный роутинг, убедившись, что они корректно работают для всех сценариев.
3. Flux и Redux
Сначала реализуем страницу со "счетчиками", чтобы разговор о Flux и Redux оказался максимально приближенным к практике.
Создадим два новых компонента: Counter.jsx и StateCounter.jsx.
Counter будет отображать переданное ему значение и кнопку "плюс", отвечающую за изменение этого значения.
StateCounter — компонент-родитель компонента Counter. Он будет хранить текущее значение Counter в собственном хранилище state и содержать бизнес-логику обновления этого значения при клике по кнопке "плюс".
Я сознательно выбрал такую реализацию, чтобы явно разделить интерфейс и бизнес-логику.
Этот прием очень часто используется на практике, так как подобный код проще:
- поддерживать и сопровождать,
- тестировать,
- повторно использовать.
В частности в нашем проекте сразу несколько компонентов будут использовать Counter.
src/components/CounterPage/Counter.jsx
import React, { Component, PropTypes } from 'react';
import Button from 'react-bootstrap/lib/Button';
import './Counter.css';
const propTypes = {
onClick: PropTypes.func,
value: PropTypes.number
};
const defaultProps = {
onClick: () => {},
value: 0
};
class Counter extends Component {
render() {
const { onClick, value } = this.props;
return (
<div>
<div className='counter-label'>
Value: {value}
</div>
<Button onClick={onClick}>+</Button>
</div>
);
}
}
Counter.propTypes = propTypes;
Counter.defaultProps = defaultProps;
export default Counter;
src/components/CounterPage/Counter.css
.counter-label {
display: inline-block;
margin-right: 20px;
}
src/components/CounterPage/StateCounter.jsx
import React, { Component } from 'react';
import Counter from './Counter';
class StateCounter extends Component {
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.state = { value: 0 };
}
handleClick() {
this.setState({ value: this.state.value + 1 });
}
render() {
return <Counter value={this.state.value} onClick={this.handleClick} />;
}
}
export default StateCounter;
src/components/CounterPage/CounterPage.jsx
+++ import PageHeader from 'react-bootstrap/lib/PageHeader';
+++ import StateCounter from './StateCounter';
render() {
--- return <div>Заглушка для счетчиков</div>;
+++ return (
+++ <div>
+++ <PageHeader>Counters</PageHeader>
+++ <h3>State Counter</h3>
+++ <StateCounter />
+++ </div>
+++ );
}
Самое время протестировать обновленный код. В браузере перейдем на вкладку "Счетчики" и нажмем на кнопку "+". Значение изменилось с 0 на 1. Отлично! Теперь перейдем на любую другую вкладку, а потом вернемся обратно. Значение счетчика снова стало "0". Это в высшей степени ожидаемо, но не всегда соответствует тому, что мы хотели бы видеть.
Настало время обсудить концепцию "Flux".
Примечание: Flux — это именно концепция, а не библиотека. На сегодняшний день существует множество различных библиотек, которые ее реализуют.
3.1. Основные принципы Flux на пальцах
Компоненты не содержат бизнес-логику, а отвечают лишь за рендеринг интерфейса.
В приложении существует один объект, который хранит состояние всего приложения. Я буду называть его "глобальным состоянием", хотя не совсем уверен, что это наиболее удачный термин. Некоторые компоненты по желанию разработчика "подписываются" на интересующую их часть глобального состояния. С течением времени глобальное состояние может изменяться, а все подписанные на него компоненты получают обновления автоматически.
- Запрещено явно изменять глобальное состояние внутри компонента. Для изменения глобального состояния компоненты вызывают специальную функцию dispatch. Отслеживание хода выполнения этой функции является anti-pattern, так как нарушает первый принцип Flux. На практике глобальное состояние будет содержать всю необходимую вашему компоненту информацию, например, статус выполнения API запроса и ошибки. Эта и другая информация будет своевременно и явно передаваться вашему компоненту с помощью props.
Важное примечание: глобальное состояние описывает лишь состояние вашего front-end приложения в отдельной вкладке и хранится исключительно в оперативной памяти браузера. Таким образом, оно будет утеряно, если пользователь нажмет F5, что абсолютно нормально, ожидаемо и by design. Я остановлюсь на этой теме более обстоятельно в третьей части.
Практический пример
Допустим, у нас есть сайт интернет-магазина: в центре страницы мы увидим список товаров, в панели навигации — иконку корзины с количеством товаров и общей их стоимостью, а где-то справа — блок с детализацией товаров, добавленных в корзину. Одним словом, достаточно распространенный сценарий.
Сценарий с точки зрения пользователя
- Пользователь нажимает на кнопку "Добавить в корзину".
- Кнопка перестает быть активной, ее иконка изменяется на индикатор загрузки.
- Выполняется запрос серверного API.
- После того, как серверная часть успешно обработала запрос и вернула ответ, сверху появляется подсказка "Товар был успешно добавлен в корзину", значение иконки с количеством товаров увеличивается на один, сумма пересчитывается, в блоке с детализацией содержимого корзины появляется новая запись. Индикатор загрузки исчезает, а сама кнопка снова становится активной.
- В случае ошибки на 3 шаге, показываем пользователю сообщение с ошибкой и возвращаем кнопку добавления товара в корзину в первоначальное состояние.
Если бы мы писали этот сценарий на jQuery, то пришлось бы написать много кода для работы с DOM'ом. В процессе реализации все новых требований заказчика код становился бы все запутаннее, и, с большой долей вероятности, что-нибудь в итоге сломалось бы, а сложность и стоимость поддержки постоянно увеличивалась бы с течением времени и новых "хотелок".
Этот же сценарий с точки зрения Flux
Примечание: компоненты "Добавить в корзину", "Уведомления", "Корзина" и "Детализация Корзины" подписаны на глобальное состояние.
- Пользователь нажимает на кнопку "Добавить в корзину", что приводит к вызову функции dispatch.
- Эта функция обновляет глобальное состояние: кнопка "Добавить в корзину" получает новое значение prop
loading
равное true, что делает ее выключенной, а ее иконка меняется на индикатор загрузки согласно исходному коду этого компонента. - Следующим шагом функция делает запрос к API, чтобы сохранить в бэкенде информацию о добавленном товаре.
- В случае успеха обновим глобальное состояние: компонент "Уведомления" получит новое значение prop
message
, что сделает его видимым для пользователя, компонент "Корзина" получит значения propcount
с новым количеством товаров и propvalue
с суммой заказа, компонент "Детализация корзины" получит значение propitems
— обновленный список объектов, соответствующих всем товарам, добавленным в корзину. Если в будущем заказчик пожелает, чтобы на странице происходило что-то еще, мы легко сможем воплотить это в жизнь, не меняя ни код других компонентов, ни функцию, которая выполняет бизнес-логику. Нам достаточно лишь реализовать новый компонент и в нем же указать, какая часть глобального состояния нас интересует. - Если API вернул ошибку, то компонент "Уведомления" получит соответствующее значение prop
message
и покажет пользователю информационное сообщение. - Функция в последний раз обновит глобальное состояние, сообщив кнопке "Добавить в корзину" новое значение prop
loading
равное false. Кнопка снова вернется в свое первоначальное состояние.
Пример кода такой функции
function addItemToCart(itemId) {
return (dispatch) => {
dispatch(addItemToCartStarted(itemId));
addItemToCartAPICall(itemId)
.then(
(data) => {
dispatch(itemToCardAdded(data));
dispatch(addItemToCartFinished(data));
}
)
.catch(error => dispatch(addItemToCartFailed(itemId, error)));
}
}
Упрощенно, хоть и не совсем корректно: в этом примере функция dispatch отвечает за обновление глобального состояния. Мы берем функцию, которая содержит бизнес-логику обновления глобального состояния, и передаем ее в качестве первого аргумента функции dispatch.
В результате общий процесс выглядит следующим образом и всегда работает однонаправленно.
- Компонент вызывает функцию, которая отвечает за выполнение бизнес-логики, например
<Button onClick={() => dispatch(addItemToCart(3))} />
В процессе выполнения эта функция изменяет один или несколько раз глобальное состояние.
- При каждом изменении глобального состояния все компоненты, подписанные на него, получают новые значения props и обновляются при необходимости автоматически.
От теории к практике!
3.2. Redux
Коротко: это одна из наиболее популярных реализаций концепции Flux.
Плюсы:
- самая распространенная: существует большое количество библиотек, которые ее используют и существенно упрощают разработчику жизнь;
- прозрачная и предсказуемая. Вы полностью контролируете процесс работы с состоянием. Вероятность, что что-то будет работать не так, как вы ожидали, предельно мала;
- очень легкая реализация. Минифицированная версия занимает менее 2 килобайт;
- у библиотеки русские корни — ее написал Даниил Абрамов, ныне сотрудник facebook.
Минусы:
- Основной минус вытекает из плюсов: за контроль приходится платить, а именно писать немало кода.
Нам предстоит многое сделать:
- установить необходимые пакеты;
- реализовать бизнес-логику изменения состояния для счетчика;
- реализовать инициализацию redux;
- добавить инициализацию redux в серверную часть приложения;
- добавить инициализацию redux в клиентскую часть приложения;
- реализовать компонент "Счетчик", который будет подписан на глобальное состояние;
- добавить новый счетчик на страницу с счетчиками;
- протестировать результат.
Подбадривающее примечание: в процессе выполнения этих шагов вам могут прийти мысли вроде: "Зачем все это?" или "Ну почему все так сложно?". Ничего! Глаза боятся, а руки делают. Если вы дошли до этого места, то вспомните первую часть статьи. Она же огромная, я-то знаю, я же ее писал! Да, в первый раз может уйти много времени, чтобы проделать эти шаги, но в последующие разы это будет занимать совсем немного времени, обещаю!
3.2.1. Установим redux, react-redux и redux-thunk
npm i --save redux react-redux redux-thunk
3.2.2. Реализуем бизнес-логику счетчиков
3.2.2.1 Создаем папки src/redux, src/redux/actions и src/redux/reducers соответственно.
3.2.2.2 Создаем файл counterActions.js. В нем будут описаны функции, которые будут вызываться из наших компонентов.
src/redux/actions/counterActions.js
export const INCREMENT_COUNTER = 'INCREMENT_COUNTER';
export function incrementCounter() {
return { type: INCREMENT_COUNTER };
}
3.2.2.3 Создаем файл counterReducer.js. В нем мы опишем логику обновления глобального состояния.
src/redux/reducers/counterReducer.js
import { INCREMENT_COUNTER } from 'redux/actions/counterActions';
const initialState = { value: 0 };
export default function(state = initialState, action) {
switch (action.type) {
case INCREMENT_COUNTER:
return { value: state.value + 1 };
default:
return state;
}
}
Что делает этот код?
- Мы указываем, что изначально значение счетчика должно быть равно 0.
- Если к нам пришло действие типа INCREMENT_COUNTER, то нужно увеличить значение value на 1.
- Иначе — возвращаем текущее состояние.
Примечание: очень важно не забыть про третий шаг, так как в процессе инициализации redux все подобные функции (их еще называют "редьюсеры") будут вызваны с действием типа @@INIT, и мы должны корректно вернуть начальное значение.
3.2.3 Реализовываем инициализацию redux
Создаем файл configureStore.js
src/redux/configureStore
import { applyMiddleware, combineReducers, createStore } from 'redux';
import thunk from 'redux-thunk';
import counterReducer from './reducers/counterReducer';
export default function (initialState = {}) {
const rootReducer = combineReducers({
counter: counterReducer
});
return createStore(rootReducer, initialState, applyMiddleware(thunk));
}
Что делает этот код?
- Мы используем метод combineReducers, который объединяет все редьюсеры в один. Таким образом, мы можем разбить одно глобальное состояние на сколько угодно небольших редьюсеров, каждый из которых независим от других и отвечает за выполнение собственной бизнес-логики.
- Мы передаем начальное состояние для инициализации. В третьей части статьи мы более подробно остановимся на этом.
- Мы используем thunk в качестве middleware. Этот компонент будет нашим "движком" — он отвечает за выполнение функций, переданных dispatch. На текущем этапе я опущу детали, иначе мы никогда не закончим наше приложение.
- Конечным результатом выполнения функции createStore будет хранилище нашего глобального состояния, которое "обучено" обрабатывать наши действия и изменяться в соответствии с ними.
3.2.4. Добавляем инициализацию redux в серверную часть приложения
src/server.js
+++ import { Provider } from 'react-redux';
+++ import configureStore from './redux/configureStore';
app.use((req, res) => {
+++ const store = configureStore();
...
--- const componentHTML = ReactDom.renderToString(<RouterContext {...renderProps} />);
+++ const componentHTML = ReactDom.renderToString(
+++ <Provider store={store}>
+++ <RouterContext {...renderProps} />
+++ </Provider>
+++ );
Контекст — это props с глобальной областью видимости. То есть вы в своем компоненте явно указываете соответствующие props, которые будут доступны во всех его child компонентах и их child компонентах и так далее. Важно помнить, что это экспериментальная фича, а значит, есть большой шанс, что ее API в будущем изменится и, соответственно, явно использовать в своих приложениях ее не рекомендуется.
Другое дело, когда речь заходит о библиотеках, где контекст использовать приемлемо. Если API контекста поменяется, то вам будет достаточно обновить версии библиотек, которые его используют, не внося ни строчки изменений в собственный код.
В нашем примере мы используем компонент Provider из библиотеки react-redux и передаем ему хранилище глобального состояния store. Этот компонент хранит состояние в виде контекста, а так как это корневой элемент приложения, то доступ к глобальному состоянию в теории может быть обеспечен для абсолютно любого компонента нашего приложения, чем мы и будем с удовольствием пользоваться.
3.2.5. Добавляем инициализацию redux в клиентскую часть приложения
src/client.js
+++ import { Provider } from 'react-redux';
+++ import configureStore from './redux/configureStore';
+++ const store = configureStore();
const component = (
+++ <Provider store={store}>
<Router history={browserHistory}>
{routes}
</Router>
+++ </Provider>
);
3.2.6. Реализуем компонент "Счетчик", который будет подписан на глобальное состояние
src/components/CounterPage/ReduxCounter.jsx
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import Counter from './Counter';
import { incrementCounter } from 'redux/actions/counterActions';
const propTypes = {
dispatch: PropTypes.func.isRequired,
value: PropTypes.number.isRequired
};
class ReduxCounter extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.props.dispatch(incrementCounter());
}
render() {
return <Counter value={this.props.value} onClick={this.handleClick} />;
}
}
ReduxCounter.propTypes = propTypes;
function mapStateToProps(state) {
const { value } = state.counter;
return { value };
}
export default connect(mapStateToProps)(ReduxCounter);
Функция connect соединяет наш компонент с глобальным состоянием. Она очень интересная и делает следующее:
- принимает в качестве параметра функцию mapStateToProps, которой передается все глобальное состояние, а она "вытаскивает" из него и возвращает только те его части, которые нужны нашему компоненту;
- внедряет функцию dispatch;
- при изменении глобального состояния, функция mapStateToProps "вытащит" новые значения и передаст их в props нашему компоненту ReduxCounter.
Примечание: такие функции как connect называются High Order Components или сокращенно HOCs.
Концепция High Order Component работает следующим образом: у вас есть функция, которая принимает в качестве параметра один компонент, а возвращает другой, который, как правило, содержит в себе исходный с расширенным набором props.
connect как раз и является примером такой функции. Упрощенно она выглядит следующим образом
function connect(mapStateToProps) {
function dispatch(...) {
...
}
const injectedProps = mapStateToProps(globalState);
return (WrappedComponent) => {
class HighOrderComponent extends Component {
render() {
<WrappedComponent {...this.props} {...injectedProps} dispatch={dispatch} />;
}
};
return HighOrderComponent;
}
}
Что происходит, когда мы пишем export default connect(mapStateToProps)(ReduxCounter) ?
- Вызывается функция connect с аргументом mapStateToProps.
- В процессе выполнения connect вызывается функция mapStateToProps, которой мы передадим глобальное состояние. Она вернет объект вида { value: "__ТЕКУЩЕЕ_ЗНАЧЕНИЕ__"}, который мы сохраним в переменной injectedProps.
- connect возвращает другую функцию в качестве результата своей работы.
- Мы передаем новой функции в качестве аргумента компонент ReduxCounter.
- Эта функция сохраняет все props, передаваемые исходному компоненту (конструкция {...this.props}), дополнительно передает injectedProps из второго шага и функцию dispatch
- Полученный компонент экспортируется нашим модулем
3.2.7. Добавляем новый счетчик на страницу с CounterPage
src/components/CounterPage/CounterPage.jsx
+++ import ReduxCounter from './ReduxCounter';
<StateCounter />
+++ <h3>Redux Counter</h3>
+++ <ReduxCounter />
3.2.8. Тестируем
- Открываем страницу в браузере и последовательно нажимаем на кнопку "+" обоих счетчиков. И там и там теперь единица.
- Перейдем на любую другую страницу, а затем вернемся обратно. Значение первого счетчика снова равно нулю, в то время как значение второго сохранилось. Здорово! Работает!
Мы проделали большую работу, и теперь наш проект напоминает полноценный сайт, поздравляю!
Приложение из статьи на github — https://github.com/yury-dymov/habr-app/tree/v2.
В третье части
- Мы напишем заглушку внешнего сервиса и реализуем редьюсер для еще одного компонента.
- Заменим заглушку на полноценный API.
- Добавим авторизацию и реализуем запросы к защищенному API.
Полезные ресурсы и материалы
- Документация и галерея компонентов react-bootstrap
- Документация react-router
- Описание Flux
- Роскошный бесплатный видеокурс о redux от его создателя
P.s. Если в тексте присутствуют ошибки или неточности, пожалуйста, напишите мне сначала в личные сообщения. Заранее спасибо!