Redux это инструмент для управления состоянием данных и пользовательским интерфейсом в приложениях JavaScript. Он идеален для одностраничных приложений, в которых управление состоянием со временем может стать сложным. Redux не привязан к какому-либо фреймворку, хотя он написан с ориентацией на React, его можно использовать с Angular и даже jQuery.

Плюс он был придуман в эксперименте с путешествиями во времени — честно, мы еще вернемся к этому позже.

Как мы уяснили в предыдущей статье, данные в React “перетекают” через компоненты. Более специфично это называется “однонаправленный поток данных”, данные перетекают в одном направлении от родителей к потомкам. С этой характеристикой не вполне очевидно, как будут взаимодействовать два компонента, не находящихся в отношениях “родитель-потомок”.

Плохая практика непосредственного взаимодействия компонентов

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

React предлагает решение для этой проблемы, но исходит из того, что его реализация это ваша задача. Вот фрагмент из документации React:

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

Здесь на помощь приходит Redux. Redux предлагает решить это путем хранения всего состояния приложения в одном месте — хранилище. Компоненты передают изменения состояния в хранилище, а не напрямую другим элементам. Компонент, которому важны эти изменения может подписаться на них в хранилище:

хранилище в Redux

Хранилище можно рассматривать как посредника для всех изменений состояния в приложении. При использовании Redux компоненты не общаются между собой, все изменения состояния передаются через единственный источник истины — хранилище.

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

Отслеживание изменений состояния с Redux и без

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

Основная концепция в использовании хранилища для координации состояния приложения это паттерн известный как Flux. Это паттерн проектирования, который отлично сочетается с архитектурой однонаправленного потока данных как в React. Redux напоминает Flux, но насколько они близки?

Redux, похожий на Flux

Flux это паттерн, а не инструмент типа Redux, вы не можете просто взять и скачать его. Redux был вдохновлен не только Flux, но и другими вещами типа Elm. Существует достаточное количество статей, сравнивающих Redux и Flux. В большинстве из них авторы приходят к выводу, что Redux это реализация Flux или, как минимум, что он очень похож на Flux, в зависимости от того, как строго они воспринимают правила Flux. В конечном итоге это не столь важно. Facebook ценит и поддерживает Redux настолько, что принял на работу основного разработчика Redux.

Эта статья предполагает, что вы совсем не знакомы с паттерном Flux. Но если это не так, то вы заметите несколько маленьких отличий, особенно с учетом трех руководящих принципов Redux.

1. Единственный источник истины

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

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

Этот подход единственного хранилища отличает Redux от Flux, в котором есть множественность хранилищ.

2. Состояние только для чтения

В соответствии с документацией Redux, “единственным способом изменения состояния является действие, объект, описывающий то, что произошло”

Это значит, что приложение не может непосредственно модифицировать состояние, вместо этого отправляются “действия”, выражающие намерение изменить состояние в хранилище.

Объект хранилище сам по себе обладает небольшим API, в котором всего четыре метода:

  • store.dispatch(action)
  • store.subscribe(listener)
  • store.getState()
  • replaceReducer(nextReducer)

Как видите, метод для задания состояния отсутствует. Следовательно, отправление действия является единственным способом для кода приложения выразить изменение состояния:

var action = {
  type: 'ADD_USER',
  user: {name: 'Dan'}
};

// Assuming a store object has been created already
store.dispatch(action);

Метод dispatch() передает объект, известный как действие, в Redux. Действие можно описать как “полезную нагрузку”, несущую type и все остальные данные, которые могут быть использованы для обновления состояния — в данном случае пользователя. Учитывайте, что за исключением свойства type, весь дизайн объекта-действия зависит от вас.

3. Изменения делаются чистыми функциями

Как сказано, React не позволяет приложению вносить изменения в состояние напрямую. Вместо этого переданное действие “описывает” изменение состояния и намерение изменить состояние. А изменяют состояние редукторы (reducers) — это функции, которые вы пишете для обработки отправленных действий.

Редуктор принимает текущее состояние как аргумент и может модифицировать состояние, только вернув новое состояние.

// Reducer Function
var someReducer = function(state, action) {
  ...
  return state;
}

Редукторы должны быть написаны как “чистые функции”, этот термин описывает функцию со следующими характеристиками:

  • она не делает вызовов за пределы сети или базы данных;
  • она возвращает значение, зависящее только от значений ее параметров;
  • ее аргументы должны рассматриваться как “неизменные”, это означает, что они не должны быть изменены;
  • вызов чистой функции с одинаковым набором аргументов должен всегда возвращать одно и то же значение.

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

Наше первое хранилище Redux

Для начала создадим хранилище с помощью Redux.createStore() и передадим ему в качестве аргументов все редукторы. Рассмотрим простой пример с единственным редуктором:

// Note that using .push() in this way isn't the
// best approach. It's just the easiest to show
// for this example. We'll explain why in the next section.

// The Reducer Function
var userReducer = function(state, action) {
  if (state === undefined) {
    state = [];
  }
  if (action.type === 'ADD_USER') {
    state.push(action.user);
  }
  return state;
}

// Create a store by passing in the reducer
var store = Redux.createStore(userReducer);

// Dispatch our first action to express an intent to change the state
store.dispatch({
  type: 'ADD_USER',
  user: {name: 'Dan'}
});

Вот краткое описание того, что произошло:

  1. Создано хранилище с одним редуктором
  2. Редуктор установил первоначальное состояние приложения в виде пустого массива
  3. Отправка нового пользователя производится внутри действия
  4. Редуктор добавляет нового пользователя в состояние и возвращает его , что обновляет хранилище.

Редуктор действительно вызывается дважды в примере — первый раз после создания хранилища и второй раз после отправки.

После создания хранилища Redux немедленно вызывает редукторов и использует возвращенные ими значения в качестве исходного состояния. Первый вызов редуктора вернет undefined для состояния. Код редуктора готов к этому и возвращает вместо этого пустой массив для задания исходного состояния хранилища.

Редукторы также вызываются каждый раз при отправке действий. Так как возвращенное состояние редуктора становится новым состоянием хранилища, Redux всегда ожидает от редукторов возвращения состояния.

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

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

редуктор в виде трубы

Теперь хранилище из нашего примера будет массивом с одним объектом пользователя:

store.getState();   // => [{name: 'Dan'}]

Не изменяйте состояние, а копируйте его

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

Передаваемые редуктору аргументы должны рассматриваться как иммутабельные (неизменные). Другими словами, они не должны изменяться напрямую. Вместо такого изменения мы можем использовать неизменяющие методы типа .concat(), чтобы делать копию массива, а затем изменять и возвращать ее.

var userReducer = function(state = [], action) {
  if (action.type === 'ADD_USER') {
    var newState = state.concat([action.user]);
    return newState;
  }
  return state;
}

С этим обновлением редуктора, добавление нового пользователя происходит путем копирования аргумента состояния, который изменяется и возвращается. Если новый пользователь не добавлялся, то вместо создания копии возвращается оригинальное состояние.

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

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

Множественные редукторы

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

{
  userState: { ... },
  widgetState: { ... }
}

Это по-прежнему “одно хранилище — один объект” для целого приложения, но в нем есть вложенные объекты для userState и widgetState, которые могут содержать все типы данных. Это может показаться слишком упрощенным, но на самом деле это не так далеко от настоящего хранилища Redux.

Чтобы создать хранилище со вложенными объектами, нам надо задать эти разделы вместе с соответствующими редукторами:

import { createStore, combineReducers } from 'redux';

// The User Reducer
const userReducer = function(state = {}, action) {
  return state;
}

// The Widget Reducer
const widgetReducer = function(state = {}, action) {
  return state;
}

// Combine Reducers
const reducers = combineReducers({
  userState: userReducer,
  widgetState: widgetReducer
});

const store = createStore(reducers);

Внимание, ES2015! Четыре основных “переменных” в этом примере не могут быть изменены, поэтому мы задали их как константы. Мы также используем модули и деструктуризацию ES2015.

Использование combineReducers() позволяет нам описать наше хранилище в терминах разных логических разделов и назначить редукторов для каждой секции. Теперь, когда каждый редуктор возвращает исходное состояние, это состояние отправляется в соответствующий раздел хранилища (userState или widgetState).

Важно заметить следующий момент — каждый редуктор передает соответствующий раздел общего состояния, а не все состояние хранилища, как в примере с одним редуктором. Состояние, возвращенное каждым редуктором, попадет в его раздел.

Какой редуктор вызывается после отправки?

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

Отправка действия с несколькими редукторами

Аргумент “текущее состояние” редуктора и его возвращенное “обновленное” состояние, затрагивают только раздел хранилища соответствующего редуктора. Запомните, что, как уже было сказано, каждый редуктор передает только соответствующее ему состояние, а не состояние всего приложения.

Стратегии действий

Существует довольно много стратегий для создания и управления действиями и типами действий. Хотя их знание будет весьма полезным, они не столь критичны, как остальная информация в этой статье. Чтобы не слишком раздувать ее объем, мы документировали базовые стратегии действий, о которых вам стоит знать в репозитории на GitHub, сопровождающем серию.

Иммутабельные структуры данных

“Форму состояния определяете вы: она может быть примитивным типом, массивом, объектом или даже структурой данных Immutable.js. Главное условие одно — при изменении состояния, вы не должны изменять объект состояния, а возвращать новый объект” (документация Redux).

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

Для начала:

  • Примитивные типы данных в JavaScript (число, строка, логическое значение, Undefined и Null) всегда неизменны.
  • Объекты, массивы и функции изменяемы.

Как было сказано, изменяемость структур данных это почва для ошибок. Так как наше хранилище использует объекты с состоянием и массивы, нам надо реализовать стратегию для сохранения неизменности состояния.

Представьте объект состояния, в котором нам надо изменить свойство. У нас есть три варианта:

// Example One
state.foo = '123';

// Example Two
Object.assign(state, { foo: 123 });

// Example Three
var newState = Object.assign({}, state, { foo: 123 });

Первый и второй примеры изменяют состояние объекта. Второй пример изменяет потому, что Object.assign() объединяет все свои аргументы в первый аргумент. Но по этой же причине в третьем примере состояние не изменяется.

В третьем примере содержимое state и {foo: 123} соединяется в один новый пустой объект. Это распространенный трюк, позволяющий по сути создать копию состояния и изменить ее без воздействия на оригинальное состояние.

Объект “оператор расширения” это еще один способ сохранения состояния неизменным:

const newState = { ...state, foo: 123 };

Более подробное разъяснение того, что делает такой код и его пользы в Redux есть в документации.

Object.assign() и операторы расширения это все ES2015.

В общем, есть много способов сохранять объекты и массивы иммутабельными. Многие разработчики используют библиотеки типа seamless-immutable, Mori или разработку Facebook Immutable.js.

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

Исходное состояние и путешествие во времени

Если вы прочитали документацию Redux, вы могли заметить второй аргумент в createStore(), который предназначен для “исходного состояния”. Это может показаться альтернативой редукторам при создании исходного состояния. Однако это исходное состояние может быть использовано только для запуска (или “увлажнения”, hydrate) состояния.

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

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

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

В интервью Дэна Абрамова спросили: “Почему вы разработали Redux?”:

Я не хотел создавать фреймворк на основе Flux. Когда конференция React Europe была анонсирована, я предложил доклад о “горячей перезагрузке и путешествиях во времени”, но, если честно, понятия не имел о реализации путешествий во времени.

Redux и React

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

Начнем с кода компонента без Redux:

import React from 'react';
import axios from 'axios';
import UserList from '../views/list-user';

const UserListContainer = React.createClass({
  getInitialState: function() {
    return {
      users: []
    };
  },

  componentDidMount: function() {
    axios.get('/path/to/user-api').then(response => {
      this.setState({users: response.data});
    });
  },

  render: function() {
    return <UserList users={this.state.users} />;
  }
});

export default UserListContainer;

ES2015! Этот пример был немного изменен в сравнении с оригиналом. В нем используются модули ES2015 и стрелочные функции.

Конечно, он делает запросы Ajax и обновляет свое локальное состояние. Но если другие области приложения надо изменить на основе нового поступившего списка пользователей, этой стратегии будет недостаточно.

Со стратегией Redux мы можем отправить действие, когда возвращается запрос Ajax, вместо того чтобы делать this.setState(). Тогда этот компонент, как и другие, может подписаться на изменение состояния. Но фактически это приводит нас к вопросу, как нам настроить store.subscribe() для обновления состояния компонента?

Я предполагал, что смогу привести несколько примеров привязки компонентов к хранилищу React вручную. Вы, возможно, сможете даже представить, как это будет выглядеть в рамках вашего подхода. Но в конечном итоге после этих примеров мне придется объяснять, что у нас есть лучший вариант и о ручной привязке надо забыть. Итак, представляю официальный модуль React/Redux для связывания — react-redux. Давайте сразу перейдем к его рассмотрению.

Соединение с react-redux

Точности ради отмечу, что react, redux и react-redux это три разных модуля npm. Модуль react-redux дает вам удобство при подсоединении компонентов React к Redux.

Вот как это выглядит:

import React from 'react';
import { connect } from 'react-redux';
import store from '../path/to/store';
import axios from 'axios';
import UserList from '../views/list-user';

const UserListContainer = React.createClass({
  componentDidMount: function() {
    axios.get('/path/to/user-api').then(response => {
      store.dispatch({
        type: 'USER_LIST_SUCCESS',
        users: response.data
      });
    });
  },

  render: function() {
    return <UserList users={this.props.users} />;
  }
});

const mapStateToProps = function(store) {
  return {
    users: store.userState.users
  };
}

export default connect(mapStateToProps)(UserListContainer);

Здесь у нас происходит много нового:

  1. Мы импортируем функцию connect из react-redux.
  2. Этот код проще разбирать снизу вверх, начиная с соединения. Функция connect() на самом деле принимает два аргумента, но мы будем показывать только один mapStateToProps().

    Может показаться странным видеть набор дополнительных скобок для connect()(). Здесь действительно два вызова функции. Я думаю, мы могли бы задать этой функции имя и вызывать ее, но зачем это делать, когда мы можем просто вызвать ее немедленно с помощью вторых скобок? Кроме того, у нас нет никаких причин для задания второго имени функции после ее вызова. При этом второй функции надо передать компонент React, в нашем случае это компонент-контейнер.
    Я понимаю, если вы думаете “зачем это придавать этому более сложный вид, чем есть на самом деле”, но на самом деле это общепринятая парадигма “функционального программирования”, которую стоит выучить.

  3. Первый аргумент в connect() это функция, которая должна возвращать объект. Свойства объекта станут свойствами компонента. Вы можете видеть, что их значения поступают от состояния. Теперь я надеюсь, что название функции mapStateToProps несет для вас больше смысла. Также обратите внимание, что mapStateToProps() принимает в виде аргумента хранилище Redux. Основная идея mapStateToProps() состоит в изолировании тех частей состояния, которые нужны компоненту как свойства.
  4. По причинам упомянутым в пункте 3, мы больше не нуждаемся в существовании getInitialState(). Отметьте, что мы ссылаемся на this.props.users вместо this.state.users так как массив users теперь является свойством, а не состоянием локального компонента.
  5. Возвращенное Ajax отправляется как действие вместо обновления состояния локального компонента. Для краткости мы не используем создателей действий и константы с типами действий.

Пример кода делает предположение о том, как работает редуктор пользователя, что может быть не очевидным. Обратите внимание на свойство userState у хранилища. Но откуда взялось такое название?

const mapStateToProps = function(store) {
  return {
    users: store.userState.users
  };
}

Название появилось как результат комбинирования наших редукторов:

const reducers = combineReducers({
  userState: userReducer,
  widgetState: widgetReducer
});

Как насчет свойства .users у userState? Откуда оно появилось?

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

const initialUserState = {
  users: []
}

const userReducer = function(state = initialUserState, action) {
  switch(action.type) {
  case 'USER_LIST_SUCCESS':
    return Object.assign({}, state, { users: action.users });
  }
  return state;
}

Отправка и жизненный цикл Ajax

В нашем примере Ajax мы отправляем только одно действие. Оно называется 'USER_LIST_SUCCESS', так как мы также хотим отправить действие 'USER_LIST_REQUEST' до выполнения Ajax и действие 'USER_LIST_FAILED' в случае неудачи. Не забудьте прочитать документацию по асинхронным действиям.

Отправка из событий

В предыдущей статье мы увидели, что события должны передаваться вниз от контейнера к презентационным компонентам. Получается, что react-redux помогает и здесь, в случаях, когда событию нужно просто отправить действие:

...

const mapDispatchToProps = function(dispatch, ownProps) {
  return {
    toggleActive: function() {
      dispatch({ ... });
    }
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(UserListContainer);

В презентационном компоненте мы можем сделать onClick={this.props.toggleActive} также, как и раньше, но теперь нам не нужно писать само событие.

Пропуск контейнера

Иногда компонент-контейнер нужен только для подписки на хранилище и не нуждается в каких-либо методах типа componentDidMount() для того, чтобы делать запросы Ajax. Ему нужен только метод render(), чтобы передавать состояние вниз к презентационному компоненту. В этом случае мы можем сделать контейнер таким способом:

import React from 'react';
import { connect } from 'react-redux';
import UserList from '../views/list-user';

const mapStateToProps = function(store) {
  return {
    users: store.userState.users
  };
}

export default connect(mapStateToProps)(UserList);

Да, у нас целый отдельный файл для нашего нового контейнера. Но подождите, а где сам контейнер? И почему у нас нигде не используется React.createClass()?

Как оказалось, компонент-контейнер для нас создает функция connect(). Обратите внимание, что сейчас мы передаем презентационный компонент напрямую вместо создания собственного компонента-контейнера для передачи. Если вы задумываетесь насчет того, что делают компоненты-контейнеры, вспомните, что они существуют для того, чтобы презентационные компоненты могли сосредоточиться на представлении и не думать о состоянии. Они также передают состояние в дочернее представление как свойства. И это именно то, что делает connect() — передает состояние (через свойства) в презентационный компонент и возвращает компонент-обертку для презентационного компонента. По сути эта обертка и есть компонент-контейнер.

Значит ли это, что в примерах два компонента-контейнера оборачивают один презентационный компонент? Конечно, вы можете думать в таком ключе. Но это не проблема, это важно только когда контейнеру нужны другие методы React помимо render().

Смотрите на два контейнера, как на выполняющие разные, но взаимосвязанные роли:

Два компонента-контейнера выполняют разные роли

Хм, возможно именно поэтому логотип React напоминает атом!

Провайдер

Чтобы любой код react-redux работал, вам надо сказать вашему приложению, чтобы оно использовало react-redux, это делается с помощью компонента <Provider />. Это компонент обертывает все ваше приложение React. Если вы используете роутер React, это будет выглядеть так:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import router from './router';

ReactDOM.render(
  <Provider store={store}>{router}</Provider>,
  document.getElementById('root')
);

Прикрепленное к провайдеру хранилище (store) это то, что на самом деле соединяет React и Redux через react-redux. Этот файл является образцом того, как может выглядеть основная входная точка приложения.

Redux с роутером React

Это не обязательно, но есть еще один проект npm, называемый react-router-redux. Так как технически маршруты являются частью состояния пользовательского интерфейса и роутер React не знает о Redux, это проект помогает связать их.

Вы заметили,что я сделал? Мы прошли полный круг и вернулись к теме первой статьи!

Итоговый проект

Итоговое руководство для нашего цикла позволяет вам сделать небольшое одностраничное приложение “Users and Widgets”:

Итоговый вид приложения

Напомню, что каждая статья цикла дополняется руководством на Github с кодм и более полной документацией.

Заключение

Я действительно надеюсь, что вам понравилось чтение этой серии статей не в меньшей мере, чем мне ее написание. Я понимаю, что множество тем в React мы даже не затронули (например, формы), но я пытался остаться в рамках задачи — дать новым пользователям React понимание того, как перейти от основ к созданию одностраничного приложения.

Помощь в создании статьи оказали многие, но особая благодарность Линн Фишер за отличную графику сделанную для оформления статей.