React.js: собираем с нуля изоморфное – универсальное приложение. Часть 2: добавляем bootstrap, страницы и роутинг

в 7:36, , рубрики: Bootstrap, isomorphic, javascript, node.js, react-router, react.js, ReactJS, redux

React.js: собираем с нуля изоморфное - универсальное приложение. Часть 2: добавляем bootstrap, страницы и роутинг - 1

Разработка изоморфного приложения глазами моей жены

Это продолжение статьи про разработку изоморфного приложения с нуля на 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'а, внутри которого будут отображаться запрошенные пользователем страницы.

Рефакторинг

  1. Создадим папку src/components/HelloWorldPage
  2. Переименуем App.jsx в HelloWorldPage.jsx, App.css в HelloWorldPage.css
  3. Переместим файлы HelloWorldPage.jsx и HelloWorldPage.css в папку src/components/HelloWorldPage
  4. Внесем изменения в HelloWorldPage.jsx

--- import './App.css';
+++ import './HelloWorldPage.css';

  1. Создадим файл 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';

Это аккуратнее и упрощает сопровождение исходного кода приложения.

mkdir src/components/HelloWorldPage
mv src/components/App.jsx src/components/HelloWorld/HelloWorldPage.jsx
mv src/components/App.css src/components/HelloWorld/HelloWorldPage.css

Создаем App.jsx

  1. Создаем папку App
  2. В папке 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. Сделаем две заглушки

  1. Создаем папки src/components/CounterPage и src/components/TimePage
  2. Напишем код заглушек

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

Чтобы она заработала, необходимо внести в наш проект следующие изменения:

  1. Определим файл с routes. В нем мы укажем соответствие между URL и компонентами, которые должны быть отрендерены.
  2. В серверной части приложения веб-сервер express передаст URL запроса функции match из react-router. Она либо вернет renderProps, которые мы сможем использовать для рендеринга запрошенного пользователем контента, либо сообщит, что совпадений нет, и тогда мы вернем страницу с 404 ошибкой.
  3. В клиентскую часть приложения мы тоже внесем изменения, чтобы библиотека 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 функцию, которая и отвечает за рендеринг.

Самое время посмотреть на результаты нашего труда в браузере — наша страница выглядит все также, хотя мы и избавились от явного вложения компонента 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>

Протестируем серверный роутинг. Для этого временно отключим клиентский JavaScript

src/components/client.js

// ReactDOM.render(<App />, document.getElementById('react-view'));

Перезапустим nodemon. В браузере откроем Developer Tools, вкладку Network.

Теперь можно оценить результаты нашего труда и покликать на ссылки в меню навигации. Заметим, что запросы уходят на сервер, где обрабатываются express. Он, в свою очередь, рендерит и возвращает браузеру HTML-код запрошенной страницы. Сейчас наше приложение работает в точности как классическое веб-приложение.

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

2.2.3 Добавим роутинг в клиентскую часть приложения.

src/components/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

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

3.1. Основные принципы Flux на пальцах

  1. Компоненты не содержат бизнес-логику, а отвечают лишь за рендеринг интерфейса.

  2. В приложении существует один объект, который хранит состояние всего приложения. Я буду называть его "глобальным состоянием", хотя не совсем уверен, что это наиболее удачный термин. Некоторые компоненты по желанию разработчика "подписываются" на интересующую их часть глобального состояния. С течением времени глобальное состояние может изменяться, а все подписанные на него компоненты получают обновления автоматически.

  3. Запрещено явно изменять глобальное состояние внутри компонента. Для изменения глобального состояния компоненты вызывают специальную функцию dispatch. Отслеживание хода выполнения этой функции является anti-pattern, так как нарушает первый принцип Flux. На практике глобальное состояние будет содержать всю необходимую вашему компоненту информацию, например, статус выполнения API запроса и ошибки. Эта и другая информация будет своевременно и явно передаваться вашему компоненту с помощью props.

Важное примечание: глобальное состояние описывает лишь состояние вашего front-end приложения в отдельной вкладке и хранится исключительно в оперативной памяти браузера. Таким образом, оно будет утеряно, если пользователь нажмет F5, что абсолютно нормально, ожидаемо и by design. Я остановлюсь на этой теме более обстоятельно в третьей части.

Практический пример

Допустим, у нас есть сайт интернет-магазина: в центре страницы мы увидим список товаров, в панели навигации — иконку корзины с количеством товаров и общей их стоимостью, а где-то справа — блок с детализацией товаров, добавленных в корзину. Одним словом, достаточно распространенный сценарий.

React.js: собираем с нуля изоморфное - универсальное приложение. Часть 2: добавляем bootstrap, страницы и роутинг - 3

Сценарий с точки зрения пользователя

  1. Пользователь нажимает на кнопку "Добавить в корзину".
  2. Кнопка перестает быть активной, ее иконка изменяется на индикатор загрузки.
  3. Выполняется запрос серверного API.
  4. После того, как серверная часть успешно обработала запрос и вернула ответ, сверху появляется подсказка "Товар был успешно добавлен в корзину", значение иконки с количеством товаров увеличивается на один, сумма пересчитывается, в блоке с детализацией содержимого корзины появляется новая запись. Индикатор загрузки исчезает, а сама кнопка снова становится активной.
  5. В случае ошибки на 3 шаге, показываем пользователю сообщение с ошибкой и возвращаем кнопку добавления товара в корзину в первоначальное состояние.

Если бы мы писали этот сценарий на jQuery, то пришлось бы написать много кода для работы с DOM'ом. В процессе реализации все новых требований заказчика код становился бы все запутаннее, и, с большой долей вероятности, что-нибудь в итоге сломалось бы, а сложность и стоимость поддержки постоянно увеличивалась бы с течением времени и новых "хотелок".

Этот же сценарий с точки зрения Flux

Примечание: компоненты "Добавить в корзину", "Уведомления", "Корзина" и "Детализация Корзины" подписаны на глобальное состояние.

  1. Пользователь нажимает на кнопку "Добавить в корзину", что приводит к вызову функции dispatch.
  2. Эта функция обновляет глобальное состояние: кнопка "Добавить в корзину" получает новое значение prop loading равное true, что делает ее выключенной, а ее иконка меняется на индикатор загрузки согласно исходному коду этого компонента.
  3. Следующим шагом функция делает запрос к API, чтобы сохранить в бэкенде информацию о добавленном товаре.
  4. В случае успеха обновим глобальное состояние: компонент "Уведомления" получит новое значение prop message, что сделает его видимым для пользователя, компонент "Корзина" получит значения prop count с новым количеством товаров и prop value с суммой заказа, компонент "Детализация корзины" получит значение prop items — обновленный список объектов, соответствующих всем товарам, добавленным в корзину. Если в будущем заказчик пожелает, чтобы на странице происходило что-то еще, мы легко сможем воплотить это в жизнь, не меняя ни код других компонентов, ни функцию, которая выполняет бизнес-логику. Нам достаточно лишь реализовать новый компонент и в нем же указать, какая часть глобального состояния нас интересует.
  5. Если API вернул ошибку, то компонент "Уведомления" получит соответствующее значение prop message и покажет пользователю информационное сообщение.
  6. Функция в последний раз обновит глобальное состояние, сообщив кнопке "Добавить в корзину" новое значение 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.

В результате общий процесс выглядит следующим образом и всегда работает однонаправленно.

  1. Компонент вызывает функцию, которая отвечает за выполнение бизнес-логики, например

<Button onClick={() => dispatch(addItemToCart(3))} />

  1. В процессе выполнения эта функция изменяет один или несколько раз глобальное состояние.

  2. При каждом изменении глобального состояния все компоненты, подписанные на него, получают новые значения props и обновляются при необходимости автоматически.

От теории к практике!

3.2. Redux

Коротко: это одна из наиболее популярных реализаций концепции Flux.

Плюсы:

  • самая распространенная: существует большое количество библиотек, которые ее используют и существенно упрощают разработчику жизнь;
  • прозрачная и предсказуемая. Вы полностью контролируете процесс работы с состоянием. Вероятность, что что-то будет работать не так, как вы ожидали, предельно мала;
  • очень легкая реализация. Минифицированная версия занимает менее 2 килобайт;
  • у библиотеки русские корни — ее написал Даниил Абрамов, ныне сотрудник facebook.

Минусы:

  • Основной минус вытекает из плюсов: за контроль приходится платить, а именно писать немало кода.

Нам предстоит многое сделать:

  1. установить необходимые пакеты;
  2. реализовать бизнес-логику изменения состояния для счетчика;
  3. реализовать инициализацию redux;
  4. добавить инициализацию redux в серверную часть приложения;
  5. добавить инициализацию redux в клиентскую часть приложения;
  6. реализовать компонент "Счетчик", который будет подписан на глобальное состояние;
  7. добавить новый счетчик на страницу с счетчиками;
  8. протестировать результат.

Подбадривающее примечание: в процессе выполнения этих шагов вам могут прийти мысли вроде: "Зачем все это?" или "Ну почему все так сложно?". Ничего! Глаза боятся, а руки делают. Если вы дошли до этого места, то вспомните первую часть статьи. Она же огромная, я-то знаю, я же ее писал! Да, в первый раз может уйти много времени, чтобы проделать эти шаги, но в последующие разы это будет занимать совсем немного времени, обещаю!

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/actions/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;      
  }
}

Что делает этот код?

  1. Мы указываем, что изначально значение счетчика должно быть равно 0.
  2. Если к нам пришло действие типа INCREMENT_COUNTER, то нужно увеличить значение value на 1.
  3. Иначе — возвращаем текущее состояние.

Примечание: очень важно не забыть про третий шаг, так как в процессе инициализации 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));
}

Что делает этот код?

  1. Мы используем метод combineReducers, который объединяет все редьюсеры в один. Таким образом, мы можем разбить одно глобальное состояние на сколько угодно небольших редьюсеров, каждый из которых независим от других и отвечает за выполнение собственной бизнес-логики.
  2. Мы передаем начальное состояние для инициализации. В третьей части статьи мы более подробно остановимся на этом.
  3. Мы используем thunk в качестве middleware. Этот компонент будет нашим "движком" — он отвечает за выполнение функций, переданных dispatch. На текущем этапе я опущу детали, иначе мы никогда не закончим наше приложение.
  4. Конечным результатом выполнения функции 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 соединяет наш компонент с глобальным состоянием. Она очень интересная и делает следующее:

  1. принимает в качестве параметра функцию mapStateToProps, которой передается все глобальное состояние, а она "вытаскивает" из него и возвращает только те его части, которые нужны нашему компоненту;
  2. внедряет функцию dispatch;
  3. при изменении глобального состояния, функция mapStateToProps "вытащит" новые значения и передаст их в props нашему компоненту ReduxCounter.

Примечание: такие функции как connect называются High Order Components или сокращенно HOCs.

Немного подробнее о High Order Components

Концепция 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) ?

  1. Вызывается функция connect с аргументом mapStateToProps.
  2. В процессе выполнения connect вызывается функция mapStateToProps, которой мы передадим глобальное состояние. Она вернет объект вида { value: "__ТЕКУЩЕЕ_ЗНАЧЕНИЕ__"}, который мы сохраним в переменной injectedProps.
  3. connect возвращает другую функцию в качестве результата своей работы.
  4. Мы передаем новой функции в качестве аргумента компонент ReduxCounter.
  5. Эта функция сохраняет все props, передаваемые исходному компоненту (конструкция {...this.props}), дополнительно передает injectedProps из второго шага и функцию dispatch
  6. Полученный компонент экспортируется нашим модулем

3.2.7. Добавляем новый счетчик на страницу с CounterPage

src/components/CounterPage/CounterPage.jsx

+++ import ReduxCounter from './ReduxCounter';

<StateCounter />
+++ <h3>Redux Counter</h3>
+++ <ReduxCounter />

3.2.8. Тестируем

  1. Открываем страницу в браузере и последовательно нажимаем на кнопку "+" обоих счетчиков. И там и там теперь единица.
  2. Перейдем на любую другую страницу, а затем вернемся обратно. Значение первого счетчика снова равно нулю, в то время как значение второго сохранилось. Здорово! Работает!

Мы проделали большую работу, и теперь наш проект напоминает полноценный сайт, поздравляю!

Приложение из статьи на github — https://github.com/yury-dymov/habr-app/tree/v2.

В третье части

  1. Мы напишем заглушку внешнего сервиса и реализуем редьюсер для еще одного компонента.
  2. Заменим заглушку на полноценный API.
  3. Добавим авторизацию и реализуем запросы к защищенному API.

Полезные ресурсы и материалы

  1. Документация и галерея компонентов react-bootstrap
  2. Документация react-router
  3. Описание Flux
  4. Роскошный бесплатный видеокурс о redux от его создателя

P.s. Если в тексте присутствуют ошибки или неточности, пожалуйста, напишите мне сначала в личные сообщения. Заранее спасибо!

Автор: yury-dymov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js