Создаем приложение “Органайзер” с помощью React, Redux и Immutable.js
React – это JavaScript библиотека от компании Facebook, использующая раздельные компоненты для построения приложения и одностороннее связывание данных, что делает её отличным вариантом для создания пользовательских интерфейсов.
Тем не менее, возможности библиотеки для работы с различными состояниями данных приложения, сознательно были упрощены разработчиками. Поэтому, всегда стоить помнить о том, что React всего лишь реализует архитектуру Вид в традиционной связке Модель-Вид-Контроллер.
По большому счету, нет ничего, что могло бы помешать созданию больших приложений с помощью React, но сперва нужно изучить определенные инструменты, которые позволять управлять данными приложения и поддерживать структуру нашего кода, сохраняя её в простоте и порядке.
На данный момент не существует официального решения для работы с состояниями данных приложения, но, в тоже время, есть несколько библиотек, которые позволяют это делать и при этом прекрасно встраиваются в парадигму React.
В сегодняшней статье мы подружим React с двумя такими библиотеками и создадим небольшое приложение.
Redux
Redux - это очень маленькая библиотека, которая реализует контейнер для данных нашего приложения, благодаря объединению идей от таких библиотек, как Flux и Elm. С помощью Redux, мы можем управлять любым состоянием данных, при условии соблюдения следующих принципов:
- Состояние данных сохраняется в одиночном хранилище
- Данные изменяются с помощью действий (actions)
Ядром одиночного хранилища Redux, является функция, которая принимает текущее состояние данных приложения, нужное действие и комбинирует их для создания нового состояния приложения. Такая функция называется – редуктором.
Наши React-компоненты будут отвечать за отправку действий в хранилище, и, в свою очередь, наше хранилище будет говорить компонентам, когда им нужно будет сделать пересборку DOM-дерева.
ImmutableJS
По причине того, что Redux не позволяет нам изменять состояние приложения, необходимо использовать неизменяемые структуры данных. ImmutableJS предоставляет нам некоторое количество неизменяемых структур данных с изменяемыми интерфейсами, которые выполняют свои функции весьма эффективным способом.
Установка модулей и настройка сборки проекта
Для начала в директории нашего проекта инициализируем package.json
командой npm init
.
Затем установим все зависимости, которые нам могут понадобиться:
npm i --save react react-dom redux react-redux immutable
npm i --save-dev webpack babel-loader babel-preset-es2015 babel-preset-react
В ходе написания кода проекта, мы будем использовать JavaScript XML-подобный синтаксис (JSX) и последнюю версию ECMAScript 2015. Поэтому, для нашего кода понадобится транспайлер Babel и сборщик проектов Webpack, который позволит легко производить сборку наших модулей в единый бандл с необходимой обработкой.
Теперь создадим файл конфигурации для Webpack с именем webpack.config.js
и внесем в него следующий код:
module.exports = {
entry: './src/app.js',
output: {
path: __dirname,
filename: 'bundle.js'
},
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel',
query: { presets: [ 'es2015', 'react' ] }
}
]
}
};
И, наконец, добавим в файл package.json
скрипт npm для сборки нашего проекта из исходного кода:
"script": {
"build": "webpack --debug"
}
Обратите внимание, что в данном случае необходимо выполнять команду npm run build
каждый раз после внесения
изменений в исходный код проекта.
React и его компоненты
До того, как мы начнем реализовывать React-компоненты, будет полезным создать небольшую заготовку с каким-то набором данных для отображения их на странице. Это позволит визуализировать рендер наших компонентов.
const dummyTodos = [
{ id: 0, isDone: true, text: 'make components' },
{ id: 1, isDone: false, text: 'design actions' },
{ id: 2, isDone: false, text: 'implement reducer' },
{ id: 3, isDone: false, text: 'connect components' }
];
Для нашего приложения нужны будут только два компонента - ‹Todo /›
и ‹TodoList
/›
. Добавим в файл src/components.js
код:
import React from 'react';
export function Todo( props )
{
const { todo } = props;
if( todo.isDone )
{
return <strike>{todo.text}</strike>;
}
else
{
return <span>{todo.text}</span>;
}
}
export function TodoList( props )
{
const { todos } = props;
return (
<div className='todo'>
<input type='text' placeholder='Add todo'/>
<ul className='todo__list'>
{todos.map( todo => (
<li key={ todo.id } className='todo__item'>
<Todo todo={ todo }/>
</li>
) )}
</ul>
</div>
);
}
Для тестирование нашего компонента создадим файл index.html
в корневой директории проекта и заполним его
разметкой. И немного стилей тоже лишними не будут.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>React ToDo-App</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app"></div>
<script src="bundle.js"></script>
</body>
</html>
И конечно же, необходимо создать входную точку для нашего приложения в файле src/app.js
:
import React from 'react';
import { render } from 'react-dom';
import { TodoList } from './components';
const dummyTodos = [
{ id: 0, isDone: true, text: 'make components' },
{ id: 1, isDone: false, text: 'design actions' },
{ id: 2, isDone: false, text: 'implement reducer' },
{ id: 3, isDone: false, text: 'connect components' }
];
render(
<TodoList todos={dummyTodos} />,
document.getElementById( 'app' )
);
Произведем сборку проекта командой npm run build
, а затем откроем в браузере файл
index.html
. Результат выполнения показан на скрине:
Redux и ImmutableJS
Теперь, когда мы создали начальный пользовательский интерфейс, самое время подумать о данных приложения. Наша
заготовка с данными отлично подойдет для преобразования её в ImmutableJS коллекцию. Внесем изменения в файле
app.js
:
import { List, Map } from 'immutable';
const dummyTodos = List( [
Map( { id: 0, isDone: true, text: 'make components' } ),
Map( { id: 1, isDone: false, text: 'design actions' } ),
Map( { id: 2, isDone: false, text: 'implement reducer' } ),
Map( { id: 3, isDone: false, text: 'connect components' } )
] );
Доступ к данным в ImmutableJS осуществляется другим способом, нежели в объектах JavaScript, поэтому нам нужно
будет произвести небольшие изменения в наших компонентах. Везде, где доступ к свойствам производится напрямую
через, например, todo.id
, необходимо использовать доступ через метод todo.get( 'id' )
.
Проектирование действий (actions)
Итак, у нас получилась некоторая структура приложения, теперь стоит подумать о том, какие действия будут влиять и обновлять её. В нашем случае, достаточно будет два действия – одно для добавления новой записи в органайзер, второе для того, чтобы отметить выполнение записи. Давайте определим функции, которые будут отвечать за эти действия.
Создадим файл src/actions.js
и добавим в него код:
// небольшой костыль для генерации уникальных id
const uid = () => Math.random().toString( 34 ).slice( 2 );
export function addTodo( text )
{
return {
type : 'ADD_TODO',
payload: {
id : uid(),
isDone: false,
text : text
}
};
}
export function toggleTodo( id )
{
return {
type : 'TOGGLE_TODO',
payload: id
}
}
Каждое из действий – это обыкновенный JavaScript объект, содержащий type (тип) действия и payload (нужные нам данные). Свойство type поможет нам в будущем, когда мы будем обрабатывать данные, определить, как именно их обрабатывать.
Проектирование редуктора
Теперь, когда у нас есть структура состояния данных приложения и действия, которые будут обновлять эту структуру, мы можем создать редуктор. В качестве напоминания, редуктор – это функция, которая берет данные и действия, а затем вычисляет на этой основе новое состояние приложения.
Вот так будет выглядеть начальная структура нашего редуктора, код которой следует поместить в файл src/reducer.js
:
import { List, Map } from 'immutable';
const init = List( [] );
export default function( todos = init, action )
{
switch( action.type )
{
case 'ADD_TODO':
// ...
case 'TOGGLE_TODO':
// ...
default:
return todos;
}
}
Обработка действия ADD_TODO
довольно таки проста, т.к. мы можем просто использовать метод
.push(), который вернет нам список, с добавленной в конце
новой записью:
case 'ADD_TODO':
return todos.push( Map( action.payload ) );
Заметьте, что мы конвертируем объект с данными в неизменяемую структуру, прежде чем, поместим его в todos список.
Более сложная обработка нужна для действия TOGGLE_TODO
:
case 'TOGGLE_TODO':
return todos.map( todo =>
{
if( todo.get( 'id' ) === action.payload )
return todo.update( 'isDone', isDone => !isDone );
else
return todo;
} );
В данном случае, мы используем метод .map()
для итерации по каждому элементу массива, чтобы найти тот элемент,
id которого совпадает с id действия. Затем мы вызываем метод
update(), который принимает ключ и функцию, после чего
возвращает новую копию структуры, в которой значение ключа isDone заменено возвращаемым результатом работы
функции.
Для лучшего понимания, возможно, стоит разобрать этот момент на отдельном примере:
const todo = Map( { id: 0, text: 'foo', isDone: false } );
todo.update( 'isDone', isDone => !isDone );
// Результат работы метода update(): { id: 0, text: 'foo', isDone: true }
Связывая все вместе
Теперь, когда у нас есть готовые действия и редуктор, мы можем создать хранилище и связать его с нашими React-компонентами.
Изменим файл app.js
следующим образом:
import React from 'react';
import { render } from 'react-dom';
import { createStore } from 'redux';
import { TodoList } from './components';
import reducer from './reducer';
const store = createStore( reducer );
render(
<TodoList todos={store.getState()} />,
document.getElementById( 'app' )
);
Нам необходимо сделать так, чтобы наши компоненты узнали об этом хранилище. Для этого мы будем использовать библиотеку react-redux, чтобы упростить процесс. Данная библиотека позволит создать контейнер, который свяжет друг с другом наши компоненты таким образом, что нам не понадобится изменять уже имеющийся код.
Поэтому, мы создадим контейнер для компонента ‹TodoList /›
, который нужно поместить в файл
src/containers.js
. Давайте взглянем на него:
import { connect } from 'react-redux';
import * as components from './components';
import { addTodo, toggleTodo } from './actions';
export const TodoList = connect(
function mapStateToProps( state )
{
// ...
},
function mapDispatchToProps( dispatch )
{
// ...
}
)( components.TodoList );
Здесь мы использовали функцию
connect(). А затем передали в неё две другие функции:
mapStateToProps()
и mapDispatchToProps()
.
Функция mapStateToProps()
принимает в качестве аргумента данные о текущем состоянии хранилища (в нашем случае
это список todos), после чего возвращает объект, содержащий структуру состояния нашего компонента:
function mapStateToProps( state )
{
return { todos: state };
}
Может быть более наглядным примером работы этой функции послужит React-компонент:
<TodoList todos={ state } />
Также нам необходимо правильно подготовить функцию mapDispatchToProps()
, в которую передается метод хранилища
dispatch и таким образом, становятся доступны созданные нами действия:
function mapDispatchToProps( dispatch )
{
return {
addTodo : text => dispatch( addTodo( text ) ),
toggleTodo: id => dispatch( toggleTodo( id ) )
};
}
И снова, чтобы понять создание этих свойств, может быть полезен пример React-компонента:
<TodoList todos={ state }
addTodo={ text => dispatch( addTodo( text ) ) }
toggleTodo={ id => dispatch( toggleTodo( id ) ) } />
Теперь, когда наши компоненты знают о действиях, мы можем вызвать их из слушателя событий.
Добавим файл components.js
:
export function TodoList( props )
{
const { todos, toggleTodo, addTodo } = props;
const onSubmit = ( event ) =>
{
const input = event.target;
const text = input.value;
const isEnterKey = (event.which == 13);
const isLongEnough = text.length > 0;
if( isEnterKey && isLongEnough )
{
input.value = '';
addTodo( text );
}
};
const toggleClick = id => event => toggleTodo( id );
return (
<div className='todo'>
<input type='text'
className='todo__entry'
placeholder='Добавить запись'
onKeyDown={ onSubmit } />
<ul className='todo__list'>
{ todos.map( todo => (
<li key={ todo.get( 'id' ) }
className='todo__item'
onClick={ toggleClick( todo.get( 'id' ) ) } >
<Todo todo={ todo.toJS() } />
</li>
) )}
</ul>
</div>
);
}
Наши контейнеры будут автоматически подписаны на изменения в хранилище, и они произведут новый рендер компонента всякий раз, когда его свойства изменятся.
В заключение нам нужно сделать наш контейнер связанным с хранилищем, используя ‹Provider
/›
компонент.
Добавим в файл app.js
:
import React from 'react';
import { render } from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import reducer from './reducer';
import { TodoList } from './containers';
// ^^^^^^^^^^
const store = createStore( reducer );
render(
<Provider store={store}>
<TodoList />
</Provider>,
document.getElementById( 'app' )
);
Итог
Без сомнения, экосистема, которая существует вокруг React и Redux может быть довольно-таки сложной, и устрашающей для новичка. Но хорошая новость в том, что практически все элементы из этой системы допускают замену.
В этой статье мы едва коснулись поверхности Redux архитектуры, но даже этого будет достаточно для начала более глубокого погружения в изучение этого инструмента.
Финальный результат работы написанного нами приложения:
Исходные коды проекта доступны по ссылке: https://github.com/sbogdanov108/react_todo
При создании статьи были использованы следующие источники: