[Из песочницы] С 0 до 1. Разбираемся с Redux
Когда вышла версия 1.0 Redux, я решил потратить немного времени на серию рассказов о моем опыте работы с ним. Недавно я должен был выбрать “реализацию Flux” для клиентского приложения и до сих пор с удовольствием работаю с Redux.
Почему Redux?
Redux позиционирует себя как предсказуемый контейнер состояния (state) для JavaScript приложений. Редакс вдохновлен Flux и Elm. Если вы раньше использовали Flux, я советую прочитать, что Redux имеет с ним общего в разделе "Предшественники" новой (отличной!) документации.
Redux предлагает думать о приложении, как о начальном состоянии модифицируемом последовательностью действий (actions), что я считаю действительно хорошим подходом для сложных веб-приложений, открывающим много возможностей.
Конечно, вы можете найти больше информации о Redux, его архитектуре и роли каждого компонента в документации.
Создаем список друзей с React и Redux
Сегодня мы сфокусируемся на пошаговом создании вашего первого приложения, использующего Редакс и Реакт: создадим простой список друзей с нуля.
Вы можете найти готовый код здесь.
Для кого?
Эта статья написана для людей, не имеющих опыта работы с Redux. Опыт разработки с Flux также не обязателен. Я буду давать ссылки на документы, когда мы будем сталкиваться с новыми понятиями.1. Установка
Автор Redux, Даниил Абрамов, создал отличную сборку для разработки с React, Webpack, ES6/7 и React Hot Loader, которую вы можете найти здесь.
Есть сборки уже с установленным Redux, но, я думаю, важно понять роль каждой библиотеки.
$ git clone https://github.com/gaearon/react-hot-boilerplate.git friendlist
$ cd friendlist && npm install
$ npm start
Теперь вы можете открыть приложение по адресу http://localhost:3000. Как вы видите, «hello world» готов!1.1 Добавим redux, react-redux и redux-devtools
Нам нужно установить три пакета:
- Redux: сама библиотека
- React-redux: связка с React
- Redux-devtools: опционально, дает некоторые полезные инструменты для разработки.
$ npm install --save redux@1.0.0-rc react-redux
$ npm install --save-dev redux-devtools
1.2 Структура директорий
Хотя то, что мы будем делать, довольно просто, давайте создадим структуру директорий как для реального приложения.
+-- src
| +-- actions
| +-- index.js
| +-- components
| +-- index.js
| +-- constants
| +-- ActionTypes.js
| +-- containers
| +-- App.js
| +-- FriendListApp.js
| +-- reducers
| +-- index.js
| +-- friendlist.js
| +-- utils
| +-- index.js
+-- index.html
+-- app.css
Мы будет видеть более детально роль каждой из директорий, когда будем создавать приложение. Мы переместили App.js в директорию containers, так что нужно будет настроить импорт statement в index.js.1.3 Подключаем Redux
Включаем devtools
Нам нужно включить devtools только для окружения разработки, так что модифицируем webpack.config.js как здесь:
/* webpack.config.js */
var devFlagPlugin = new webpack.DefinePlugin({
__DEV__: JSON.stringify(JSON.parse(process.env.DEBUG || 'false'))
});
[...]
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin(),
devFlagPlugin
]
Когда мы запустим наше приложение с DEBUG=true npm start
, это включит __DEV__
флаг, который мы можем использовать в нашем приложении. Мы можем подключить devtools следующим образом:
/* src/utils/devTools.js */
import React from 'react';
import { createStore as initialCreateStore, compose } from 'redux';
export let createStore = initialCreateStore;
if (__DEV__) {
createStore = compose(
require('redux-devtools').devTools(),
require('redux-devtools').persistState(
window.location.href.match(/[?&]debug_session=([^&]+)\b/)
),
createStore
);
}
export function renderDevTools(store) {
if (__DEV__) {
let {DevTools, DebugPanel, LogMonitor} = require('redux-devtools/lib/react');
return (
<DebugPanel top right bottom>
<DevTools store={store} monitor={LogMonitor} />
</DebugPanel>
);
}
return null;
}
Мы делаем здесь две вещи. Мы переопределяем createStore используя созданную функцию, которая позволяет нам применять множественные store enhancers, таких как devTools. Мы также включаем функцию renderDevTools, которая рендерит DebugPanel.
Контейнер приложения
Сейчас нам нужно модифицировать App.js для соединения с redux. Для этого мы будем использовать Provider
из react-redux. Это сделает наш экземпляр хранилища доступным для всех компонентов, которые располагаются в Provider компоненте. Не нужно беспокоится о странно выглядящей функции, ее цель использовать “контекст” функции Реакта для создания хранилища, доступного для всех детей (компонентов).
/* src/containers/App.js */
import React, { Component } from 'react';
import { combineReducers } from 'redux';
import { Provider } from 'react-redux';
import { createStore, renderDevTools } from '../utils/devTools';
import FriendListApp from './FriendListApp';
import * as reducers from '../reducers';
const reducer = combineReducers(reducers);
const store = createStore(reducer);
Для создания хранилища мы используем createStore
функцию, которую мы определили в devTools файле, как map всех наших редьюсеров.
ES6 синтаксис import * as reducers
позволяет нам получать объект в виде { key: fn(state, action),… }. Это отлично подходит для задания аргументов для combineReducers
.
export default class App extends Component {
render() {
return (
<div>
<Provider store={store}>
{() => <FriendListApp /> }
</Provider>
{renderDevTools(store)}
</div>
);
}
}
В нашем приложении App.js — внешняя обертка для Redux и FriendListApp — корневой компонент для нашего приложения. После создания простого ‘Hello world’ в FriendListApp.js, мы можем наконец запустить наше приложение с redux и devTools. Вы должен получить это (без стилей).
Хотя это просто ‘Hello world’ приложение, у нас включен Hot Reloading, т.е. вы можете модифицировать текст и получать автоматическое обновление на экране. Как вы можете видеть, devtools справа показывает пустые хранилища. Заполним их!
2. Создаем приложение
Теперь, когда сделаны все настройки, мы можем сфокусироваться на самом приложении.2.1 Действия и генераторы действий
Действия — это структура, которая передает данные из вашего приложения в хранилище. По соглашению, действия должны иметь строковое поле type
, которое указывает на тип исполняемого действия. Определять этот тип в другом модуле — хорошая практика, и это заставляет нас задуматься заранее о том, что мы будем делать в нашем приложении.
/* src/constants/ActionTypes.js */
export const ADD_FRIEND = 'ADD_FRIEND';
export const STAR_FRIEND = 'STAR_FRIEND';
export const DELETE_FRIEND = 'DELETE_FRIEND';
Как вы можете видеть, это очень выразительный путь определения области действий нашего приложения, которое будет позволять нам добавлять друзей, отмечать их как «избранных» или удалять их из нашего списка.
Генераторы действий — функции, которые создают действия. В Redux генераторы действий являются чистыми функциями, что делает их портативными и простыми для тестирования, т.к. они не имеют сайд-эффектов.
Мы поместим их в папку действий, но не забывайте, что это разные понятия.
/* src/actions/FriendsActions.js */
import * as types from '../constants/ActionTypes';
export function addFriend(name) {
return {
type: types.ADD_FRIEND,
name
};
}
export function deleteFriend(id) {
return {
type: types.DELETE_FRIEND,
id
};
}
export function starFriend(id) {
return {
type: types.STAR_FRIEND,
id
};
}
Как видите, действия довольно минималистичны. Чтобы добавить элемент, мы сообщаем все свойства (здесь мы имеем дело только с name), а для других мы ссылаемся на id. В более сложном приложении, мы, возможно, имели бы дело с асинхронными действиями, но это тема для другой статьи…2.2 Редьюсеры
Редьюсеры отвечают за модификации состояний приложения. Они — чистые функции со следующим видом (previousState, action) => newState
. Очень важно понимать, что вы не должны никогда (вообще никогда) изменять исходное состояние в редьюсере. Вместо этого вы можете создавать новые объекты на базе свойств previousState. В противном случае это может иметь нежелательные последствия. Также, это не место для обработки сайд-эффектов, таких как роутинг или асинхронные вызовы.
Мы, для начала, определяем вид состояния нашего приложения в initialState
:
/* src/reducers/friends.js */
const initialState = {
friends: [1, 2, 3],
friendsById: {
1: {
id: 1,
name: 'Theodore Roosevelt'
},
2: {
id: 2,
name: 'Abraham Lincoln'
},
3: {
id: 3,
name: 'George Washington'
}
}
};
Состоянием может быть все, что мы захотим, мы можем просто сохранить массив друзей. Но это решение плохо масштабируется, так что мы будем использовать массив id и map друзей. Об этом можно почитать в normalizr.
Теперь нам нужно написать актуальный редьюсер. Мы воспользуемся возможностями ES6 для задания аргументов по умолчанию для обработки случаев, когда состояние не определено. Это поможет понять как записать редьюсер, в данном случае я использую switch.
export default function friends(state = initialState, action) {
switch (action.type) {
case types.ADD_FRIEND:
const newId = state.friends[state.friends.length-1] + 1;
return {
friends: state.friends.concat(newId),
friendsById: {
...state.friendsById,
[newId]: {
id: newId,
name: action.name
}
}
}
default:
return state;
}
}
Если вы не знакомы с синтаксисом ES6/7, то возможно вам будет трудно это прочесть. Т.к. нам нужно вернуть новое состояние объекта, как правило используют Object.assign или Spread operator.
Что здесь происходит: мы определяем новый id. В реальном приложении мы, возможно, возьмем его с сервера или, как минимум, убедимся, что он уникальный. Затем мы используем concat
чтобы добавить этот новый id в наш id-лист. Concat добавит новый массив и не изменит оригинальный.
Computed properties — это удобные возможности ES6, которые позволяют нам проще создавать динамические key в friendsById объекте с [newId]
.
Как вы можете видеть, несмотря на синтаксис, который может сначала смутить, логика проста. Вы задаете состояние и получаете назад новое состояние. Важно: ни в одной точке этого процесса не изменять предыдущее состояние.
Окей, давайте вернемся и создадим редьюсеры для двух других действий:
import omit from 'lodash/object/omit';
import assign from 'lodash/object/assign';
import mapValues from 'lodash/object/mapValues';
/* ... */
case types.DELETE_FRIEND:
return {
...state,
friends: state.friends.filter(id => id !== action.id),
friendsById: omit(state.friendsById, action.id)
}
case types.STAR_FRIEND:
return {
...state,
friendsById: mapValues(state.friendsById, (friend) => {
return friend.id === action.id ?
assign({}, friend, { starred: !friend.starred }) :
friend
})
}
Я добавил lodash, чтобы упростить управление объектами. Как обычно, в этих двух примерах, важно не изменять предыдущее состояние, поэтому мы используем функцию, которая возвращает новый объект. Для примера, вместо delete state.friendsById[action.id]
, мы используем _.omit
функцию.
Вы также можете заметить, что spread оператор позволяет нам манипулировать только теми состояниями, которое нам нужно изменить.
Redux не важно, как вы храните данные, так что можно использовать Immutable.js.
Теперь вы можете поиграть с хранилищем минуя интерфейс, путем вызова dispatch вручную в нашем App.js.
/* src/containers/App.js */
import { addFriend, deleteFriend, starFriend } from '../actions/FriendsActions';
store.dispatch(addFriend('Barack Obama'));
store.dispatch(deleteFriend(1));
store.dispatch(starFriend(4));
Вы увидите в devTools действия, с ними можно поиграть в реальном времени.
3. Создаем интерфейс
Т.к. этот урок не об этом, я пропустил создание React-компонентов и сфокусировался только на Redax. Мы имеем три простых компонента:
- FriendList список друзей
<ul>
friends: array
массив друзей
- FriendListItem элемент одного друга
<li>
name: string
имя другаstarred: boolean
показывает звездочку, если друг отмечен, как избранныйstarFriend: function
вызов, который срабатывает, когда пользователь кликает на звездочкиdeleteFriend: function
вызов, который срабатывает, когда пользователь кликает на корзину
- AddFriendInput поле для ввода новых имён
addFriend: function
вызов, срабатывающий при нажатии ввод
В Redux считается хорошей практикой делать по возможности большинство компонентов “глупыми”. Т.е. чем меньше компонентов связаны с Redux, тем лучше.
Здесь FriendListApp будет единственным “умным” компонентом.
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as FriendsActions from '../actions/FriendsActions';
import { FriendList, AddFriendInput } from '../components';
@connect(state => ({
friendlist: state.friendlist
}))
export default class FriendListApp extends Component {
Это часть нового синтаксиса ES6, называемая декоратор. Это удобный способ вызова функции высшего порядка. Будет эквивалентна connect(select)(FriendListApp);
где select
— функция, которая возвращает то, что мы здесь сделали.
static propTypes = {
friendsById: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired
}
render () {
const { friendlist: { friendsById }, dispatch } = this.props;
const actions = bindActionCreators(FriendsActions, dispatch);
return (
<div className={styles.friendListApp}>
<h1>The FriendList</h1>
<AddFriendInput addFriend={actions.addFriend} />
<FriendList friends={friendsById} actions={actions} />
</div>
);
}
}
Мы используем bindActionCreators
, чтобы обернуть наши генераторы действий вызовом dispatch. Цель — передать генераторы действий другим нашим компонентам без предоставления dispatch объекта (сохраняя их глупыми).
То, что случится дальше — стандартный подход для React. Мы привяжем функции к onClick, onChange или onKeyDown свойствам, чтобы обработать действия пользователя.
Если вы заинтересовались, как это сделать, ты можете посмотреть весь код.
Сейчас вы можете почувствовать магию работы redux/react приложения. Как изображено на GIF, вы логгируете все действия.
Разрабатывать удобней, когда ты можешь производить какие-то действия, находить баги, возвращаться, исправлять их и повторять уже исправленную последовательность…