Тип Поста

React – Flux & Redux. Пошаговое Руководство, Часть 4

React – Flux & Redux. Пошаговое Руководство, Часть 4

Часть 4

Это финальная часть нашего туториала и в ней мы рассмотрим, как связать вместе Redux и React на примере небольшого приложения.

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

Для создания нашего React-приложения и обеспечения его работоспособности мы будем использовать:

  • Простой node HTTP-сервер
  • Потрясающий Webpack для связывания всех частей нашего приложения
  • Немного магии Webpack Dev Server для обслуживания JS файлов, что позволит наблюдать за изменениями в этих файлах
  • Невероятный React Hot Loader (еще один удивительный проект от Dan Abramov – на всякий случай, это автор Redux), чтобы получить live-reloading наших компонентов прямо в браузере, пока мы их доводим до ума в редакторе кода

Также важным замечанием будет то, что это приложение создано на версии React 0.14, что вполне подходить для демонстрации принципа связки Redux и React.

Итак, приступим!

Подготовка

Для начала создадим в корневой директории нашего проекта файл package.json чтобы установить все необходимые зависимости нашего проекта. Дабы не увеличивать объем этой статьи, код для этого файла мы возьмем из репозитория проекта. И запустим установку зависимостей командой:

npm install

Теперь создадим в корне проекта файлы конфигов для Webpack webpack.config.js и для Webpack Dev Server webpack-dev-server.js. Мы не будем разбирать подробности работы данных модулей, т.к. это выходит за рамки статьи.

Затем приступим к созданию простого, но достаточного сервера для обслуживания нашего приложения. Мы не станем использовать Express, так как все, что нам понадобится, так это простая HTML-страница.

Создадим в корневой директории файл server.js, в который импортируем модуль 'http' для создания HTTP-сервера и все остальные необходимые модули для работы сервера:

import http from 'http';
import webpackDevServer from './webpack-dev-server';
import React from 'react';

Добавим номер порта, на котором будет висеть наше приложение и запуск Webpack Dev Server:

const port = 5050;
webpackDevServer.listen( port );

И создадим сервер, который будет обслуживать одну и ту же страницу при обращении к любому URI. Поэтому вы не найдете никакой особо специфичной логики работы роутов в коде, приведенном ниже, кроме отклонения запроса favicon:

var server = http.createServer( function( req, res ) {

  // Это нужно всего лишь для того, чтобы избежать сервер ничего не делал при
  // автоматическом запросе браузером favicon.
  // Иначе, сервер бы вернул пустую HTML-страницу вместо иконки
  if( req.url.match( 'favicon.ico' ) ) {
    return res.end();
  }

  // И конечно же, вот HTML нашего приложения, который мы отправляем обратно браузеру
  // Ничего особого, кроме URI, который указывает на общий JS-файл для нашего приложения
  // И который расположен на dev-сервере http://localhost:5051
  res.write(
    `<!DOCTYPE html>
    <html>
      <head>
        <meta charSet="utf-8" />
      </head>
      <body>
        <div id="app-wrapper"></div>
        <script type="text/javascript" src="http://localhost:5051/static/bundle.js"></script>
      </body>
    </html>`
  );

  res.end();
} );

export default server;

Запустим наш главный сервер и выведем в консоль информацию об этом запуске:

server.listen( port );
console.log( `Сервер запущен на http://127.0.0.1:${port}` );

Теперь проверим работоспособность проведенных нами манипуляций с кодом. Для этого создадим новую директорию src и в ней пустой файл index.jsx, который определен как стартовая точка для нашего приложения в конфиге webpack.config.js. Код этого файл будет автоматически исполняться, когда JS-бандл (bundle.js) загрузится в нашем браузере. И в консоли запустим команду:

npm start

Если все было сделано правильно, то в консоли будет, примерно, такой вывод:

react-flux-redux-part4-02

Создаем входную точку приложения

Обратимся к созданному ранее файлу src/index.jsx. Этот файл является входной точкой для нашего JS-бандла и именно здесь мы будем создавать Redux-хранилище, экземпляр корневого компонента Rect-приложения и прикреплять это к DOM:

import React from 'react';
import { render } from 'react-dom';
import createStore from './create-store';
import Application from './application';

Весь код, относящийся к созданию хранилища будет располагаться в файле src/create-store.js, а корневой компонент нашего приложения и компонент Redux Провайдера в файле src/application.jsx.

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

const store = createStore();

Теперь самое время сделать рендер нашего приложения в DOM, используя ReactDOM.render или просто render, благодаря ES6 нотации import { render } from 'react-dom':

render(
  <Application store={store} />,
  document.getElementById( 'app-wrapper' )
);

И предоставим наше Redux-хранилище корневому компоненту в качестве свойства, после чего Redux Провайдер сможет сделать свою работу.

Далее мы создадим файл src/create-store.js, в котором опишем наше Redux-хранилище.

Создаем Redux-хранилище

По большому счету здесь особо нечего сказать, так как вы уже это видели множество раз в предыдущих главах и создание хранилища должно быть довольно-таки знакомым.

Тем не менее есть одна вещь на которую стоит обратить внимание: в данном случае, мы не используем thunk middleware, пример которого мы разбирали ранее. Вместо этого – решение на основе promise middleware, которое позволит нам обрабатывать асинхронные action creators и делать некоторые интересные вещи с нашим пользовательским интерфейсом. Это middleware было разобрано здесь и оно используется в одном очень хорошем примере react-redux-universal-example, который было неплохо бы изучить более подробно (позже, не сейчас :)).

Добавим в файл src/create-store.js:

import { createStore, applyMiddleware, combineReducers } from 'redux';
import promiseMiddleware from './promise-middleware';

Вы можете рассмотреть более подробно код используемого нами promise middleware – оно не слишком сложно и это будет хорошим упражнением для понимания middleware в общем.

import * as reducers from './reducers';

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

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

В данном примере мы не передаем никаких данных, но полезно будет знать о такой возможности в createStore:

export default function( data ) {
  var reducer          = combineReducers( reducers );
  var finalCreateStore = applyMiddleware( promiseMiddleware )( createStore );
  var store            = finalCreateStore( reducer, data );

  return store;
}

И самое время приступить к созданию нашего редуктора, код которого будет находиться в файле src/reducers.js.

Создаем редуктор

Файл редуктора src/reducers.js будет содержать только один редуктор для нашего приложения. Его поведение не будет чем-то новым для вас, исключая, может быть, обработку трех вариантов действия GET_TIME.

Такой подход позволит делать обновления в реальном времени нашего интерфейса, например:

  1. Когда мы получаем GET_TIME_REQUEST действие, мы изменяем состояние таким образом, чтобы определенная часть интерфейса была заморожена, т.к. мы производим операцию, которая не была еще завершена
  2. Позже, когда мы получаем GET_TIME_SUCCESS или GET_TIME_FAILURE, мы изменяем состояние, чтобы разморозить наше приложение и добавить новые данные, которые мы получили

Добавим в файл код редуктора:

var initialTimeState = {};

export function _time( state = initialTimeState, action ) {
  console.log( '_time редуктор вызван с состоянием', state, 'и действием', action );

  switch( action.type ) {
    case 'GET_TIME_REQUEST':
      return {
        ...state,
        frozen: true
      };
    case 'GET_TIME_SUCCESS':
      return {
        ...state,
        time  : action.result.time,
        frozen: false
      };
    case 'GET_TIME_FAILURE':
      // мы могли бы добавить здесь сообщение об ошибке, которое показали бы где-нибудь в нашем приложении
      return {
        ...state,
        frozen: false
      };
    default:
      return state;
  }
}

Имя редуктора содержит символ “_”, чтобы избежать такой ситуации: state.time.time (time повторяется дважды), когда производится чтение данных из состояния. Такая запись является всего лишь личным предпочтением и, возможно, вам это не понадобится – все зависит от названия вашего редуктора и какие свойства он предоставляет Redux-хранилищу.

Теперь перейдем к созданию файла src/application.jsx, в котором мы свяжем вместе Redux и React с помощью компонента Провайдера.

Используем React и Redux

Наконец-то пришло время создать нашу первую связку с помощью компонента Провайдера, которого предоставляет react-redux.

Провайдер – это React-компонент, предназначенный для использования в качестве обертки для вашего корневого компонента приложения. Его цель заключается в том, чтобы предоставить экземпляр Redux всем компонентам вашего приложения. Как Провайдер это делает – не слишком важно, единственное, что нужно знать, так это то, что он использует особенности контекста React. Этого нет в документации и по большому счету знать об этом не обязательно, но если вам любопытно, то по ссылке можно ознакомиться с этим подробно.

Добавим в файл src/application.jsx:

import React from 'react';
import Home from './home';
import { Provider } from 'react-redux';

export default class Application extends React.Component {
  render() {
    return (
      <Provider store={this.props.store}>
        <Home />
      </Provider>
    )
  }
}

Как было сказано ранее, Провайдер должен быть оберткой для корневого компонента вашего приложения. Таким образом, этот компонент и все его дети (даже на самом глубоком уровне вложенности) будут иметь доступ к вашему Redux-хранилищу. Конечно, чтобы позволить Провайдеру сделать это, вы должны передать ему заранее созданное хранилище через свойство store.

Создаем action creators

Для этого примера мы будем использовать Bluebird в качестве promise-библиотеки, но вы можете заменить её на любую другую удобную для вас.

Наш action creator просто берет текущее время с учетом переданной задержки для того, чтобы показать использование promise middleware.

Работа promise middleware заключается в том, что оно ожидает что-то из следующего списка:

  1. Действие в формате:
    {
        types: [ REQUEST, SUCCESS, FAILURE ], // типы действий, переданные в этом определенном порядке
        promise: function() {
          // возвращает promise
        }
    }
  2. Или что угодно еще, что может быть передано в next middleware, или в Redux. На самом деле, при текущей реализации promise middleware (находится в файле src/promise-middleware.js), “что угодно еще” НЕ должно содержать promise свойство, когда осуществляется передача в next middleware или в Redux

Когда promise middleware примет действие, оно создаст два действия из одного: одно действие будет для REQUEST, и позже – одно действие для SUCCESS или для FAILURE.

И снова, код promise middleware не слишком сложен и его стоит разобрать более подробно.

Действие имеет отсрочку исполнения, благодаря значение delay, переданному в качестве параметра action creator.

Теперь создадим файл src/action-creators.js и добавим в него код:

import Promise from 'bluebird';

export function getTime( delay ) {
  return {
    types  : [ 'GET_TIME_REQUEST', 'GET_TIME_SUCCESS', 'GET_TIME_FAILURE' ],
    promise: () => {
      return new Promise( ( resolve, reject ) => {
        // Просто имитируем асинхронный запрос к серверу с помощью setTimeout
        setTimeout( () => {
          const d  = new Date();
          const ms = ( '000' + d.getMilliseconds() ).slice( -3 );

          resolve( {
            time: `${d.toString().match( /\d{2}:\d{2}:\d{2}/ )[ 0 ]}.${ms}`
          } );
        }, delay );
      } );
    }
  }
}

Далее перейдем к созданию файла src/home.jsx и узнаем, как мы можем произвести чтение данных из состояния и отправить действие из React-компонента.

Чтение данных состояния и отправка действий

Наш туторил почти подошел к концу и единственная вещь, которую мы еще не обсудили – каким образом мы можем получить данные из нашего хранилища и как мы будем отправлять действия?

На оба этих вопроса можно ответить с помощью использования react-redux связывания – connect.

Мы уже говорили о том, что при использовании компонента Провайдера, мы предоставляем всем компонентам нашего приложения доступ к Redux. Но этот доступ может быть осуществлен только через недокументированную особенность контекста React. Чтобы избежать необходимости использования таких покрытых мраком API React, react-redux дает нам функцию, которую можно использовать в классе компонента.

Функция, о которой идет речь – connect и она буквально позволяет связать наш компонент и Redux-хранилище. Помимо этого, она предоставляет нам функцию отправки через свойства компонента, а также позволяет добавлять любые свойства, которые вы хотели бы сделать частью состояния хранилища.

Используя connect, вы будете превращать глупый компонент в умный компонент с помощью очень небольшого количества кода.

Функция connect принимает в качестве параметров несколько преобразующих (mapping) функций и возвращает функцию, ожидающую текущий класс компонента, который вы хотели бы связать. Подобные функции (connect) называются Компонентами или Функциями Высшего Порядка (HOC). Функции Высшего Порядка пришли из паттернов функционального программирования и разработаны для добавления особенностей или поведения в их входные данные (компоненты, хранилище, ...) без использования наследования. Такой подход способствует использованию композиции, а не наследования, что является предпочтительным способом создания React-приложений (на самом деле нет никаких строгих ограничений для React-приложений). Ознакомиться более подробно с HOC и композициями можно по ссылкам:
https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750#.lpp7we7mx
http://natpryce.com/articles/000814.html

Connect функция создана для решения всех возможных случаев применения, от самых простых до самых сложных. В настоящем примере мы не будем использовать самую сложную разновидность connect, но вы можете найти всю необходимую информацию об этом в подробной API документации к этой функции.

Вот полная сигнатура connect:

connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])

И один из способов её использования:

const wrappedComponentClass = connect( ... )( ComponentClass );

Мы сфокусируемся только на первом параметре connect: mapStateToProps...

Connect принимает в качестве первого параметра функцию, которая будет выбирать те части вашего состояния, которые вы бы хотели предоставить компонентам. Эта функция называется “селектором” и в свою очередь принимает два параметра: состояние вашего хранилища и текущие свойства компонента.

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

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

state.items[ props.someID ]

mapStateToProps возвращает свойства, которые вы хотели бы предоставить компоненту (обычно через литерал объекта). От вас зависит конечное преобразование состояния, так как вы получаете его до того, как вернуть.

Далее приведен простой пример использования connect (сразу за определением класса компонента) и это будет содержимым файла src/home.jsx:

import React from 'react';
import { connect } from 'react-redux';
import * as actionCreators from './action-creators';

Мы используем ES6 трюк для импорта, чтобы получить все action creators и их имена, точно так, как мы сделали это с нашими редукторами.

Теперь давайте создадим класс компонента Home и добавим в него метод onTimeButtonClick:

class Home extends React.Component {
  onTimeButtonClick( delay ) {
    this.props.dispatch( actionCreators.getTime( delay ) );
  }
}

Метод onTimeButtonClick будет отправлять действие в качестве ответа на событие клика от пользователя. В данном случае мы используем функцию отправки, которая автоматически предоставляется connect в свойствах.

Существуют альтернативный способ вызова actionCreators, который уже будет связан с отправкой, и этот способ реализуется через второй параметр connect.

Значение параметра delay передается в actionCreators.getTime и является задержкой для имитирования асинхронной работы, которая была сделана до того, как мы получили текущее время. Попробуйте изменить это значение, чтобы проверить правильность воздействия задержки на наш интерфейс.

Далее мы добавим в класс компонента Home метод render():

render() {

  // Благодаря connect, мы можем получить конкретные данные через свойства
  var { frozen, time, reduxState } = this.props;
  var attrs                        = {};
  const DELAY                      = 500; // в ms

  if( frozen ) {
    attrs = {
      disabled: true
    }
  }

  return (
    <div>
      <h1>Пример Провайдера и функции connect</h1>

      <span>
        <b>Который час?</b> { time ? `Сейчас ${time}` : 'Без понятия...' }
      </span>
      <br /><br />

      <i>
        При клике по кнопке, время будет отображено после задержки в {DELAY}ms.<br/>
        Попробуйте изменить это значение (в <b>src/home.jsx</b> - строка 26), чтобы проверить правильность воздействия задержки на интерфейс
      </i>
      <br />

      {/* Здесь мы регистрируем обработчик кнопки onClick: */}
      <button { ...attrs } onClick={() => this.onTimeButtonClick( DELAY )}>Получить вермя!</button>

      <pre>
        redux state = { JSON.stringify( reduxState, null, 2 ) }
      </pre>
    </div>
  )
}

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

const mapStateToProps = ( state/*, props*/ ) => {
  return {
    frozen    : state._time.frozen,
    time      : state._time.time,
    reduxState: state
  }
};

const ConnectedHome = connect( mapStateToProps )( Home );

export default ConnectedHome;

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

Возможно вы заметили, что благодаря Redux, у нас есть динамические компоненты, которым требуется какое-то состояние (чтобы хранить текущее время), но это состояние не представлено внутри компонента. Наш компонент всего лишь принимает свойства с необходимыми данными.

Таким образом, мы имеем компонент без сохранения состояния (stateless). Вы должны всегда стремиться к тому, чтобы использовать как можно больше stateless-компонентов (также их можно назвать глупыми компонентами) в вашем приложении, чем компонентов с сохранением состояния (stateful), так как их намного легче повторно использовать.

Когда мы обсуждали onTimeButtonClick обработчик, то упомянули, что можем передать через второй параметр mapDispatchToProps функции connect колбэк нашего клика. Сделав так, мы бы получили извлечение поведения нашей кнопки наружу компонента, что позволит производить легкое тестирование, так как после этого можно с легкостью вставить любые данные и обработчики тестов в компонент и убедиться, что все работает правильно.

И прежде чем перейти к заключению, обратите внимание на заметку про альтернативное использование функции connect.

Использование connect()

По той причине, что connect() возвращает функцию, которая в свою очередь принимает класс и возвращает другой класс, вы можете использовать, при желании, ES7 декоратор. Декоратор является экспериментальной особенностью ES7, с помощью которой можно изменять классы и свойства во время их разработки.

Но стоит помнить, что, так как это экспериментальная особенность, то возможны изменения в её использовании и как следствия – поломки кода. Используя эту возможность уже сейчас, вы должны быть полностью уверены в своих действиях и понимать неопределенность в ходе развития этой возможности. Декоратор предоставляет синтаксический сахар, и тот код, который мы написали выше, с его помощью можно записать немного по-другому. Вместо:

class MyClass {}
export default somedecorator( MyClass )

Можно написать так:

@somedecorator
export default class MyClass {}

Применив такой синтаксис к Redux connect, можно заменить это:

let mapStateToProps = ( state ) => { ... }
class MyClass {}
export default connect( mapStateToProps )( MyClass )

На такой вариант:

let mapStateToProps = ( state ) => { ... }
@connect( mapStateToProps )
export default class MyClass {}

Как можно заметить, применение HOC-функции connect к классу компонента, теперь сделано в неявном виде ( @connect( mapStateToProps ) ), вместо вызова её саму по себе ( @connect( mapStateToProps )( Myclass ) ). Кто-то находит такой способ более элегантным, некоторым не нравится тот факт, что при этом скрывается то, что происходит в действительности и многие просто не понимают, как декораторы работают. Зная все об этом, и держа в памяти, что декораторы – все еще экспериментальные возможности, вы можете решить, какой способ использования connect предпочтителен именно для вас, и вы не будете удивлены, встретив оба варианта синтаксиса в других статьях, туторилах и т.д.

Запускаем наше приложение

Без лишних слов выполним команду:

npm start

И если мы все сделали правильно, то получим такой результат:

react-flux-redux-part4-03

Обратите внимание на вывод в консоли браузера – там можно увидеть с каким действием и состоянием был вызван редуктор.

react-flux-redux-part4-04

Исходные коды приложения доступны в репозитории по ссылке: https://github.com/sbogdanov108/redux_guide

Заключение

Существует гораздо больше тем для обсуждения Redux и react-redux, чем мы коснулись в этом туториале. Например, рассматривая Redux, может быть интересным разобрать bindActionCreators для связывания action creators и функции отправки действий.

Что делать дальше?

Официальная документация по Redux действительно исчерпывающа и её можно без колебаний посоветовать для дальнейшего изучения: http://redux.js.org/index.html

Успехов в изучении React & Redux!


Часть 3

При создании статьи были использованы следующие источники:

  1. Redux Tutorial
  2. Redux Docs
Поделиться

Оставить комментарий

Вы можете использовать следующие HTML-теги:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>
Обязательно к заполнению