[Перевод] Руководство по работе с Redux
Сегодня Redux — это одно из наиболее интересных явлений мира JavaScript. Он выделяется из сотни библиотек и фреймворков тем, что грамотно решает множество разных вопросов путем введения простой и предсказуемой модели состояний, уклоне на функциональное программирование и неизменяемые данные, предоставления компактного API. Что ещё нужно для счастья? Redux — библиотека очень маленькая, и выучить её API не сложно. Но у многих людей происходит своеобразный разрыв шаблона — небольшое количество компонентов и добровольные ограничения чистых функций и неизменяемых данных могут показаться неоправданным принуждением. Каким именно образом работать в таких условиях?
В этом руководстве мы рассмотрим создание с нуля full-stack приложения с использованием Redux и Immutable-js. Применив подход TDD, пройдём все этапы конструирования Node+Redux бэкенда и React+Redux фронтенда приложения. Помимо этого мы будем использовать такие инструменты, как ES6, Babel, Socket.io, Webpack и Mocha. Набор весьма любопытный, и вы мигом его освоите!
1. Что вам понадобится
2. Приложение
3. Архитектура
4. Серверное приложение
4.1. Разработка дерева состояний приложения
4.2. Настройка проекта
4.3. Знакомство с неизменяемыми данными
4.4. Реализация логики приложения с помощью чистых функций
4.4.1. Загрузка записей
4.4.2. Запуск голосования
4.4.3. Голосование
4.4.4. Переход к следующей паре
4.4.5. Завершение голосования
4.5. Использование Actions и Reducers
4.6. Привкус Reducer-композиции
4.7. Использование Redux Store
4.8. Настройка сервера Socket.io
4.9. Трансляция состояния из Redux Listener
4.10. Получение Redux Remote Actions
5. Клиентское приложение
5.1. Настройка клиентского проекта
5.1.1. Поддержка модульного тестирования
5.2. React и react-hot-loader
5.3. Создание интерфейса для экрана голосования
5.4. Неизменяемые данные и pure rendering
5.5. Создание интерфейса для экрана результатов и обработка роутинга
5.6. Использование клиентского Redux-Store
5.7. Передача входных данных из Redux в React
5.8. Настройка клиента Socket.io
5.9. Получение actions от сервера
5.10. Передача actions от React-компонентов
5.11. Отправка actions на сервер с помощью Redux Middleware
6. Упражнения
Данное руководство будет наиболее полезным для разработчиков, которые уже умеют писать JavaScript-приложения. Как уже упоминалось, мы будем использовать Node, ES6, React, Webpack и Babel, и если вы хотя бы немного знакомы с этими инструментами, никаких проблем с продвижением не будет. Даже если не знакомы, вы сможете понять основы по пути.
В качестве хорошего пособия по разработке веб-приложений с помощью React, Webpack и ES6, можно посоветовать SurviveJS. Что касается инструментов, то вам понадобится Node с NPM и ваш любимый текстовый редактор.
Мы будем делать приложение для «живых» голосований на вечеринках, конференциях, встречах и прочих собраниях. Идея заключается в том, что пользователю будет предлагаться коллекция позиций для голосования: фильмы, песни, языки программирования, цитаты с Horse JS, и так далее. Приложение будет располагать элементы парами, чтобы каждый мог проголосовать за своего фаворита. В результате серии голосований, останется один элемент — победитель. Пример голосования за лучший фильм Дэнни Бойла:
Приложение будет иметь два разных пользовательских интерфейса:
- Интерфейс для голосования можно будет использовать на любом устройстве, где запускается веб-браузер.
- Интерфейс результатов голосования может быть выведен на проектор или какой-то большой экран. Результаты голосования будут обновляться в реальном времени.
Структурно система будет состоять из двух приложений:
- Браузерное приложение на React, предоставляющее оба пользовательских интерфейса.
- Серверное приложение на Node, содержащее логику голосования.
Взаимодействие между приложениями будет осуществляться с помощью WebSockets. Redux поможет нам организовать код клиентской и серверной частей. А для хранения состояний будем применять структуры Immutable.
Несмотря на большое сходство клиента и сервера — к примеру, оба будут использовать Redux, — это не универсальное/изоморфное приложение, и приложения не будут совместно использовать какой-либо код. Скорее это можно охарактеризовать как распределённую систему из двух приложений, взаимодействующих друг с другом с помощью передачи сообщений.
Сначала напишем Node-приложение, а затем — React. Это позволит нам не отвлекаться от реализации базовой логики приложения, прежде чем мы перейдём к интерфейсу. Поскольку мы создаём серверное приложение, будем знакомиться с Redux и Immutable и узнаем, как будет устроено построенное на них приложение. Обычно Redux ассоциируется с React-проектами, но его применение вовсе ими не ограничивается. В частности, мы узнаем, насколько Redux может быть полезен и в других контекстах!
По ходу чтения этого руководства я рекомендую вам писать приложение с нуля, но можете скачать исходники с GitHub.
4.1. Разработка дерева состояний приложения
Создание приложения с помощью Redux зачастую начинается с продумывания структуры данных состояния приложения (application state). С её помощью описывается, что происходит в приложении в каждый момент времени. Состояние (state) есть у любого фреймворка и архитектуры. В приложениях на базе Ember и Backbone состояние хранится в моделях (Models). В приложениях на базе Angular состояние чаще всего хранится в фабриках (Factories) и сервисах (Services). В большинстве Flux-приложений состояние является хранилищем (Stores). А как это сделано в Redux?
Главное его отличие в том, что все состояния приложения хранятся в единственной древовидной структуре. Таким образом все, что необходимо знать о состоянии приложения, содержится в одной структуре данных из ассоциативных (map) и обычных массивов. Как вы вскоре увидите, у этого решения есть немало последствий. Одним из важнейших является то, что вы можете отделить состояние приложения от его поведения. Состояние — это чистые данные. Оно не содержит никаких методов или функций, и оно не упрятано внутрь других объектов. Всё находится в одном месте. Это может показаться ограничением, особенно если у вас есть опыт объектно-ориентированного программирования. Но на самом деле это проявление большей свободы, поскольку вы можете сконцентрироваться на одних лишь данных. И если вы уделили немного времени проектированию состояния приложения, то многое другое логически последует.
Я не хочу сказать, что вам всегда нужно сначала полностью разрабатывать дерево состояний, а затем создавать остальные компоненты приложения. Обычно это делают параллельно. Но мне кажется, что полезнее сначала в общих чертах представить себе, как должно выглядеть дерево в разных ситуациях, прежде чем приступать к написанию кода. Давайте представим, каким может быть дерево состояний для нашего приложения голосований. Цель приложения — иметь возможность голосовать внутри пар объектов (фильмы, музыкальные группы). В качестве начального состояния приложения целесообразно сделать просто коллекцию из позиций, которые будут участвовать в голосовании. Назовём эту коллекцию entries (записи):
После начала голосования нужно как-то отделить позиции, которые участвуют в голосовании в данный момент. В состоянии может быть сущность vote, содержащая пару позиций, из которых пользователь должен выбрать одну. Естественно, эта пара должна быть извлечена из коллекции entries:
Также нам нужно вести учёт результатов голосования. Это можно делать с помощью другой структуры внутри vote:
По завершении текущего голосования проигравшая запись выкидывается, а победившая возвращается обратно в entries и помещается в конец списка. Позднее она снова будет участвовать в голосовании. Затем из списка берётся следующая пара:
Эти состояния циклически сменяют друг друга до тех пор, пока в коллекции есть записи. В конце останется только одна запись, которая объявляется победителем, а голосование завершается:
Схема кажется вполне разумной, начнём её реализовывать. Есть много разных способов разработки состояний под эти требования, возможно, этот вариант и не оптимальный. Но это не особенно важно. Начальная схема должна быть просто хорошей для старта. Главное, что у нас есть понимание того, как должно работать наше приложение. И это ещё до того, как мы перешли к написанию кода!
4.2. Настройка проекта
Пришло время засучить рукава. Для начала нужно создать папку проекта, а затем инициализировать его в качестве NPM-проекта:
mkdir voting-server
cd voting-server
npm init -y
В созданной папке пока что лежит одинокий файл package.json
. Писать код мы будем в спецификации ES6. Хотя Node начиная с версии 4.0.0 поддерживает много возможностей ES6, необходимые нам модули все же остались за бортом. Поэтому нам нужно добавить в наш проект Babel, чтобы мы могли воспользоваться всей мощью ES6 и транспилировать (transpile) код в ES5:
npm install --save-dev babel-core babel-cli babel-preset-es2015
Также нам понадобятся библиотеки для написания модульных (unit) тестов:
npm install --save-dev mocha chai
В качестве фреймворка для тестирования будем использовать Mocha. Внутри тестов будем использовать Chai в роли библиотеки для проверки ожидаемого поведения и состояний. Запускать тесты мы будем с помощью команды mocha
:
./node_modules/mocha/bin/mocha --compilers js:babel-core/register --recursive
После этого Mocha будет рекурсивно искать все тесты проекта и запускать их. Для транспилинга ES6-кода перед его запуском будет использоваться Babel. Для удобства можно хранить эту команду в package.json
:
package.json
"scripts": {
"test": "mocha --compilers js:babel-core/register --recursive"
},
Теперь нам нужно включить в Babel поддержку ES6/ES2015. Для этого активируем уже установленный нами пакет babel-preset-es2015
. Далее просто добавим в package.json
секцию "babel"
:
package.json
"babel": {
"presets": ["es2015"]
}
Теперь с помощью команды npm
мы можем запускать наши тесты:
npm run test
Команда test:watch
может использоваться для запуска процесса, отслеживающего изменения в нашем коде и запускающего тесты после каждого изменения:
package.json
"scripts": {
"test": "mocha --compilers js:babel-core/register --recursive",
"test:watch": "npm run test -- --watch"
},
Разработанная в Facebook библиотека Immutable предоставляет нам ряд полезных структур данных. Мы обсудим её в следующей главе, а пока просто добавим в проект наряду с библиотекой chai-immutable, которая добавляет в Chai поддержку сравнения Immutable-структур:
npm install --save immutable
npm install --save-dev chai-immutable
Подключать chai-immutable нужно до запуска каких-либо тестов. Сделать это можно с помощью файла test_helper
:
test/test_helper.js
import chai from 'chai';
import chaiImmutable from 'chai-immutable';
chai.use(chaiImmutable);
Теперь сделаем так, чтобы Mocha подгрузил этот файл до запуска тестов:
package.json
"scripts": {
"test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js --recursive",
"test:watch": "npm run test -- --watch"
},
Теперь у нас есть все, чтобы начать.
4.3. Знакомство с неизменяемыми данными
Второй важный момент, связанный с архитектурой Redux: состояние — это не просто дерево, а неизменяемое дерево (immutable tree). Структура деревьев из предыдущей главы может навести на мысль, что код должен менять состояние приложения просто обновляя деревья: заменяя элементы в ассоциативных массивах (maps), удаляя их из массивов и т.д. Но в Redux всё делается по-другому. Дерево состояний в Redux-приложении представляет собой неизменяемую структуру данных (immutable data structure). Это значит, что пока дерево существует, оно не меняется. Оно всегда сохраняет одно и то же состояние. И переход к другому состоянию осуществляется с помощью создания другого дерева, в которое внесены необходимые изменения. То есть два следующих друг за другом состояния приложения хранятся в двух отдельных и независимых деревьях. А переключение между деревьями осуществляется с помощью вызова функции, принимающей текущее состояние и возвращающей следующее.
Хорошая ли это идея? Обычно сразу указывают на то, что если все состояния хранятся в одном дереве и вы вносите все эти безопасные обновления, то можно без особых усилий сохранять историю состояний приложения. Это позволяет реализовать undo/redo «бесплатно» — можно просто задать предыдущее или следующее состояние (дерево) из истории. Также можно сериализовать историю и сохранить её на будущее, или поместить ее в хранилище для последующего проигрывания, что может оказать неоценимую помощью в отладке.
Но мне кажется, что, помимо всех этих дополнительных возможностей, главное достоинство использование неизменяемых данных заключается в упрощении кода. Вам приходится программировать чистые функции: они только принимают и возвращают данные, и больше ничего. Эти функции ведут себя предсказуемо. Вы можете вызывать их сколько угодно раз, и они всегда будут вести себя одинаково. Давайте им одни и те же аргументы, и будете получать одни и те же результаты. Тестирование становится тривиальным, ведь вам не нужно настраивать заглушки или иные фальшивки, чтобы «подготовить вселенную» к вызову функции. Есть просто входные и выходные данные.
Поскольку мы будем описывать состояние нашего приложения неизменяемыми структурами, давайте потратим немного времени на знакомство с ними, написав несколько unit-тестов, иллюстрирующих работу.
Если же вы уверенно работаете с неизменяемыми данными и библиотекой Immutable, то можете приступить к следующему разделу.
Для ознакомления с идеей неизменяемости можно для начала поговорить о простейшей структуре данных. Допустим, у вас есть приложение-счётчик, состояние которого представляет собой число. Скажем, оно меняется от 0 до 1, потом до 2, потом до 3 и т.д. В принципе, мы уже думаем о числах как о неизменяемых данных. Когда счётчик увеличивается, то число не изменяется. Да это и невозможно, ведь у чисел нет «сеттеров». Вы не можете сказать 42.setValue(43)
.
Так что мы просто получаем другое число, прибавляя к предыдущему единицу. Это можно сделать с помощью чистой функции. Её аргументом будет текущее состояние, а возвращаемое значение будет использоваться в качестве следующего состояния. Вызываемая функция не меняет текущее состояние. Вот её пример, а также unit тест к ней:
test/immutable_spec.js
import {expect} from 'chai';
describe('immutability', () => {
describe('a number', () => {
function increment(currentState) {
return currentState + 1;
}
it('is immutable', () => {
let state = 42;
let nextState = increment(state);
expect(nextState).to.equal(43);
expect(state).to.equal(42);
});
});
});
Очевидно, что state
не меняется при вызове increment
, ведь числа неизменяемы!
Как вы могли заметить, этот тест ничего не делает с нашим приложением, мы его пока и не писали вовсе.
Тесты могут быть просто инструментом обучения для нас. Я часто нахожу полезным изучать новые API или методики с помощью написания модульных тестов, прогоняющих какие-то идеи. В книге Test-Driven Development подобные тесты получили название «обучающих тестов».
Теперь распространим идею неизменяемости на все виды структур данных, а не только на числа.
С помощью Immutable списков мы можем, к примеру, сделать приложение, чьим состоянием будет список фильмов. Операция добавления нового фильма создаст новый список, который представляет собой комбинацию старого списка и добавляемой позиции. Важно отметить, что после этой операции старое состояние остаётся неизменённым:
test/immutable_spec.js
import {expect} from 'chai';
import {List} from 'immutable';
describe('immutability', () => {
// ...
describe('A List', () => {
function addMovie(currentState, movie) {
return currentState.push(movie);
}
it('is immutable', () => {
let state = List.of('Trainspotting', '28 Days Later');
let nextState = addMovie(state, 'Sunshine');
expect(nextState).to.equal(List.of(
'Trainspotting',
'28 Days Later',
'Sunshine'
));
expect(state).to.equal(List.of(
'Trainspotting',
'28 Days Later'
));
});
});
});
А если бы мы вставили фильм в обычный массив, то старое состояние изменилось бы. Но вместо этого мы используем списки из Immutable, поэтому применяем ту же семантику, что и в предыдущем примере с числами.
При вставке в обычный массив старое состояние изменилось бы. Но поскольку мы используем Immutable списки, то имеем ту же семантику, что и в примере с числами.
Эта идея также хорошо применима и к полноценным деревьям состояний. Дерево является вложенной структурой списков (lists), ассоциативных массивов (maps) и других типов коллекций. Применяемая к нему операция создаёт новое дерево состояния, оставляя предыдущее в неприкосновенности. Если дерево представляет собой ассоциативный массив (map) с ключом movies
, содержащим список (list) фильмов, то добавление новой позиции подразумевает необходимость создания нового массива, в котором ключ movies
указывает на новый список:
test/immutable_spec.js
import {expect} from 'chai';
import {List, Map} from 'immutable';
describe('immutability', () => {
// ...
describe('a tree', () => {
function addMovie(currentState, movie) {
return currentState.set(
'movies',
currentState.get('movies').push(movie)
);
}
it('is immutable', () => {
let state = Map({
movies: List.of('Trainspotting', '28 Days Later')
});
let nextState = addMovie(state, 'Sunshine');
expect(nextState).to.equal(Map({
movies: List.of(
'Trainspotting',
'28 Days Later',
'Sunshine'
)
}));
expect(state).to.equal(Map({
movies: List.of(
'Trainspotting',
'28 Days Later'
)
}));
});
});
});
Здесь мы видим точно такое же поведение, как и прежде, расширенное для демонстрации работы с вложенными структурами. Идея неизменяемости применима к данным всех форм и размеров.
Для операций над подобными вложенными структурами в Immutable есть несколько вспомогательных функций, облегчающих «залезание» во вложенные данные ради получения обновлённого значения. Для краткости кода можем использовать функцию update:
test/immutable_spec.js
function addMovie(currentState, movie) {
return currentState.update('movies', movies => movies.push(movie));
}
Похожую функцию мы будем использовать в нашем приложении для обновления состояния приложения. В API Immutable скрывается немало других возможностей, и мы лишь рассмотрели верхушку айсберга.
Неизменяемые данные являются ключевым аспектом архитектуры Redux, но не существует жесткого требования использовать именно библиотеку Immutable. В официальной документации Redux по больше части упоминаются простые объекты и массивы JavaScript, и от их изменения воздерживаются по соглашению.
Существует ряд причин, по которым в нашем же руководстве будет использована библиотека Immutable:
- Структуры данных в Immutable разработаны с нуля, чтобы быть неизменяемыми и предоставляют API, который позволяет удобно выполнять операции над ними.
- Я разделяю точки зрения Рича Хайки, согласно которой не существует такой вещи, как неизменяемость по соглашению. Если вы используете структуры данных, которые могут быть изменены, то рано или поздно кто-нибудь ошибётся и сделает это. Особенно если вы новичок. Вещи вроде Object.freeze () помогут вам не ошибиться.
- Неизменяемые структуры данных являются персистентными, то есть их внутренняя структура такова, что создание новой версии является эффективной операцией с точки зрения времени и потребления памяти, особенно в случае больших деревьев состояний. Использование обычных объектов и массивов может привести к избыточному копированию, снижающему производительность.
4.4. Реализация логики приложения с помощью чистых функций
Познакомившись с идеей неизменяемых деревьев состояний и функциями, оперирующими этими деревьями, можно перейти к созданию логики нашего приложения. В её основу лягут рассмотренные выше компоненты: древовидная структура и набор функций, создающих новые версии этого дерева.
4.4.1. Загрузка записей
В первую очередь, приложение должно «загружать» коллекцию записей для голосования. Можно сделать функцию setEntries
, берущую предыдущее состояние и коллекцию, и создающую новое состояние, включив туда записи. Вот тест для этой функции:
test/core_spec.js
import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries} from '../src/core';
describe('application logic', () => {
describe('setEntries', () => {
it('добавляет записи к состоянию', () => {
const state = Map();
const entries = List.of('Trainspotting', '28 Days Later');
const nextState = setEntries(state, entries);
expect(nextState).to.equal(Map({
entries: List.of('Trainspotting', '28 Days Later')
}));
});
});
});
Первоначальная реализация setEntries
делает только самое простое: ключу entries
в ассоциативном массиве (map
) состояния присваивает в качестве значения указанный список (list
) записей. Получаем первое из спроектированных нами ранее деревьев.
src/core.js
export function setEntries(state, entries) {
return state.set('entries', entries);
}
Для удобства разрешим входным записям представлять собой обычный JavaScript-массив (или что-нибудь итерируемое). В дереве состояния же должен присутствовать Immutable список (List
):
test/core_spec.js
it('преобразует в immutable', () => {
const state = Map();
const entries = ['Trainspotting', '28 Days Later'];
const nextState = setEntries(state, entries);
expect(nextState).to.equal(Map({
entries: List.of('Trainspotting', '28 Days Later')
}));
});
Для удовлетворения этому требованию будем передавать записи в конструктор списка (List
):
src/core.js
import {List} from 'immutable';
export function setEntries(state, entries) {
return state.set('entries', List(entries));
}
4.4.2. Запуск голосования
Голосование можно запустить вызовом функции next
при состоянии, уже имеющем набор записей. Таким образом будет осуществлён переход от первого ко второму из спроектированных деревьев.
Этой функции не нужны дополнительные аргументы. Она должна создавать ассоциативный массив vote
, в котором по ключу pair
лежат две первые записи. При этом записи, которые в данный момент участвуют в голосовании, больше не должны находиться в списке entries
:
test/core_spec.js
import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries, next} from '../src/core';
describe('логика приложения', () => {
// ..
describe('далее', () => {
it('берёт для голосования следующие две записи', () => {
const state = Map({
entries: List.of('Trainspotting', '28 Days Later', 'Sunshine')
});
const nextState = next(state);
expect(nextState).to.equal(Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later')
}),
entries: List.of('Sunshine')
}));
});
});
});
Реализация функции будет объединять (merge) обновление со старым состоянием, обособляя первые записи лежат в отдельный список, а остальные — в новую версию списка entries
:
src/core.js
import {List, Map} from 'immutable';
// ...
export function next(state) {
const entries = state.get('entries');
return state.merge({
vote: Map({pair: entries.take(2)}),
entries: entries.skip(2)
});
}
4.4.3. Голосование
По мере продолжения голосования, пользователь должен иметь возможность отдавать голос за разные записи. И при каждом новом голосовании на экране должен отображаться текущий результат. Если за конкретную запись уже голосовали, то её счётчик должен увеличиться.
test/core_spec.js
import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries, next, vote} from '../src/core';
describe('логика приложения', () => {
// ...
describe('vote', () => {
it('создаёт результат голосования для выбранной записи', () => {
const state = Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later')
}),
entries: List()
});
const nextState = vote(state, 'Trainspotting');
expect(nextState).to.equal(Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 1
})
}),
entries: List()
}));
});
it('добавляет в уже имеющийся результат для выбранной записи', () => {
const state = Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 3,
'28 Days Later': 2
})
}),
entries: List()
});
const nextState = vote(state, 'Trainspotting');
expect(nextState).to.equal(Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 4,
'28 Days Later': 2
})
}),
entries: List()
}));
});
});
});
С помощью функции fromJS из Immutable можно более лаконично создать все эти вложенные схемы и списки.
Прогоним тесты:
src/core.js
export function vote(state, entry) {
return state.updateIn(
['vote', 'tally', entry],
0,
tally => tally + 1
);
}
Использование updateIn позволяет не растекаться мыслью по древу. В этом коде говорится: «возьми путь вложенной структуры данных ['vote'
, 'tally'
, 'Trainspotting'
] и примени эту функцию. Если какие-то ключи отсутствуют, то создай вместо них новые массивы (Map
). Если в конце отсутствует значение, то инициализируй нулем». Именно такого рода код позволяет получать удовольствие от работы с неизменяемыми структурами данных, так что стоит уделить этому время и попрактиковаться.
4.4.4. Переход к следующей паре
По окончании голосования по текущей паре, переходим к следующей. Нужно сохранить победителя и добавить в конец списка записей, чтобы позднее он снова принял участие в голосовании. Проигравшая запись просто выкидывается. В случае ничьей сохраняются обе записи.
Добавим эту логику к имеющейся реализации next
:
test/core_spec.js
describe('next', () => {
// ...
it('помещает победителя текущего голосования в конец списка записей', () => {
const state = Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 4,
'28 Days Later': 2
})
}),
entries: List.of('Sunshine', 'Millions', '127 Hours')
});
const nextState = next(state);
expect(nextState).to.equal(Map({
vote: Map({
pair: List.of('Sunshine', 'Millions')
}),
entries: List.of('127 Hours', 'Trainspotting')
}));
});
it('в случае ничьей помещает обе записи в конец списка', () => {
const state = Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 3,
'28 Days Later': 3
})
}),
entries: List.of('Sunshine', 'Millions', '127 Hours')
});
const nextState = next(state);
expect(nextState).to.equal(Map({
vote: Map({
pair: List.of('Sunshine', 'Millions')
}),
entries: List.of('127 Hours', 'Trainspotting', '28 Days Later')
}));
});
});
В нашей реализации мы просто соединяем победителей текущего голосования с записями. А находить этих победителей можно с помощью новой функции getWinners
:
src/core.js
function getWinners(vote) {
if (!vote) return [];
const [a, b] = vote.get('pair');
const aVotes = vote.getIn(['tally', a], 0);
const bVotes = vote.getIn(['tally', b], 0);
if (aVotes > bVotes) return [a];
else if (aVotes < bVotes) return [b];
else return [a, b];
}
export function next(state) {
const entries = state.get('entries')
.concat(getWinners(state.get('vote')));
return state.merge({
vote: Map({pair: entries.take(2)}),
entries: entries.skip(2)
});
}
4.4.5. Завершение голосования
В какой-то момент у нас остаётся лишь одна запись — победитель, и тогда голосование завершается. И вместо формирования нового голосования, мы явным образом назначаем эту запись победителем в текущем состоянии. Конец голосования.
test/core_spec.js
describe('next', () => {
// ...
it('когда остаётся лишь одна запись, помечает её как победителя', () => {
const state = Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 4,
'28 Days Later': 2
})
}),
entries: List()
});
const nextState = next(state);
expect(nextState).to.equal(Map({
winner: 'Trainspotting'
}));
});
});
В реализации next
нужно предусмотреть обработку ситуации, когда после завершения очередного голосования в списке записей остаётся лишь одна позиция:
src/core.js
export function next(state) {
const entries = state.get('entries')
.concat(getWinners(state.get('vote')));
if (entries.size === 1) {
return state.remove('vote')
.remove('entries')
.set('winner', entries.first());
} else {
return state.merge({
vote: Map({pair: entries.take(2)}),
entries: entries.skip(2)
});
}
}
Здесь можно было бы просто вернуть Map({winner: entries.first()})
. Но вместо этого мы снова берём старое состояние и явным образом убираем из него ключи vote
и entries
. Это делается с прицелом на будущее: может случиться так, что в нашем состоянии появятся какие-то сторонние данные, которые нужно будет в неизменном виде передать с помощью этой функции. В целом, в основе функций трансформирования состояний лежит хорошая идея — всегда преобразовывать старое состояние в новое, вместо создания нового состояния с нуля.
Теперь у нас есть вполне приемлемая версия основной логики нашего приложения, выраженная в виде нескольких функций. Также мы написали для них модульные (unit) тесты, которые дались нам довольно легко: никаких преднастроек и заглушек. В этом и проявляется красота чистых функций. Можно просто вызвать их и проверить возвращаемые значения.
Обратите внимание, что мы пока ещё даже не установили Redux. При этом спокойно занимались разработкой логики приложения, не привлекая «фреймворк» к этой задаче. Есть в этом что-то чертовски приятное.
4.5. Использование Actions и Reducers
Итак, у нас есть основные функции, но мы не будем вызывать их в Redux напрямую. Между функциями и внешним миром расположен слой косвенной адресации: действия (Actions
).
Это простые структуры данных, описывающие изменения, которые должны произойти с состоянием вашего приложения. По сути это описание вызова функции, упакованное в маленький объект. По соглашению, каждое действие имеет атрибут type
, описывающий, для какой операции это действие предназначено. Также могут использоваться и дополнительные атрибуты. Вот несколько примеров действий, подходящих для наших основных функций:
{type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']}
{type: 'NEXT'}
{type: 'VOTE', entry: 'Trainspotting'}
При таком способе выражения нам ещё понадобится превратить их в нормальные вызовы основных функций. В случае с VOTE
должен выполняться следующий вызов:
// Этот action
let voteAction = {type: 'VOTE', entry: 'Trainspotting'}
// должен сделать это:
return vote(state, voteAction.entry);
Теперь нужно написать шаблонную функцию (generic function), принимающую любое действие — в рамках текущего состояния — и вызывающую соответствующую функцию ядра. Такая функция называется преобразователем (reducer
):
src/reducer.js
export default function reducer(state, action) {
// Определяет, какую функцию нужно вызвать, и делает это
}
Теперь нужно убедиться, что наш преобразователь способен обрабатывать каждое из трёх действий:
test/reducer_spec.js
import {Map, fromJS} from 'immutable';
import {expect} from 'chai';
import reducer from '../src/reducer';
describe('reducer', () => {
it('handles SET_ENTRIES', () => {
const initialState = Map();
const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
entries: ['Trainspotting']
}));
});
it('handles NEXT', () => {
const initialState = fromJS({
entries: ['Trainspotting', '28 Days Later']
});
const action = {type: 'NEXT'};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later']
},
entries: []
}));
});
it('handles VOTE', () => {
const initialState = fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later']
},
entries: []
});
const action = {type: 'VOTE', entry: 'Trainspotting'};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
},
entries: []
}));
});
});
В зависимости от типа действия преобразователь должен обращаться к одной из функций ядра. Он также должен знать, как извлечь из действия дополнительные аргументы для каждой из функций:
src/reducer.js
import {setEntries, next, vote} from './core';
export default function reducer(state, action) {
switch (action.type) {
case 'SET_ENTRIES':
return setEntries(state, action.entries);
case 'NEXT':
return next(state);
case 'VOTE':
return vote(state, action.entry)
}
return state;
}
Обратите внимание, что если преобразователь не распознает действие, то просто вернёт текущее состояние.
К преобразователям предъявляется важное дополнительное требование: если они вызываются с незаданным состоянием, то должны знать, как проинициализировать его правильным значением. В нашем случае исходным значением является ассоциативный массив (map). Таким образом, состояние undefined
должно обрабатываться, как если бы мы передали пустой массив:
test/reducer_spec.js
describe('reducer', () => {
// ...
it('has an initial state', () => {
const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
const nextState = reducer(undefined, action);
expect(nextState).to.equal(fromJS({
entries: ['Trainspotting']
}));
});
});
Поскольку логика нашего приложения расположена в core.js
, то здесь же можно объявить начальное состояние:
src/core.js
export const INITIAL_STATE = Map();
Затем мы импортируем его в преобразователе (reducer) и используем в качестве значения по умолчанию для аргумента состояния:
src/reducer.js
import {setEntries, next, vote, INITIAL_STATE} from './core';
export default function reducer(state = INITIAL_STATE, action) {
switch (action.type) {
case 'SET_ENTRIES':
return setEntries(state, action.entries);
case 'NEXT':
return next(state);
case 'VOTE':
return vote(state, action.entry)
}
return state;
}
Любопытно то, как абстрактно преобразователь (reducer) можно использовать для перевода приложения из одного состояния в другое при помощи действия любого типа. В принципе, взяв коллекцию прошлых действий, вы действительно можете просто преобразовать её в текущее состояние. Именно поэтому функция называется преобразователь: она заменяет собой вызов callback-a.
test/reducer_spec.js
it('может использоваться с reduce', () => {
const actions = [
{type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']},
{type: 'NEXT'},
{type: 'VOTE', entry: 'Trainspotting'},
{type: 'VOTE', entry: '28 Days Later'},
{type: 'VOTE', entry: 'Trainspotting'},
{type: 'NEXT'}
];
const finalState = actions.reduce(reducer, Map());
expect(finalState).to.equal(fromJS({
winner: 'Trainspotting'
}));
});
Способность создавать и/или проигрывать коллекции действий является главным преимуществом модели переходов состояний с помощью action/reducer, по сравнению с прямым вызовом функций ядра. Поскольку actions — это объекты, которые можно сериализовать в JSON, то вы, к примеру, можете легко отправлять их в Web Worker, и там уже выполнять логику reducer-a. Или даже можете отправлять их по сети, как мы это сделаем ниже.
Обратите внимание, что в качестве actions мы используем простые объекты, а не структуры данных из Immutable. Этого требует от нас Redux.
4.6. Привкус Reducer-композиции
Согласно логике нашего ядра, каждая функция принимает и возвращает полное состояние приложения.
Но можно легко заметить, что в больших приложениях этот подход может оказаться не лучшим решением. Если каждая операция в приложении должна знать о структуре всего состояния, то ситуация быстро может стать нестабильной. Ведь для изменения состояния потребуется внести кучу других изменений.
Лучше всего в любых возможных случаях выполнять операции в рамках как можно меньшей части состояния (или в поддереве). Речь идёт о модульности: функциональность работает только с какой-то одной частью данных, словно остальное и не существует.
Но в нашем случае приложение такое маленькое, что у нас не возникнет вышеописанных проблем. Хотя кое-что улучшить мы всё же можем: функции vote
можно не передавать всё состояние приложения, ведь она работает только с одноимё