redux что такое reducer
Введение в Redux & React-redux
Оглавление
Введение
Вот вы прочитали мою статью про React (если нет, то настоятельно рекомендую вам сделать это) и начали разрабатывать приложения на нём. Но что это? Вы замечаете, как с расширением вашего приложения становится всё сложнее следить за текущим состоянием, сложно следить за тем, когда и какие компоненты рендарятся, когда они не рендарятся и почему они не рендарятся, сложно следить за потоком изменяющихся данных. Для этого и есть библиотека Redux. Сам React хоть и лёгкий, но для комфортной разработки на нем нужно много чего изучить.
И сегодня мы разберём 2 библиотеки: Redux и React-redux. Для использования Redux’а вам не нужно скачивать дополнительных библиотек, но, если использовать его в связке с библиотекой React-redux разработка становится ещё удобнее и проще.
Все примеры из этой статьи вы можете найти в этом репозитории на Github. Там находится полностью настроенное приложение React с использованием Redux и React-redux. Вы можете использовать его как начальную точку для вашего проекта. Изменяйте названия файлов и добавляйте новые в этот репозитории для создания собственного приложения. Смотрите во вкладку релизы для того что бы найти разные версии приложения. Первая содержит приложение только с использованием Redux, второе с использованием Redux и React-redux.
Мотивация использования Redux
Механизм локального хранилища компонента, который поставляется вместе с базовой библиотекой (React) неудобен тем, что такое хранилище изолировано. К примеру, если вы хотите, чтобы разные независимые компоненты реагировали на какое-либо событие, вам придётся либо передавать локальное состояние в виде пропсов дочерним компонентам, либо поднимать его вверх до ближайшего родительского компонента. В обоих случаях делать это не удобно. Код становится более грязным, трудночитаемым, а компоненты зависимыми от их вложенности. Redux снимает эту проблему так как всё состояние доступно всем компонентом без особых трудностей.
Redux является универсальным средством разработки и может быть использован в связке с различными библиотеками и фреймворками. В этой же статье будет рассматривается использование Redux в React приложениях.
1. Установка Redux и начало работы
Используете ли вы Yarn или Npm, выполните одну из этих команд для установки Redux:
Скорее всего вы используете папку src в которой хранится ваша кодовая база. Файлы, связанные с redux принято хранить в отдельной папке. Для этого я использую папку /src/store в которой хранится всё то, что связано с Redux и хранилищем приложения. Вы можете назвать ее по другому или поместить в другое место.
Создайте базовую структуру для хранилища. Она должна выглядит примерно следующим образом:
.store
├── actionCreators
│ ├── action_1.js
│ └── action_2.js
├── actions
│ ├── action_1.js
│ └── action_2.js
├── reducers
│ ├── reducer_1.js
│ ├── reducer_2.js
│ └── rootReducer.js
├── initialState.js
└── store.js
Конечно здесь я использовал примитивные названия для файлов, это сделано для наглядности. В настоящем проекте так называть файлы не стоит.
2. Redux
2.1 createStore
Когда вы создали базовую структуру для работы с хранилищем Redux пришло время понять то как вы можете взаимодействовать с ним.
Глобальное хранилище приложения создаётся в отдельном файле, который как правило называется store.js:
2.2 reducer()
reducer — чистая функция которая будет отвечать за обновление состояния. Здесь реализовывается логика в соответствие с которой будет происходить обновление полей store.
Так выглядит базовая функция reducer:
Функция принимает значение текущего состояния и обьект события (action). Обьект события содержит два свойства — это тип события (action.type) и значение события (action.value).
К примеру если нужно обработать событие onChange для поля ввода то объект события может выглядеть так:
Некоторые события могут не нуждаться в передаче каких-либо значении. К примеру, обрабатывая событие onClick мы можем сигнализировать о том, что событие произошло, более никаких данных не требуется, а как на него реагировать будет описывать логика, заложенная непосредственно в сам компонент которой должен на него реагировать и частично в reducer. Но во всех случаях необходимо определять тип события. Редьюсер как бы спрашивает: что произошло? actio.type равен «ACTION_1» ага значит произошло событие номер 1. Дальше его нужно как то обработать и обновить состояние. То, что вернёт редьюсер и будет новым состоянием.
ACTION_1 и ACTION_2 это константы событий. По-другому Actions. Про них мы поговорим далее 2.5 Actions.
Как вы уже догадались store может хранить сложную структуру данных состоящих из набора независимых свойств. Обновление одного свойства оставит нетронутым другие свойства. Так из примера выше, когда происходит событие номер один (ACTION_1) обновляется поле номер один (value_1) в store при этом поле номер два (value_2) остаётся нетронутым. В общем механизм схож с методом this.setState().
2.3 dispatch()
Что бы обновить store необходимо вызвать метод dispatch(). Он вызывается у объекта store который вы создаёте в store.js. Этот объект принято называть store поэтому обновление состояния в моём случае выглядит так:
ACTION_1 это константа события о которой речь пойдет дальше (см. Actions).
Эта функция вызовет функцию reducer который обработает событие и обновит соответствующие поля хранилища.
2.4 actionCreator()
На самом деле передавать объект события напрямую в dispatch() является признаком плохого тона. Для этого нужно использовать функцию под названием actionCreator. Она делает ровно то что и ожидается. Создаёт событие! Вызов этой функции нужно передавать как аргумент в dispatch а в actionCreator передавать необходимое значение (value). Базовый actionCreator выглядит следующим образом:
Таким образом вызов dispatch должен выглядеть так:
С использованием actionCreator код становится более чистым.
2.5 Actions
actions это константы, описывающие событие. Обычно это просто строка с названием описывающее событие. К примеру константа описывающее событие номер один будет выглядеть следующем образом:
Опять же в проекте вам стоит называть константы в соответствии с событием, которое она описывает: onClick, createUserSesion, deleteItem, addItem и т.д. Главное, чтобы было понятно. Замете что я нигде не писал import поэтому не забудьте импортировать ваши константы перед их использованием. Потому что константы тоже принято разбивать на отдельные файлы храня их в специальной папке. Хотя некоторые хранят их в одном файле под названием actionTypes.js. Такое решение нельзя назвать не правильным, но и не идеальным.
2.6 getState()
С помощью dispatch() обновили, а как теперь посмотреть новое значение store? Ничего изобретать не нужно, есть метод getState(). Он также, как и метод dispatch вызывается на экземпляре объекта store. Поэтому для моего примера вызов
вернёт значение полей хранилища. К примеру что бы посмотреть значение поля value_1 необходимо будет вызвать
2.7 subscribe()
А как же узнать, когда состояние обновилось? Для этого есть метод subscribe(). Он также вызывается на экземпляре store. Данный метод принимает функцию, которая будет вызывается каждый раз после обновления store. Он как бы «подписывает» функцию, переданную ему на обновление. К примеру следующий код при каждом обновлении (при каждом вызове dispatch()) будет выводить новое значение store в консоль.
Этот метод возвращает функцию unsubscribe(). Которая позволяет «отписаться от обновления». К примеру если компонент удаляется из DOM стоит отписать его методы от обновления в componentWillUnmount(). Этот метод жизненного цикла вызывается при размонтировании компонента и это именно то место где стоит отписываться от обновления. Проще говоря в деструкторе.
2.8 combineReducers()
combineReducers() позволяет объединить несколько редьюсеров в один.
Если логика обновления компонентов довольно сложна и\или необходимо обрабатывать большое количество различных типов событий, то корневой reducer может стать слишком громоздким. Лучшим решением будет разбить его на несколько отдельных редьюсеров каждый из которых отвечает за обработку только одного типа событий и обновления определённого поля.
Когда вы разбиваете базовый редьюсер на несколько, то название каждого из них должно соответствовать полю которое он обновляет в store.
К примеру если редьюсер обновляет поле номер один, то он может выглядеть так:
Название редьюсера (value_1) показывает какое свойство он будет обновлять в store. Если переименуете его в value_2 то он станет обновлять value_2. Поэтому учтите это!
Когда используется единый редьюсер мы показываем какое поле хотим обновить:
Но когда вы разделили ваши редьюсеры вам нужно просто вернуть новое значение:
Поскольку здесь не требуется указывать которое из полей обновляет редьюсер ибо его название и есть поле которое он обновляет.
2.9 initialState
initialState — объект, представляющий начальное состояние хранилища. Он является вторым не обязательным аргументом метода createStore(). С созданием хранилища можно сразу объявить начальное состояние для его полей. Этот объект желательно создавать, даже в тех случаях, когда объявления начального состояния не требуется. Потому что этот объект помогает посмотреть на структуру хранилища и название его полей. Обычный объект initialState выглядит следующим образом:
В некоторых случаях (когда компонент сразу использует значение из store), его объявление может стать обязательным иначе вы получите ошибку: TypeError: Cannot read property ‘value_1’ of undefined.
Также редьюсеры всегда должны возвращать по дефолту текущее состояние. К примеру, если используется единый reducer то последнее значение в switch должно выглядеть так:
Если же вы разделяете редьюсеры на независимые функции, то он должен возвращать значение того свойства за которое он отвечает:
Также если вы не передаёте объект initialState в createStore вы можете вернуть его из редьюсера. В обоих случаях будет инициализировано начальное состояние для store.
3. React-redux
Казалось бы, у нас есть всё что бы использовать Redux. Но на деле использование его без пакета React-redux в React приложениях выглядит не очень красиво.
3.1 Provider
Для использование store в компоненте вам необходимо передавать его в пропсы:
И после использовать в компоненте: this.props.state. Для этого react-redux предостовляет метод Provider:
Также можно передать store напрямую в компонент, не оборачивая его в Provider и это будет работать. Но лучше всё-таки используйте Provider.
3.2 mapStateToProps()
Этот метод вызывается всякий раз, когда происходит обновление store и именно он передаёт необходимые свойства из store в компонент. К примеру компонент, должен реагировать и обновлять UI каждый раз, когда поле номер один (value_1) обновилось. На обновление других полей ему реагировать не нужно. Если вы не используете React-redux вам бы пришлось использовать метод subscribe() что бы узнавать об обновлении и далее каким то образом проверять обновилось ли поле номер один или нет. В общем несложно понять, что такой код будет выглядеть слишком грязным и избыточным. С помощью mapStateToProps() можно чётко определить какие поля интересуют компонент. И на какие поля он должен реагировать.
Возвращаясь к примеру выше, если компоненту один нужно получать поле номер один (value_1) то mapStateToProps для него будет выглядеть следующим образом:
После внутри компонента мы можем обращается к полю value_1 через this.props.value_1. И каждый раз когда это поле будет обновляется компонент будет рендерится заново.
Вы можете создать отдельную папку в /src/store для хранения файлов каждый из которых будет содержать функцию mapStateToProps для всех ваших компонентов. Либо (как сделал это я) использовать единую функцию возвращающую функцию mapStateToProps для каждого компонента. Лично мне нравится такой подход. Такая функция выглядит следующим образом:
Эта функция в качестве аргумента принимает строку с названием компонента и возвращает функцию mapStateToProps которая возвращает объект со свойством из store необходимом для данного компонента. Эту функцию можно назвать mapStateToPropsGenerator().
3.3 mapDispatchToProps()
Эта функция передаёт в компонент методы для обновления необходимого поля store. Что бы не вызывать dispatch напрямую из компонента вы будете использовать данный метод для того что бы передавать в props метод вызов которого приведёт к вызову dispatch и обновлению соответствующего поля. Просто теперь это будет выглядеть более элегантно, а код более понятным и чистым.
К примеру компонент, номер один должен иметь возможность обновлять поле номер один из store. Тогда mapDispatchToProps для него будет выглядеть следующим образом:
Теперь для обновления свойства value_1 вы будете вызывать changeValue_1() через this.props.changeValue_1(value). Не вызывая dispatch напрямую через this.props.store.dispatch(action_1(value)).
bindActionCreators следует импортировать из redux. Он позволяет оборачивать функцию dispatch и actionCreator в единый объект. Вы можете не использовать bindActionCreators но тогда код будет выглядеть избыточным. Вы должны старятся реализовать какую-либо функциональность так, чтобы код выгладил просто и миниатюрно. Поэтому ничего лишнего писать не следует.
Только чистый и понятный код. Метод bindActionCreators(actionCreator, dispatch) принимает два обязательных параметра это функцию actionCreator о которой мы уже говорили и dispatch. Возвращая метод для изменения полей store.
Также как и для mapStateToProps я использую функцию генератор возвращающую функцию mapDispatchToProps для каждого компонента:
3.4 connect()
Ну и теперь кульминация! То без чего всё это не будет работать. Это функция connect.
Именно она связывает mapStateToProps и mapDispatchToProps с компонентом и передает необходимые поля и методы в него. Возвращает она новый компонент-обёртку для вашего компонента. Как правильно именовать такой компонент я не знаю, ибо в самой документации React-redux это не описывается. Лично я добавляю окончание _w для компонентов оберток. Как бы _w = wrap Component. Подключение компонента в этм случае выглядит так:
И теперь в ReactDOM.render() вы передаёте не ваш компонент, а тот что возвращает функция connect.
Если же у компонента нет необходимости в передаче ему mapStateToProps или mapDispatchToProps передавайте undefined или null в него.
Краткое руководство по Redux для начинающих
Авторизуйтесь
Краткое руководство по Redux для начинающих
Библиотека Redux — это способ управления состоянием приложения. Она основана на нескольких концепциях, изучив которые, можно с лёгкостью решать проблемы с состоянием. Вы узнаете о них далее, в этом руководстве по Redux для начинающих.
Примечание Вы читаете улучшенную версию некогда выпущенной нами статьи.
Содержание:
Когда нужно пользоваться Redux?
Redux идеально использовать в средних и крупных приложениях. Им стоит пользоваться только в случаях, когда невозможно управлять состоянием приложения с помощью стандартного менеджера состояний в React или любой другой библиотеке.
Простым приложениям Redux не нужен.
Использование Redux
Разберём основные концепции библиотеки Redux, которые нужно понимать начинающим.
Неизменяемое дерево состояний
В Redux общее состояние приложения представлено одним объектом JavaScript — state (состояние) или state tree (дерево состояний). Неизменяемое дерево состояний доступно только для чтения, изменить ничего напрямую нельзя. Изменения возможны только при отправке action (действия).
Действия
Действие (action) — это JavaScript-объект, который лаконично описывает суть изменения:
Типы действий должны быть константами
В простом приложении тип действия задаётся строкой. По мере разрастания функциональности приложения лучше переходить на константы:
и выносить действия в отдельные файлы. А затем их импортировать:
Генераторы действий
Генераторы действий (actions creators) — это функции, создающие действия.
Обычно инициируются вместе с функцией отправки действия:
Или при определении этой функции:
Редукторы
При запуске действия обязательно что-то происходит и состояние приложения изменяется. Это работа редукторов.
Что такое редуктор
Редуктор (reducer) — это чистая функция, которая вычисляет следующее состояние дерева на основании его предыдущего состояния и применяемого действия.
Чистая функция работает независимо от состояния программы и выдаёт выходное значение, принимая входное и не меняя ничего в нём и в остальной программе. Получается, что редуктор возвращает совершенно новый объект дерева состояний, которым заменяется предыдущий.
Чего не должен делать редуктор
Редуктор — это всегда чистая функция, поэтому он не должен:
Поскольку состояние в сложных приложениях может сильно разрастаться, к каждому действию применяется не один, а сразу несколько редукторов.
Симулятор редуктора
Упрощённо базовую структуру Redux можно представить так:
Состояние
Список действий
Редуктор для каждой части состояния
Редуктор для общего состояния
Хранилище
Хранилище (store) — это объект, который:
Хранилище в приложении всегда уникально. Так создаётся хранилище для приложения listManager:
Хранилище можно инициировать через серверные данные:
Функции хранилища
Прослушивание изменений состояния:
Поток данных
Поток данных в Redux всегда однонаправлен.
15–17 ноября, Онлайн, Беcплатно
Передача действий с потоками данных происходит через вызов метода dispatch() в хранилище. Само хранилище передаёт действия редуктору и генерирует следующее состояние, а затем обновляет состояние и уведомляет об этом всех слушателей.
Советуем начинающим в Redux прочитать нашу статью о других способах передачи данных.
Dec 23, 2016 · 3 min read
На рисунке ниже изображено простое TODO приложение. В данный момент оно показывает как завершенные, так и не завершенные задачи.
С правой стороны находится state. Это простой JavaScript объект, в котором хранится текущее состояние приложения.
Чистая функция
На фундаментальном уровне, любая функция, которая не изменяет входные данные, не зависит от внешнего состояния (базы данных, DOM или глобальной переменной) и возвращает один и тот же результат для одинаковых входных данных является чистой функцией.
Напри м ер функция ниже не изменяет значения “a” или “b”, не зависит от внешнего состояния и всегда возвращает один и тот же результат для одинаковых входных данных.
Теперь, если вы посмотрите на наш reducer, то заметите, что это тоже чистая функция.
Но почему Reducer должен быть чистой функцией?
Давайте посмотрим, что произойдет, если мы cделаем наш reducer “не чистым”. Закомментируем раздел где он возвращает новый объект и позволим ему мутировать текущее состояние ( state).
Теперь, если мы попытаемся нажать на одну из задач, ничего не произойдёт!
Хм. Залазим в исходный код Redux.
Без паники! Тут всё просто!
Redux принимает переданное в него состояние ( state) и отдаёт его каждому reducer в цикле. Если есть какие-либо изменения он ожидает получить новый объект из reducer функции (также он рассчитывает получить старый объект обратно, если изменений не было).
Дальше Redux проверяет, изменилось ли старое состояние путём обычного сравнения, а так как мы мутировали свойство старого объекта вместо создания нового, внутри нашего reducer, то старое и новое состояние будут ссылаться на один и тот же объект.
Поэтому Redux думает, что ничего не изменилось!
Ответ: есть только один способ узнать равны ли два объекта в JavaScript. Для этого используется deep-compare.
К сожалению, это становится чрезвычайно дорогим удовольствием в реальных приложениях, из-за, как правило, больших объектов.
Новый объект = Новое состояние
Старый объект = Не изменённое состояние
Вот поэтому редюсеры должны быть “чистыми” функциями в Redux!
Если эта статья оказалась для вас полезной, ставьте ❤️, так остальные люди смогут найти её!
Руководство по работе с Redux
Сегодня Redux — это одно из наиболее интересных явлений мира JavaScript. Он выделяется из сотни библиотек и фреймворков тем, что грамотно решает множество разных вопросов путем введения простой и предсказуемой модели состояний, уклоне на функциональное программирование и неизменяемые данные, предоставления компактного API. Что ещё нужно для счастья? Redux — библиотека очень маленькая, и выучить её API не сложно. Но у многих людей происходит своеобразный разрыв шаблона — небольшое количество компонентов и добровольные ограничения чистых функций и неизменяемых данных могут показаться неоправданным принуждением. Каким именно образом работать в таких условиях?
В этом руководстве мы рассмотрим создание с нуля full-stack приложения с использованием Redux и Immutable-js. Применив подход TDD, пройдём все этапы конструирования Node+Redux бэкенда и React+Redux фронтенда приложения. Помимо этого мы будем использовать такие инструменты, как ES6, Babel, Socket.io, Webpack и Mocha. Набор весьма любопытный, и вы мигом его освоите!
Содержание статьи
1. Что вам понадобится
Данное руководство будет наиболее полезным для разработчиков, которые уже умеют писать JavaScript-приложения. Как уже упоминалось, мы будем использовать Node, ES6, React, Webpack и Babel, и если вы хотя бы немного знакомы с этими инструментами, никаких проблем с продвижением не будет. Даже если не знакомы, вы сможете понять основы по пути.
В качестве хорошего пособия по разработке веб-приложений с помощью React, Webpack и ES6, можно посоветовать SurviveJS. Что касается инструментов, то вам понадобится Node с NPM и ваш любимый текстовый редактор.
2. Приложение
Мы будем делать приложение для «живых» голосований на вечеринках, конференциях, встречах и прочих собраниях. Идея заключается в том, что пользователю будет предлагаться коллекция позиций для голосования: фильмы, песни, языки программирования, цитаты с Horse JS, и так далее. Приложение будет располагать элементы парами, чтобы каждый мог проголосовать за своего фаворита. В результате серии голосований останется один элемент — победитель. Пример голосования за лучший фильм Дэнни Бойла:
Приложение будет иметь два разных пользовательских интерфейса:
3. Архитектура
Структурно система будет состоять из двух приложений:
Несмотря на большое сходство клиента и сервера — к примеру, оба будут использовать Redux, — это не универсальное/изоморфное приложение, и приложения не будут совместно использовать какой-либо код. Скорее это можно охарактеризовать как распределённую систему из двух приложений, взаимодействующих друг с другом с помощью передачи сообщений.
4. Серверное приложение
Сначала напишем 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-проекта:
Также нам понадобятся библиотеки для написания unit тестов:
В качестве фреймворка для тестирования будем использовать Mocha. Внутри тестов будем использовать Chai в роли библиотеки для проверки ожидаемого поведения и состояний. Запускать тесты мы будем с помощью команды mocha :
После этого Mocha будет рекурсивно искать все тесты проекта и запускать их. Для транспилинга ES6-кода перед его запуском будет использоваться Babel. Для удобства можно хранить эту команду в package.json :
Теперь с помощью команды npm мы можем запускать наши тесты:
Команда test:watch может использоваться для запуска процесса, отслеживающего изменения в нашем коде и запускающего тесты после каждого изменения:
Разработанная в Facebook библиотека Immutable предоставляет нам ряд полезных структур данных. Мы обсудим её в следующей главе, а пока просто добавим в проект наряду с библиотекой chai-immutable, которая добавляет в Chai поддержку сравнения Immutable-структур:
Подключать chai-immutable нужно до запуска каких-либо тестов. Сделать это можно с помощью файла test_helper :
Теперь сделаем так, чтобы Mocha подгрузил этот файл до запуска тестов:
Теперь у нас есть все, чтобы начать.
4.3. Знакомство с неизменяемыми данными
Второй важный момент, связанный с архитектурой Redux: состояние — это не просто дерево, а неизменяемое дерево (immutable tree). Структура деревьев из предыдущей главы может навести на мысль, что код должен менять состояние приложения просто обновляя деревья: заменяя элементы в ассоциативных массивах, удаляя их из массивов и т.д. Но в Redux всё делается по-другому. Дерево состояний в Redux-приложении представляет собой неизменяемую структуру данных (immutable data structure). Это значит, что пока дерево существует, оно не меняется. Оно всегда сохраняет одно и то же состояние. И переход к другому состоянию осуществляется с помощью создания другого дерева, в которое внесены необходимые изменения. То есть два следующих друг за другом состояния приложения хранятся в двух отдельных и независимых деревьях. А переключение между деревьями осуществляется с помощью вызова функции, принимающей текущее состояние и возвращающей следующее.
Хорошая ли это идея? Обычно сразу указывают на то, что если все состояния хранятся в одном дереве и вы вносите все эти безопасные обновления, то можно без особых усилий сохранять историю состояний приложения. Это позволяет реализовать undo/redo “бесплатно” — можно просто задать предыдущее или следующее состояние (дерево) из истории. Также можно сериализовать историю и сохранить её на будущее, или поместить ее в хранилище для последующего проигрывания, что может оказать неоценимую помощью в отладке.
Но мне кажется, что, помимо всех этих дополнительных возможностей, главное достоинство использование неизменяемых данных заключается в упрощении кода. Вам приходится программировать чистые функции: они только принимают и возвращают данные, и больше ничего. Эти функции ведут себя предсказуемо. Вы можете вызывать их сколько угодно раз, и они всегда будут вести себя одинаково. Давайте им одни и те же аргументы, и будете получать одни и те же результаты. Тестирование становится тривиальным, ведь вам не нужно настраивать заглушки или иные фальшивки, чтобы «подготовить вселенную» к вызову функции. Есть просто входные и выходные данные.
Поскольку мы будем описывать состояние нашего приложения неизменяемыми структурами, давайте потратим немного времени на знакомство с ними, написав несколько unit-тестов, иллюстрирующих работу.
Если же вы уверенно работаете с неизменяемыми данными и библиотекой Immutable, то можете приступить к следующему разделу.
Так что мы просто получаем другое число, прибавляя к предыдущему единицу. Это можно сделать с помощью чистой функции. Её аргументом будет текущее состояние, а возвращаемое значение будет использоваться в качестве следующего состояния. Вызываемая функция не меняет текущее состояние. Вот её пример, а также unit тест к ней:
Как вы могли заметить, этот тест ничего не делает с нашим приложением, мы его пока и не писали вовсе.
Тесты могут быть просто инструментом обучения для нас. Я часто нахожу полезным изучать новые API или методики с помощью написания модульных тестов, прогоняющих какие-то идеи. В книге Test-Driven Development подобные тесты получили название «обучающих тестов».
Теперь распространим идею неизменяемости на все виды структур данных, а не только на числа.
С помощью Immutable списков мы можем, к примеру, сделать приложение, чьим состоянием будет список фильмов. Операция добавления нового фильма создаст новый список, который представляет собой комбинацию старого списка и добавляемой позиции. Важно отметить, что после этой операции старое состояние остаётся неизменённым:
А если бы мы вставили фильм в обычный массив, то старое состояние изменилось бы. Но вместо этого мы используем списки из Immutable, поэтому применяем ту же семантику, что и в предыдущем примере с числами.
При вставке в обычный массив старое состояние изменилось бы. Но поскольку мы используем Immutable списки, то имеем ту же семантику, что и в примере с числами.
Здесь мы видим точно такое же поведение, как и прежде, расширенное для демонстрации работы с вложенными структурами. Идея неизменяемости применима к данным всех форм и размеров.
Для операций над подобными вложенными структурами в Immutable есть несколько вспомогательных функций, облегчающих «залезание» во вложенные данные ради получения обновлённого значения. Для краткости кода можем использовать функцию update:
Похожую функцию мы будем использовать в нашем приложении для обновления состояния приложения. В API Immutable скрывается немало других возможностей, и мы лишь рассмотрели верхушку айсберга.
Неизменяемые данные являются ключевым аспектом архитектуры Redux, но не существует жесткого требования использовать именно библиотеку Immutable. В официальной документации Redux по больше части упоминаются простые объекты и массивы JavaScript, и от их изменения воздерживаются по соглашению.
Существует ряд причин, по которым в нашем же руководстве будет использована библиотека Immutable:
4.4. Реализация логики приложения с помощью чистых функций
Познакомившись с идеей неизменяемых деревьев состояний и функциями, оперирующими этими деревьями, можно перейти к созданию логики нашего приложения. В её основу лягут рассмотренные выше компоненты: древовидная структура и набор функций, создающих новые версии этого дерева.
4.4.1. Загрузка записей
Первоначальная реализация setEntries делает только самое простое: ключу entries в ассоциативном массиве состояния присваивает в качестве значения указанный список записей. Получаем первое из спроектированных нами ранее деревьев.
Для удобства разрешим входным записям представлять собой обычный JavaScript-массив (или что-нибудь итерируемое). В дереве состояния же должен присутствовать Immutable список ( List ):
Для удовлетворения этому требованию будем передавать записи в конструктор списка:
4.4.2. Запуск голосования
Голосование можно запустить вызовом функции next при состоянии, уже имеющем набор записей. Таким образом будет осуществлён переход от первого ко второму из спроектированных деревьев.
Реализация функции будет объединять (merge) обновление со старым состоянием, обособляя первые записи лежат в отдельный список, а остальные — в новую версию списка entries :
4.4.3. Голосование
По мере продолжения голосования, пользователь должен иметь возможность отдавать голос за разные записи. И при каждом новом голосовании на экране должен отображаться текущий результат. Если за конкретную запись уже голосовали, то её счётчик должен увеличиться.
С помощью функции fromJS из Immutable можно более лаконично создать все эти вложенные схемы и списки.
4.4.4. Переход к следующей паре
По окончании голосования по текущей паре, переходим к следующей. Нужно сохранить победителя и добавить в конец списка записей, чтобы позднее он снова принял участие в голосовании. Проигравшая запись просто выкидывается. В случае ничьей сохраняются обе записи.
Добавим эту логику к имеющейся реализации next :
В нашей реализации мы просто соединяем победителей текущего голосования с записями. А находить этих победителей можно с помощью новой функции getWinners :
4.4.5. Завершение голосования
В какой-то момент у нас остаётся лишь одна запись — победитель, и тогда голосование завершается. И вместо формирования нового голосования, мы явным образом назначаем эту запись победителем в текущем состоянии. Конец голосования.
В реализации next нужно предусмотреть обработку ситуации, когда после завершения очередного голосования в списке записей остаётся лишь одна позиция:
Теперь у нас есть вполне приемлемая версия основной логики нашего приложения, выраженная в виде нескольких функций. Также мы написали для них unit тесты, которые дались нам довольно легко: никаких преднастроек и заглушек. В этом и проявляется красота чистых функций. Можно просто вызвать их и проверить возвращаемые значения.
Обратите внимание, что мы пока ещё даже не установили Redux. При этом спокойно занимались разработкой логики приложения, не привлекая «фреймворк» к этой задаче. Есть в этом что-то чертовски приятное.
4.5. Использование Actions и Reducers
Итак, у нас есть основные функции, но мы не будем вызывать их в Redux напрямую. Между функциями и внешним миром расположен слой косвенной адресации: действия ( Actions ).
При таком способе выражения нам ещё понадобится превратить их в нормальные вызовы основных функций. В случае с VOTE должен выполняться следующий вызов:
Теперь нужно написать шаблонную функцию (generic function), принимающую любое действие — в рамках текущего состояния — и вызывающую соответствующую функцию ядра. Такая функция называется преобразователем ( reducer ):
Теперь нужно убедиться, что наш reducer способен обрабатывать каждое из трёх действий:
В зависимости от типа действия reducer должен обращаться к одной из функций ядра. Он также должен знать, как извлечь из действия дополнительные аргументы для каждой из функций:
Обратите внимание, что если reducer не распознает действие, то просто вернёт текущее состояние.
К reducer-ам предъявляется важное дополнительное требование: если они вызываются с незаданным состоянием, то должны знать, как проинициализировать его правильным значением. В нашем случае исходным значением является ассоциативный массив. Таким образом, состояние undefined должно обрабатываться, как если бы мы передали пустой массив:
Затем мы импортируем его в reducer-е и используем в качестве значения по умолчанию для аргумента состояния:
Любопытно то, как абстрактно reducer можно использовать для перевода приложения из одного состояния в другое при помощи действия любого типа. В принципе, взяв коллекцию прошлых действий, вы действительно можете просто преобразовать её в текущее состояние. Именно поэтому функция называется преобразователь: она заменяет собой вызов callback-a.
Способность создавать и/или проигрывать коллекции действий является главным преимуществом модели переходов состояний с помощью action/reducer, по сравнению с прямым вызовом функций ядра. Поскольку actions — это объекты, которые можно сериализовать в JSON, то вы, к примеру, можете легко отправлять их в Web Worker, и там уже выполнять логику reducer-a. Или даже можете отправлять их по сети, как мы это сделаем ниже.
Обратите внимание, что в качестве actions мы используем простые объекты, а не структуры данных из Immutable. Этого требует от нас Redux.
4.6. Привкус Reducer-композиции
Согласно логике нашего ядра, каждая функция принимает и возвращает полное состояние приложения.
Но можно легко заметить, что в больших приложениях этот подход может оказаться не лучшим решением. Если каждая операция в приложении должна знать о структуре всего состояния, то ситуация быстро может стать нестабильной. Ведь для изменения состояния потребуется внести кучу других изменений.
Лучше всего в любых возможных случаях выполнять операции в рамках как можно меньшей части состояния (или в поддереве). Речь идёт о модульности: функциональность работает только с какой-то одной частью данных, словно остальное и не существует.
Как видите, код теста упростился, а это обычно хороший знак!
Теперь реализация vote должна просто брать соответствующий сегмент состояния и обновлять счетчик голосования:
Далее reducer должен взять состояние и передать функции vote только необходимую часть.
Это лишь небольшой пример подхода, важность которого сильно возрастает с увеличением размера приложения: главная функция-reducer просто передаёт отдельные сегменты состояния reducer-ам уровнем ниже. Мы отделяем задачу поиска нужного сегмента дерева состояний от применения обновления к этому сегменту.
Гораздо подробнее шаблоны reducer-композиции рассмотрены в соответствующей секции документации Redux. Также там объясняются некоторые вспомогательные функции, во многих случаях облегчающие использование reducer-композиции.
4.7. Использование Redux Store
Теперь, когда у нас есть reducer, можно начать думать, как всё это подключить к Redux.
Как мы только что видели, если у вас есть коллекция всех действий, которые когда либо будут иметь место в вашем приложении, что вы можете просто вызвать reduce и получить на выходе финальное состояние приложения. Конечно, обычно у вас нет такой коллекции. Действия осуществляются постепенно, по мере возникновения разных событий: когда пользователь взаимодействует с приложением, когда данные приходят из сети, по триггеру таймаута.
Приспособиться к ситуации помогает хранилище — Redux Store. Как подсказывает логика, это объект, в котором хранится состояние нашего приложения.
Хранилище инициализируется reducer-функцией, наподобие уже реализованной нами:
Далее можно передать (dispatch) действия в store, который затем воспользуется reducer-ом для применения этих действий к текущему состоянию. В качестве результата этой процедуры мы получим следующее состояние, которое будет находиться в Redux-Store.
Вы можете получить из хранилища текущее состояние в любой момент времени:
Перед созданием Store нам нужно добавить Redux в проект:
Итак, Redux Store соединяет части нашего приложения в целое, которое можно использовать как центральную точку — здесь находится текущее состояние, сюда приходят actions, которые переводят приложение из одного состояния в другое с помощью логики ядра, транслируемой через reducer.
Вопрос: Сколько переменных в Redux-приложении вам нужно?
Ответ: Одна. Внутри хранилища.
На первый взгляд это звучит странно. По крайне мере, если у вас не так много опыта в функциональном программировании. Как можно сделать хоть что-то полезное всего лишь с одной переменной?
Но больше нам и не нужно. Текущее дерево состояний — единственная вещь, которая изменяется со временем в нашем базовом приложении. Всё остальное — это константы и неизменяемые значения.
Примечательно, насколько мала площадь соприкосновения между кодом нашего приложения и Redux. Благодаря тому, что у нас есть шаблонная reducer-функция, нам достаточно уведомить Redux лишь о ней. А всё остальное есть в нашем собственном, не зависящем от фреймворка, портируемом и исключительно функциональном коде!
А раз уж мы его экспортировали, то можем теперь завести и Node REPL (например, с помощью babel-node ), запросить файл index.js и взаимодействовать с приложением с помощью Store.
4.8. Настройка сервера Socket.io
Наше приложение будет работать в качестве сервера для другого браузерного приложения, имеющего пользовательский интерфейс для голосования и просмотра результатов. Нам нужно организовать взаимодействие клиентов с сервером, и наоборот.
Наше приложение только выиграет от внедрения общения в реальном времени, поскольку пользователям понравится сразу же наблюдать результаты своих действий и действий других. Для этой цели давайте воспользуемся WebSocket’ами. Точнее, возьмём библиотеку Socket.io, предоставляющую хорошую абстракцию для работающих в браузерах WebSocket’ов. К тому же тут есть и несколько запасных механизмов для клиентов, не поддерживающих WebSocket’ы.
Добавляем Socket.io в проект:
Этот код создает сервер Socket.io, а также поднимает на порте 8090 обычный HTTP-сервер. Порт выбран произвольно, он должен совпадать с портом, который позднее будет использоваться для связи с клиентами.
Можно немного упростить процедуру запуска, добавив команду start в наш package.json :
Теперь после ввода следующей команды будет запускаться сервер и создаваться Redux-Store:
Команда babel-node взята из ранее установленного нами пакета babel-cli. Она позволяет легко запускать Node-код с включённой поддержкой Babel-транспилирования. В целом, это не рекомендуется делать для боевых серверов, потому что производительность несколько снижается. Но зато хорошо подходит для наших учебных задач.
4.9. Трансляция Store из Redux Listener
Теперь у нас есть сервер Socket.io и контейнер Redux состояния, но они пока никак не интегрированы. Изменим это.
Сервер должен сообщать клиентам о текущем состоянии приложения (например, «за что сейчас голосуем?», «каков текущий результат?», «есть ли уже победитель?»). Это можно делать при каждом изменении с помощью передачи события из Socket.io всем подключённым клиентам.
А как узнать, что что-то изменилось? Для этого можно подписаться на Redux store, предоставив функцию, которая будет вызываться хранилищем при каждом применении action, когда состояние потенциально изменилось. По сути, это callback на изменения состояния внутри store.
Теперь при каждом изменении мы передаём полное состояние всем клиентам. Но это может повлечь за собой серьёзный рост трафика. Можно предложить различные способы оптимизации (например, отправлять только актуальную часть состояния, отправлять дифы вместо снэпшотов, и т.д.). В нашей реализации не будем этого делать в целях сохранения простоты кода.
Помимо передачи снэпшота состояния было бы хорошо, если бы клиенты немедленно получали текущее состояние при подключении к серверу. Это позволит сразу синхронизировать состояние клиентских приложений с текущим состоянием сервера.
4.10. Получение Remote Redux Actions
Здесь мы уже выходим за рамки «стандартного Redux», потому что фактически принимаем в store удалённые (remote) actions. Но архитектура Redux вовсе не мешает нам: действия являются JavaScript-объектами, которые можно легко посылать по сети, поэтому мы сразу получаем систему, в которой принимать участие в голосовании может любое количество клиентов. А это большой шаг!
Конечно, с точки зрения безопасности здесь есть ряд моментов, ведь мы позволяем любому клиенту, подключившемуся к Socket.io, отправлять любое действие в Redux store. Поэтому в реальных проектах нужно использовать что-то вроде файрвола, наподобие Vert.x Event Bus Bridge. Также файрвол нужно внедрять в приложения с механизмом аутентификации.
Теперь наш сервер работает следующим образом:
Теперь можно перейти к клиентскому приложению.
5. Клиентское приложение
Далее мы будем писать React-приложение, которое подключается к серверу и позволяет пользователям голосовать. И здесь мы тоже воспользуемся Redux. Собственно, это одно из наиболее распространённых его применений: в качестве движка в основании React-приложений. Мы уже познакомились с его работой, и скоро узнаем, как он совмещается с React и какое оказывает влияние на архитектуру. Рекомендую писать приложение с нуля, но можете скачать код с GitHub.
5.1. Настройка клиентского проекта
В первую очередь мы создадим свежий NPM-проект, как мы это делали в случае с сервером.
Теперь для нашего приложения нужна стартовая HTML-страница. Положим её в dist/index.html :
Документ содержит лишь
Создадим первый JavaScript-файл, который станет входной точкой приложения. Пока что можно просто поместить в него простое логирующее выражение:
Для облегчения процесса создания приложения воспользуемся Webpack и его сервером разработки, добавив их к нашему проекту:
Добавим файл конфигурации Webpack, соответствующий созданным ранее файлам, в корень проекта:
Теперь можно запустить webpack для создания bundle.js :
Далее запустим сервер, после чего тестовая страница станет доступна в localhost:8080 (включая логирующее выражение из index.js ).
Поскольку мы собрались использовать в клиентском коде React JSX синтаксис и ES6, то нам нужна еще пара инструментов. Babel умеет работать с ними обоими, поэтому подключим его и его Webpack-загрузчик:
Включаем в package.json поддержку Babel’ем ES6/ES2015 и React JSX, активируя только что установленные пресеты:
Не будем тратить время на CSS. Если вы хотите сделать приложение красивее, то можете сами добавить в него стили. Либо можете воспользоваться стилями из этого коммита. В дополнение к CSS-файлу будет добавлена Webpack-поддержка для подключения стилей (и автопрефиксов), а также компонент, немного улучшенный для визуализации.
5.1.1. Поддержка модульного тестирования
Для клиентского кода мы тоже будем писать модульные тесты. Для этого воспользуемся теми же библиотеками — Mocha и Chai:
Также будем тестировать и React-компоненты, для чего нам понадобится DOM. В качестве альтернативы можно предложить прогнать в настоящем веб-браузере тесты библиотекой наподобие Karma. Но это не является необходимостью для нас, поскольку мы можем обойтись средствами jsdom, реализацией DOM на чистом JavaScript внутри Node:
Для последней версии jsdom требуется io.js или Node.js 4.0.0. Если вы пользуетесь более старой версией Node, то вам придётся установить и более старый jsdom:
Также мы воспользуемся Immutable коллекциями, поэтому придётся прибегнуть к той же уловке, что и в случае с сервером, чтобы внедрить поддержку Chai. Установим оба пакета — immutable и chai-immutable:
Далее пропишем их в тестовом вспомогательном файле:
Последний шаг перед запуском тестов: добавим в файл package.json команду для их запуска:
5.2. React и react-hot-loader
Инфраструктура Webpack и Babel готова, займемся React!
При построении React-приложений с помощью Redux и Immutable мы можем писать так называемые чистые компоненты (Pure Components, их ещё иногда называют Dumb Components). Идея та же, что и в основе чистых функций, должны соблюдаться два правила:
Но если компонент не может обладать состоянием, то где оно будет находиться? В неизменяемой структуре данных внутри Redux store! Отделить состояние от кода пользовательского интерфейса — отличная идея. React-компоненты представляют собой всего лишь не имеющую состояния проекцию состояния на данный момент времени.
Но не будем забегать вперёд. Добавим React в наш проект:
Также настроим react-hot-loader. Этот инструмент сильно ускорит процесс разработки благодаря перезагрузке кода без потери текущего состояния приложения.
Было бы глупо пренебрегать react-hot-loader, ведь наша архитектура только поощряет его использование. По сути, создание Redux и react-hot-loader — две части одной истории!
Теперь при запуске или рестарте сервера разработки мы увидим в консоли сообщение о включении поддержки горячей замены модулей (Hot Module Replacement).
5.3. Создание пользовательского интерфейса для экрана голосования
Этот экран будет очень простым: пока голосование не завершилось, всегда будут отображаться две кнопки, по одной для каждой из двух записей. А по завершении голосования будет показан победитель.
По большей части пока мы занимались разработкой через тестирование, при создании React-компонентов применим другой подход: сначала пишем компоненты, а затем тесты. Дело в том, что Webpack и react-hot-loader имеют ещё более короткий контур обратной связи, чем модульные тесты. Кроме того, при создании интерфейса нет ничего эффективнее, чем наблюдать его работу своими глазами.
Компонент Voting получает пару записей в виде свойств. Пока что мы эту пару захардкодим, а позднее заменим реальными данными. Компонент чистый, поэтому ему не важно, откуда берутся данные.
Изменим имя стартового файла в webpack.config.js :
Пара записей выводятся в виде кнопок, их можно увидеть в браузере. Попробуйте внести в код компонента какие-нибудь изменения, они немедленно появятся в браузере. Без рестартов и перезагрузок страницы. Это к вопросу о скорости обратной связи.
Если вы видите не то, что ожидаете, то проверьте выходные данные webpack-dev-server, а также лог браузера.
Теперь можно добавить первый модульный тест. Он будет расположен в файле Voting_spec.jsx :
После отрисовки компонента для поиска кнопок можно использовать другую вспомогательную функцию React — scryRenderedDOMComponentsWithTag. Их должно быть две, а что текстовое содержимое элементов должно совпадать с нашими двумя записями.
Запускаем тест и проверяем:
При клике на любую кнопку компонент должен вызвать callback-функцию. Она должна быть передана компоненту в виде свойства, как и пара записей. Добавим в тест соответствующую проверку. Эмулируем клик с помощью объекта Simulate из тестовых утилит React:
Таким образом мы с помощью чистых компонентов будем управлять пользовательским вводом и действиями: компоненты не будут самостоятельно обрабатывать actions, а будут просто вызывать callback-и.
Здесь мы вернулись к разработке через тестирование. В ходе создания интерфейса мы будем ещё не раз переключаться с одного подхода на другой, в зависимости того, что будет полезнее в текущих обстоятельствах.
И допишем компонент голосования:
Когда у нас появится финальный победитель, то отображаться будет только он. Для него мы сделаем другое свойство, значение которого также временно захардкодим:
Можем обработать это в компоненте, отрисовывая div победителя или кнопки выбора в зависимости от значения свойства winner:
Теперь мы получили нужную нам функциональность, но код отрисовки пока выглядит немного неопрятно. Лучше извлечь из него отдельные компоненты, чтобы компонент экрана голосования (vote screen) отрисовывал либо компонент победителя (winner), либо компонент голосования (vote). В случае компонента winner будет отрисовываться просто div:
Компонент голосования будет практически таким же, как и прежде, нужны лишь кнопки голосования:
А сам компонент голосования теперь просто принимает решение, какой из двух компонентов нужно отрисовать:
Обратите внимание, что в компонент победителя добавлен ref. Мы будем использовать его в модульных тестах для получения необходимого DOM-элемента.
У нас готов чистый компонент голосования! Заметьте, мы до сих пор не реализовали никакую логику: есть только кнопки, которые пока ничего не делают, за исключением вызова callback-ов. Компоненты ответственны лишь за отрисовку интерфейса. Позднее мы добавим логику приложения, подключив интерфейс к Redux store.
Теперь напишем ещё несколько модульных тестов для проверки новой функциональности. Наличие свойства hasVoted должно приводить к отключению кнопок голосования:
Label Voted появляется на той кнопке, чья запись совпадает со значением свойства hasVoted :
Когда у нас появляется победитель, то должны отрисовываться не кнопки, а элемент с ref’ом победителя:
Можно было бы написать тесты для каждого компонента в отдельности, но я считаю, что в данном случае правильнее тестировать экран голосования в качестве «модуля». Мы тестируем внешнее поведение компонента, а тот факт, что внутри него есть более мелкие компоненты, это уже детали реализации.
5.4. Неизменяемые данные и чистый рендеринг (Pure Rendering)
Мы уже обсудили основные достоинства неизменяемых данных, но есть ещё одно, крайне практичное преимущество, связанное с их использованием вместе с React. Если в качестве свойств компонента мы будем использовать только неизменяемые данные, а сами компоненты напишем в соответствии с критериями чистоты, то заставим React применять более эффективную стратегию обнаружения изменений в свойствах.
Для этого применим PureRenderMixin из add-on-пакета. Если добавить mixin в компонент, то React станет по-другому проверять свойства (и состояние) компонента на наличие изменений. Сравнение будет не глубоким, а поверхностным, что гораздо быстрее.
Целесообразность этого решения заключается в том, что изменений в immutable структурах быть не может. Так что если свойства компонента есть неизменяемые данные, и они указывают на те же значения между отрисовками, то нет необходимости рендерить компонент заново!
Давайте напишем модульные тесты на этот случай. Предполагается, что у нас чистый компонент, так что если дать ему изменяемый массив, а затем сделать в нём какое-то изменение, то компонент не должен перерисоваться:
Вместо renderIntoDocument мы вручную создаём родительский
и дважды отрисовываем в него, эмулируя перерисовку.
Нужно явно задать в свойствах новый неизменяемый список, чтобы изменения отразились в интерфейсе:
Обычно я не заморачиваюсь написанием подобных тестов, а просто предполагаю использование PureRenderMixin. Но в нашем случае тесты просто помогают разобраться в происходящем. Здесь они демонстрируют, что компонент ведёт себя не так, как ожидается: обновление интерфейса происходит в обоих случаях. Это означает проведение глубоких проверок свойств, чего мы как раз и хотели избежать с помощью неизменяемых данных.
Всё встаёт на свои места после того, как мы включим PureRenderMixin в нашем компоненте. Сначала установим пакет:
После его добавления в компоненты тесты начинают выполняться успешно:
Строго говоря, мы начнём проходить тесты даже при простом включении PureRenderMixin в компоненте голосования, не обращая внимания на остальные два компонента. Дело в том, что когда React не находит изменений в свойствах Voting, то он пропускает перерисовку всего поддерева компонента.
Но всё же правильнее будет последовательно использовать PureRenderMixin во всех компонентах. Во-первых, это подтвердит их чистоту, а во-вторых, их поведение не изменится даже после перегруппирования.
5.5. Создание пользовательского интерфейса для экрана результатов и обработка переходов (Routing Handling)
Закончили с экраном голосования, теперь перейдём к другому важному экрану нашего приложения: к экрану отображения результатов.
В качестве данных для отображения используются те же пары записей, что и на экране голосования, а также текущие счетчики голосов по каждой записи. Внизу добавляется маленькая кнопка, при нажатии которой мы переходим к голосованию по следующей паре.
Получается, что у нас есть два отдельных экрана, и в каждый момент времени должен отображаться один из них. Для выбора конкретного экрана для отображения можно использовать URL’ы. Назначим путь #/ для отображения экрана голосования, а путь #/results — для отображения экрана результатов.
Подобные вещи легко выполняются с помощью библиотеки react-router, благодаря которой можно ассоциировать друг с другом разные компоненты и пути. Добавим её в наш проект:
Задача компонента корневого пути заключается в отрисовке общей для всех разметки. Так должен выглядеть наш корневой компонент App :
Выше мы говорили о том, что лучше использовать PureRenderMixin во всех компонентах. Исключением из этого правила является компонент App: из-за особенностей взаимодействия между роутером и React маршруты могут не измениться. Возможно, в ближайшем будущем ситуация изменится.
Компонент Router из пакета react-router является корневым для нашего приложения, и он будет использовать механизм сохранения истории на базе #hash (в отличие от API для сохранения истории в HTML 5). Передадим ему нашу таблицу соответствий в виде дочернего компонента.
Давайте создадим простую реализацию Results и посмотрим на работу роутинга:
Если открыть в браузере localhost:8080/#/results, то вы увидите сообщение от компонента Results. Корневной маршрут должен отобразить кнопки голосования. С помощью кнопок «вперёд» и «назад» в браузере вы можете переключаться между путями, и отображаемый компонент будет меняться. Вот он, роутер в действии!
Больше в нашем приложении мы ничего не будем делать с помощью роутера React. Хотя у библиотеки куда больше возможностей, можете посмотреть её документацию.
Теперь, когда у нас есть временный компонент Results, давайте заставим его сделать что-то полезное. Пусть он отображает те же две записи, которые сейчас участвуют в голосовании в компоненте Voting:
Раз это экран результатов, то нужно отобразить текущее распределение голосов, ведь именно это люди ожидают увидеть. Передадим в компонент из корневого компонента App временный результат голосования Map:
Теперь настроим компонент Results для отображения этих чисел:
А теперь давайте сменим передачу и добавим модульный тест для текущего поведения компонента Results, чтобы удостовериться, что позднее мы его не сломаем. Компонент должен отрисовывать div’ы для каждой записи, внутри которых отображать имена самих записей и текущее количество голосов. Если за запись никто не проголосовал, то пусть отображается ноль:
Теперь поговорим о кнопке «Next», используемой для перехода к следующему голосованию. С точки зрения компонента, в свойствах должна быть просто callback-функция. Она должна вызываться компонентом, когда внутри него нажимается кнопка «Next». Сформулируем достаточно простой модульный тест, весьма похожий на тот, что мы делали для кнопок голосования:
Реализация во многом схожа с кнопками голосования. Получилось немного проще, поскольку не нужно передавать аргументы:
Как и в случае с экраном голосования, на экране результатов должен отобразиться победитель:
Можно реализовать это с помощью повторного использования компонента Winner, уже разработанного для экрана голосования. Как только у нас определяется финальный победитель, то мы отрисовываем соответствующий компонент вместо стандартного экрана результатов:
Этому компоненту также пошло бы на пользу разделение на более мелкие составляющие. Например, компонент Tally мог бы отображать пары записей. Если вам нравится эта идея, то смело рефакторьте!
И это почти весь интерфейс, необходимый нашему простому приложению. Пока что написанные нами компоненты ничего не делают, потому что не получают реальных данных или действий. Примечательно, как далеко нам удаётся зайти и без этого. Мы даже смогли внедрить в эти компоненты простые заглушки, чтобы сконцентрироваться на структуре интерфейса.
Теперь, когда мы завершили его создание, поговорим о том, как вдохнуть в него жизнь с помощью подключения Redux store к входным и выходным каналам.
5.6. Использование клиентского Redux Store
Redux был спроектирован для использования в качестве контейнера состояний приложений, имеющих пользовательский интерфейс. Наше приложение полностью подходит под этот критерий. Пока что мы использовали Redux только на сервере и выяснили, что он и там очень полезен! Теперь можно посмотреть, как он поведёт себя с React-приложением.
Как и в случае с сервером, давайте сначала продумаем возможные состояния нашего приложения. Отличий будет мало, и это не случайно.
У нас есть интерфейс с двумя экранами. На обоих отображается пара записей, участвующих в голосовании. Имеет смысл сделать состояние vote с парой элементов для голосования:
В то же состояние поместим экран результатов, куда выводятся текущее распределение голосов.
Компонент голосования (Voting) отрисовывается иначе, когда пользователь уже проголосовал в текущей паре. Это также должно отслеживаться состоянием:
Когда появляется финальный победитель, только он и должен присутствовать в состоянии:
Давайте рассмотрим это с точки зрения того, что может изменить состояние выполняющегося приложения. Один источник изменений состояния — действия пользователя. Сейчас в интерфейсе предусмотрено два возможных сценария взаимодействия:
Посмотрим с помощью модульных тестов, как это работает. Получив действие, наподобие приведённого выше, reducer должен объединять его данные с текущим состоянием:
Reducer должен уметь получать от сокета простую JS-структуру данных. Она должна быть преобразована в неизменяемую структуру к моменту своего возвращения в виде следующего значения:
Начальное состояние undefined также должно быть корректно инициализировано reducer-ом в виде неизменяемой структуры:
Таковы наши технические условия. Давайте посмотрим, как их можно выполнить. У нас есть функция-reducer, экспортируемая reducer-модулем:
Обратите внимание, что мы не стали возиться с «основным» модулем, отделённым от reducer-модуля. Это связано с тем, что логика в преобразователе настолько простая, что о ней не нужно волноваться. Просто выполняем слияние, в то время как на сервере находится полная логика системы голосования. Если возникнет необходимость, можно будет позднее и на клиенте разделить функциональности.
У нас осталось ещё две причины изменения состояния, связанные с действиями пользователя: голосование и нажатие кнопки «Next». В обоих случаях подразумевается взаимодействие с сервером, поэтому мы вернёмся к этом чуть позднее, когда разберёмся с архитектурой подключения к серверу.
Пришло время добавить Redux в наш проект:
Store готов. Как нам теперь передать из него данные в React-компоненты?
5.7. Передача входных данных из Redux в React
В Redux Store содержится неизменяемое состояние приложения. У нас есть не имеющие состояния React-компоненты, принимающие неизменяемые данные на вход. Если мы сможем придумать способ надёжно передавать актуальные данные из store в компоненты, то будет замечательно. При сменах состояния React будет перерисовываться, а PureRenderMixin будет следить за тем, чтобы не перерисовывались те части интерфейса, которые не должны.
Вместо самостоятельного написания кода синхронизации, можно использовать Redux React биндинги из пакета react-redux:
react-redux подключает наши чистые компоненты к Redux store с помощью:
Поместим провайдера вокруг компонента-роутера. В результате провайдер станет наследником всех компонентов нашего приложения.
Теперь надо подумать о том, какие из компонентов нужно «подключить», чтобы из store поступали все необходимые данные. У нас есть пять компонентов, которые можно разделить на три категории:
Маппинг-функция осуществляет сопоставление состояния из Redux Store в свойства объекта. Затем эти свойства будут объединены со свойствами подключаемого компонента. В случае с Voting нам всего лишь нужно замапить pair и winner из Store:
Больше ничего менять не требуется. Тесты написаны для чистого компонента Voting, который остаётся неизменным. Мы просто добавили обёртку, чтобы подключить его к store.
В index.jsx изменим путь передачи результатов, вместо Results будет ResultsContainer :
Наконец, в тесте результатов обновим выражение импорта для Results :
Таким вот образом можно подключать чистые React-компоненты к Redux-хранилищу, чтобы они могли получать оттуда нужные данные.
Для очень маленьких приложений, обладающих единственным корневым компонентом и не использующих роутинг, в большинстве случаев будет достаточно подключения корневого компонента. А затем уже корень делегирует эти данные в виде свойств для своих дочерних компонентов.
В приложениях с роутингом, вроде создаваемого нами, обычно лучше подключать каждый из компонентов роутера. Но каждый компонент может быть подключён отдельно, поэтому вы вольны применять разные стратегии в зависимости от архитектуры приложения. На мой взгляд, имеет смысл во всех возможных случаях использовать обычные свойства, поскольку с ними проще понять, какие данные подаются на вход. К тому же вам не придётся разбираться с кодом «подключения».
Итак, мы теперь можем передавать в интерфейс данные из Redux. В App.jsx нам больше не нужны заглушки свойств, так что код упрощается:
5.8. Настройка клиента Socket.io
Поскольку в наш клиент представляет собой Redux-приложение, поговорим о способе подключения к серверному Redux-приложению. На данный момент они оба существуют в своих собственных мирах, никак не взаимодействуя друг с другом.
Сервер уже готов к принятию входящих socket-подключений и передачи им состояния голосования. А у клиента есть Redux-хранилище, в которое можно легко записать входные данные. Осталось только связать их.
Начнём с инфраструктуры. Нам нужно создать Socket.io-канал от браузера к серверу. Для этого воспользуемся библиотекой socket.io-client, являющейся клиентским аналогом библиотеки, которую мы использовали на сервере:
Проверьте, что сервер запущен, откройте в браузере клиентское приложение и просмотрите сетевой трафик. Должно быть установлено WebSocket-подключение, в которое отправляются контрольные сигналы Socket.io.
Во время разработки мы будем использовать на странице два Socket.io-подключения: одно наше, а второе для поддержки горячей Webpack-перезагрузки.
5.9. Получение actions с сервера
Взгляните на интерфейс — голосования или результатов: там должна отображаться первая пара записей, переданных с сервера. Подключение между клиентом и сервером установлено!
5.10. Передача actions от React-компонентов
Мы знаем, как передавать в интерфейс входные данные от Redux store. Давайте теперь поговорим о передаче из интерфейса исходящих действий.
Также имеет смысл не принимать это свойство в состояние, если действие VOTE по какой-то причине приходит с записью, которая в данный момент не участвует в голосовании:
Расширим логику reducer-a для обработки этого случая:
Это можно реализовать сочетанием функции resetVote и обработчика действия SET_STATE :
Эта логика определения актуальности свойства hasVoted для текущей пары имеет изъяны. Обратите внимание на упражнения ниже для улучшения логики.
Пришла пора подключить свойство hasVoted к свойствам Voting :
Нам ещё нужно как-то передать в Voting vote callback, который приведёт к обработке этого нового действия. Voting должен оставаться чистым и независимым от actions или Redux, поэтому задействуем функцию connect из react-redux.
react-redux можно использовать для подключения как входных свойств, так и выходных действий. Но сначала мы задействуем ещё одну ключевую идею Redux: создатели действий (Action creators).
Как мы уже знаем, действия в Redux представляют собой простые объекты, обладающие (по соглашению) атрибутом type и прочими специфическими данными. Мы создавали эти действия по мере надобности с помощью объектных литералов. Но предпочтительнее использовать маленькие фабричные функции наподобие этой:
Такие функции ещё называют «создателями действий». Они являются чистыми функциями, которые всего лишь возвращают объекты действий. Но при этом таким образом инкапсулируют внутреннюю структуру объектов действий, чтобы она больше не была никак связана с остальной кодовой базой. С помощью создателей действий также удобно документировать все действия, которые могут быть переданы в ваше приложение. Было бы труднее собирать подобную информацию, будь она разбросана по всей кодовой базе в виде объектных литералов.
Создадим новый файл, определяющий создателей действий для двух наших уже существующих клиентских действия:
Для этих функций очень легко писать модульные тесты. Но обычно я этого не делаю, если создатель действия только возвращает объект. Но если хотите, то можете добавить.
Теперь в файле index.jsx в Socket.io-обработчике события мы можем использовать создателя действия setState :
Ещё одно преимущество создателей действий заключается том, как react-redux подключает их к React-компонентам. У нас есть callback-свойство vote в Voting и создатель действия vote. Имена одинаковые, как и сигнатуры функции: один аргумент, являющийся записью, за которую проголосовали. Так что мы можем просто передать создателя действия в функцию connect из react-redux в качестве второго аргумента:
5.11. Отправка действий на сервер с помощью Redux Middleware
Теперь нам нужно решить последний вопрос — получения сервером результатов пользовательских действий. Это должно происходить при голосовании и нажатии на кнопку “Next” на экране результатов.
Начнём с голосования. Что у нас уже есть?
С чего начать? В Redux нет ничего подходящего для этого, поскольку в число его основных задач не входит поддержка распределённых систем наподобие нашей. Так что будем сами решать, как нам организовать отправку действий на сервер.
Redux предоставляет шаблонный способ подцепления к actions, отправляемым в redux store — Middleware.
Middleware (посредник) — это функция, которая вызывается при передаче действия ещё до того, как это действие попадёт в reducer и store. Middleware можно использовать в разных целях, от логгирования и обработки исключений до модифицирования действий, кэширования результатов и изменения способа и времени попадания действия в store. Мы же воспользуемся этими функциями для отправки клиентских actions на сервер.
Обратите внимание на разницу между middleware и listeners:
Создадим remote action middleware, благодаря которому с помощью Socket.io-подключения действие будет отправлено не только в первоначальный store, но и в удалённый.
Настроим каркас нашего middleware. Это функция, которая берёт Redux store и возвращает другую функцию, принимающую callback «next». Эта другая функция возвращает третью, принимающую Redux action. Именно эта последняя функция и отражает реализацию middleware:
Вы можете счесть предыдущий код немного странным, но это более конкретный способ записи:
Подобный способ вкладывания одноаргументных функций называется каррированием. В этом случае мы можем легко сконфигурировать посредника: если бы все аргументы содержались в одной функции ( function(store, next, action) < >), то нам пришлось бы передавать их при каждом использовании посредника. А благодаря каррированию мы можем единожды вызвать первую функцию и получить возвращаемое значение, которое «помнит», какой store нужно использовать.
Давайте что-нибудь залоггируем в middleware, чтобы узнать, когда оно вызывается:
Это еще один хороший пример использования каррирования. И его очень активно используют API Redux.
Обратите внимание, что нам нужно поменять местами инициализацию сокета и store: сокет должен создаваться первым, поскольку он нам понадобится в ходе инициализации store.
Осталось только, чтобы middleware сгенерировало событие action :
Вот и всё! Теперь при клике на одну из кнопок голосования вы увидите в том же окне браузера обновление текущих результатов. То же самое произойдёт и в других браузерах, в которых будет запущено приложение. Голос зарегистрирован!
Middleware удалённого action не должен отправлять на сервер каждое действие. Некоторые из них, вроде SET_STATE, должны обрабатываться локально, на клиенте. Пусть посредник отправляет на сервер только конкретные действия, содержащие свойство
(этот подход взят из примера rafScheduler из документации middleware)
Создатель действия для NEXT должен создавать удалённое действие правильного типа:
Создатели действий подключаются в виде свойств к компоненту ResultsContainer :
Ну… вот и всё! Теперь наше приложение полностью готово и функционирует. Попробуйте открыть экран результатов на компьютере и экран голосования на мобильном устройстве. Вы увидите, что после выполнения действия на одном устройстве результат сразу отображается на другом. Волшебное зрелище. Нажмите на кнопку «Next» на экране результатов и посмотрите, как продвигается голосование на другом устройстве.
6. Упражнения
Если вы хотите улучшить это приложение, а заодно получше познакомиться с архитектурой Redux, то можете выполнить несколько упражнений. Под каждым из них есть ссылка на одно из возможных решений.
1. Неправильное предотвращение голосования
Если запись не входит текущую пару, то сервер не должен позволять голосовать за неё. Добавьте провальный модульный тест для иллюстрации проблемы, и исправьте её.
2. Улучшение сброса состояния голосования
Если в новой паре нет записи, которая уже участвовала в голосовании, то клиент сбрасывает свойство hasVoted. Но тут есть проблема: в последних раундах голосования возникают ситуации, когда в двух следующих друг за другом парах есть одинаковая запись, и тогда свойство не сбрасывается. В последнем раунде пользователь не может проголосовать, потому что кнопки неактивны.
Модифицируйте систему так, чтобы она создавала уникальный идентификатор для каждого раунда, а состояние голосования отслеживало бы этот идентификатор.
Подсказка: Можно отслеживать счётчик раундов на сервере. Когда пользователь отдаёт голос, сохраняйте на клиенте номер раунда. При обновлении общего состояния сбрасывайте состояние голосования, если на сервере изменился номер раунда.
3. Предотвращение повторного голосования
В течение одного раунда пользователь всё ещё может проголосовать несколько раз. Достаточно обновить страницу, и состояние голосования теряется. Исправьте это.
Подсказка: Можно генерировать уникальные идентификаторы для каждого пользователя, чтобы сервер мог знать, кто и за что проголосовал. Так что если пользователь снова проголосует, то его предыдущий голос в этом раунде обнуляется. Тогда вы можете не блокировать кнопки голосования, это позволит пользователям менять своё мнение в течение раунда.
4. Перезапуск голосования
Создайте на экране голосования кнопку, позволяющую пользователю запустить голосование с самого начала.
Подсказка: Вам нужно отслеживать в состоянии порядок следования записей, чтобы при сбросе вернуться к началу.
5. Отображение состояния подключения сокета
При неустойчивой связи подключение к Socket.io может прерываться. Добавьте визуальный индикатор, который сообщал бы пользователю об отсутствии с сервером.
Подсказка: Прослушивайте сетевые сообщения от Socket.io и передавайте действия, которые будут класть в Redux-хранилище состояние подключения.
Бонусный вызов: переход в одноранговый режим (Peer to Peer)
Сам я это сделать не пробовал, но тема интересная, если вы любите исследовать. Модифицируйте логику системы таким образом, чтобы вместо отдельных реализаций reducer-ов на клиенте и сервере применялся reducer с полной логикой, исполняемый на каждом клиенте. Передавайте все действия каждому участнику, чтобы все видели одно и то же.