Новый уровень React: Redux
React

Новый уровень React: Redux

Оригинал: Leveling Up with React: Redux, Brad Westfall

Серия статей
Часть 1: React Router
Часть 2: Компоненты-контейнеры
Часть 3: Redux (вы здесь!)

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

Как мы убедились в прошлом уроке, данные в React «текут» через компоненты. Это называется «однонаправленный поток данных» — поток данных проходит в одном направлении, от родителя к ребенку.При этом не очевидно, как два компонента, не связанные отношением родитель-ребенок, будут взаимодействовать между собой:

В React не рекомендуется реализовывать прямое взаимодействие компонент-компонент. Это считается плохой практикой, приводит к ошибкам и спагетти-коду — старый термин для запутанного кода.

Разработчики React дают подсказку, но они ожидают, что вы реализуете это по своему усмотрению. Вот отрывок из документации:

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

Вот здесь пригождается Redux. Redux предлагает хранить все состояние приложения в одном месте, называемом «store» («хранилище»). Компоненты «отправляют» изменение состояния в хранилище, а не напрямую другим компонентам. Компоненты, которые должны быть в курсе этих изменений, «подписываются» на хранилище:

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

Это сильно отличается от других стратегий, где части приложения взаимодействуют непосредственно друг с другом:

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

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

Redux — это «Flux-like»

Flux — это шаблон, а не конкретный инструмент, как Redux, вы не сможете его скачать. Redux — это инструмент, вдохновленный шаблоном Flux, как, например, и Elm. Есть множество руководств, где сравниваются Redux и Flux. Они называют Redux Flux или Flux-like, в зависимости от требований, предъявляемых при сравнении. В конечном итоге, это не важно. Facebook настолько любит и поддерживает Redux, что они наняли его разработчика, Дена Абрамова.

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

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

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

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

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

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

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

Это означает, что приложение не может напрямую изменить состояние. Вместо этого, необходимо передать «action», чтобы выразить намерение изменить состояние в хранилище.

Само хранилище имеет очень маленький API, состоящий всего из четырех методов:

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

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


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

// Предполагается, что объект хранилища уже был создан
store.dispatch(action);

Метод dispatch() отправляет в Redux объект, называемый action (экшен). Экшен содержит тип и другие данные, необходимые для обновления — в данном случае, информацию о пользователе. Кроме поля type, остальная структура экшена полностью зависит от вас.

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

Итак, Redux не позволяет изменять состояние напрямую. Вместо это экшен описывает, какие изменения необходимо сделать. Редьюсеры (reducers) — это функции, которые обрабатывают экшены и могут вносить изменения в состояние.

Редьюсер принимает текущее состояние в качестве параметра.


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

Редьюсеры должны быть реализованы как “чистые” функции (pure functions), термин, описывающий функции, удовлетворяющие следующим условиям:

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

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

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

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


/* Обратите внимание, что использование .push() таким образом - не лучшее решение. Здесь она используется для простоты. В следующем разделе мы рассмотрим, почему. */

// Функция-редьюсер
var userReducer = function(state, action) {
  if (state === undefined) {
    state = [];
  }
  if (action.type === 'ADD_USER') {
    state.push(action.user);
  }
  return state;
}

// Создание хранилища с передачей редьюсера
var store = Redux.createStore(userReducer);

/* Отправка первого экшена, чтобы выразить намерение изменить состояние */
store.dispatch({
  type: 'ADD_USER',
  user: {name: 'Dan'}
});

Краткое описание того, что происходит в коде выше:

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

* Редьюсер действительно вызывается дважды в примере — один раз при создании и один раз после вызова экшена.

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

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

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

Редьюсеры можно представить в виде воронок, через которые проходит состояние: они принимают и возвращают состояние, чтобы обновить хранилище:

В примере выше хранилище будет содержать массив с одним пользователем:


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

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

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

Аргументы, переданные в редьюсер, не должны должны считаться неизменяемыми. Другими словами, их нельзя менять напрямую. Вместо прямого изменения, в примере можно использовать метод .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 хранлища.

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

Какой редьюсер вызывается после dispatch?

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

Я говорю «свое» состояние, потому что «текущее состояние» и возвращаемое «обновленное» значение каждого влияет только на соответствующий раздел состояния. Помните, что каждый редьюсер получает и передает только свою часть, а не целое состояние.

Стратегии экшенов

Есть, на самом деле, довольно много стратегий создания экшенов и типов экшенов. Несмотря на то, что о них полезно знать, они не являются критичной частью этой статьи. Для краткости, я собрал и описал эти стратегии в репозитории на GitHub.

Неизменяемые структуры данных

Структура состояния полностью зависит от вас: это может быть примитив, массив, объект или структура данных Immutable.js. Единственная важная часть заключается в том, что при обновлении состояния вы не должны изменять объект состояния, а возвращать новый объект. — документация Redux

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

Для начала:

  • Примитивные типы в JavaScript (Number, String, Boolean, Undefined, and Null) уже неизменяемы.
  • Объекты, массивы и функции изменяемы.

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

Давайте представим объект state, свойство которого необходимо изменить. Есть три способа:


// Способ 1
state.foo = '123';

// Способ 2
Object.assign(state, { foo: 123 });

// Способ 3
var newState = Object.assign({}, state, { foo: 123 });

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

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

Оператор расширения — другой способ сохранить состояние неизменяемым:


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

За более подробной информацией обратитесь к документации Redux.

Метод Object.assign() и оператор расширения являются частью стандарта ES2015.

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

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

Начальное состояние и путешествие во времени

Если вы прочитаете документацию, вы заметите второй параметр функции createStore(), в котором передается «начальное состояние». Это альтернатива инициализации состояния в редьюсерах. Тем не менее, это начальное состояние должно использоваться только для восстановления состояния.

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

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

Это порождает еще одну интересную концепцию. Если настолько просто и легко восстановить старое состояние, то можно представить некий эквивалент «путешествия во времени» в приложении. Это может быть полезно для отладки или функции undo/redo. В этом нам помогает и структура состояния в виде одного хранилища, и неизменяемость состояния.

В интервью Дена Абрамова спросили: «Почему вы разработали 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 к хранилищу 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(). Функция connect() на самом деле принимает два аргумента.

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

    Я понимаю, что вы подумаете, «зачем делать пример сложнее, чем он есть?», но такие вызовы являются общей практикой парадигмы функционального программирования, о них полезно знать.

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

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

    Это название появляется при комбинировании редьюсеров:

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

    А что на счет свойства 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-запросах

    В нашем примере мы отправляем только один экшен 'USER_LIST_SUCCESS', но также есть возможность отправлять 'USER_LIST_REQUEST' при старте запроса и '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(). Нужен только метод 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 Router, это может выглядеть следующим образом:

    
    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')
    );
    

    Хранилище передается в компонент Provider, что фактически связывает React и Redux с помощью react-redux.

    Redux и React Router

    Это не обязательно, но существует еще один пакет в npm, который называется react-router-redux. Он позволяет связать React Router и Redux. Заметили, что мы сделали круг и вернулись к первой статье?

    Финальный проект

    Заключительное руководство к этой серии статей позволяет создать небольшое одностраничное приложение «Пользователи и виджеты»:

    Заключение

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

Рассылка
Подпишитесь на рассылку и получайте дайджест новостей и статей.
Никакого спама!
Подписаться