-Поиск по дневнику

Поиск сообщений в predefglas

 -Подписка по e-mail

 

 -Статистика

Статистика LiveInternet.ru: показано количество хитов и посетителей
Создан: 10.12.2014
Записей:
Комментариев:
Написано: 94

[Перевод] Руководство по работе с Redux

Дневник

Пятница, 17 Июня 2016 г. 07:23 + в цитатник
Сегодня 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. Упражнения 1. Что вам понадобится Данное руководство будет наиболее полезным для разработчиков, которые уже умеют писать JavaScript-приложения. Как уже упоминалось, мы будем использовать Node, ES6, React, Webpack и Babel, и если вы хотя бы немного знакомы с этими инструментами, никаких проблем с продвижением не будет. Даже если не знакомы, вы сможете понять основы по пути. В качестве хорошего пособия по разработке веб-приложений с помощью React, Webpack и ES6, можно посоветовать SurviveJS. Что касается инструментов, то вам понадобится Node с NPM и ваш любимый текстовый редактор. 2. Приложение Мы будем делать приложение для «живых» голосований на вечеринках, конференциях, встречах и прочих собраниях. Идея заключается в том, что пользователю будет предлагаться коллекция позиций для голосования: фильмы, песни, языки программирования, цитаты с Horse JS, и так далее. Приложение будет располагать элементы парами, чтобы каждый мог проголосовать за своего фаворита. В результате серии голосований останется один элемент — победитель. Пример голосования за лучший фильм Дэнни Бойла: Приложение будет иметь два разных пользовательских интерфейса: Интерфейс для голосования можно будет использовать на любом устройстве, где запускается веб-браузер. Интерфейс результатов голосования может быть выведен на проектор или какой-то большой экран. Результаты голосования будут обновляться в реальном времени. 3. Архитектура Структурно система будет состоять из двух приложений: Браузерное приложение на React, предоставляющее оба пользовательских интерфейса. Серверное приложение на Node, содержащее логику голосования. Взаимодействие между приложениями будет осуществляться с помощью WebSockets. Redux поможет нам организовать код клиентской и серверной частей. А для хранения состояний будем применять структуры Immutable. Несмотря на большое сходство клиента и сервера — к примеру, оба будут использовать 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-проекта: mkdir voting-server cd voting-server npm init -y В созданной папке пока что лежит одинокий файл package.json. Писать код мы будем в спецификации ES6. Хотя Node начиная с версии 4.0.0 поддерживает много возможностей ES6, необходимые нам модули все же остались за бортом. Поэтому нам нужно добавить в наш проект Babel, чтобы мы могли воспользоваться всей мощью ES6 и транспилировать код в 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). Структура деревьев из предыдущей главы может навести на мысль, что код должен менять состояние приложения просто обновляя деревья: заменяя элементы в ассоциативных массивах, удаляя их из массивов и т.д. Но в 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) и других типов коллекций. Применяемая к нему операция создаёт новое дерево состояния, оставляя предыдущее в неприкосновенности. Если дерево представляет собой ассоциативный массив с ключом movies, содержащим список фильмов, то добавление новой позиции подразумевает необходимость создания нового массива, в котором ключ 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 в ассоциативном массиве состояния присваивает в качестве значения указанный список записей. Получаем первое из спроектированных нами ранее деревьев. 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') })); }); Для удовлетворения этому требованию будем передавать записи в конструктор списка: 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 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) { // Определяет, какую функцию нужно вызвать, и делает это } Теперь нужно убедиться, что наш reducer способен обрабатывать каждое из трёх действий: 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: [] })); }); }); В зависимости от типа действия reducer должен обращаться к одной из функций ядра. Он также должен знать, как извлечь из действия дополнительные аргументы для каждой из функций: 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; } Обратите внимание, что если reducer не распознает действие, то просто вернёт текущее состояние. К reducer-ам предъявляется важное дополнительное требование: если они вызываются с незаданным состоянием, то должны знать, как проинициализировать его правильным значением. В нашем случае исходным значением является ассоциативный массив. Таким образом, состояние 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 можно не передавать всё состояние приложения, ведь она работает только с одноимённым сегментом vote. И только о нём ей достаточно знать. Для отображения этой идеи мы можем модифицировать наши unit тесты для vote: test/core_spec.js describe('vote', () => { it('создаёт результат голосования для выбранной записи', () => { const state = Map({ pair: List.of('Trainspotting', '28 Days Later') }); const nextState = vote(state, 'Trainspotting') expect(nextState).to.equal(Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 1 }) })); }); it('добавляет в уже имеющийся результат для выбранной записи', () => { const state = Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 3, '28 Days Later': 2 }) }); const nextState = vote(state, 'Trainspotting'); expect(nextState).to.equal(Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 4, '28 Days Later': 2 }) })); }); }); Как видите, код теста упростился, а это обычно хороший знак! Теперь реализация vote должна просто брать соответствующий сегмент состояния и обновлять счетчик голосования: src/core.js export function vote(voteState, entry) { return voteState.updateIn( ['tally', entry], 0, tally => tally + 1 ); } Далее reducer должен взять состояние и передать функции vote только необходимую часть. src/reducer.js 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 state.update('vote', voteState => vote(voteState, action.entry)); } return state; } Это лишь небольшой пример подхода, важность которого сильно возрастает с увеличением размера приложения: главная функция-reducer просто передаёт отдельные сегменты состояния reducer-ам уровнем ниже. Мы отделяем задачу поиска нужного сегмента дерева состояний от применения обновления к этому сегменту. Гораздо подробнее шаблоны reducer-композиции рассмотрены в соответствующей секции документации Redux. Также там объясняются некоторые вспомогательные функции, во многих случаях облегчающие использование reducer-композиции. 4.7. Использование Redux Store Теперь, когда у нас есть reducer, можно начать думать, как всё это подключить к Redux. Как мы только что видели, если у вас есть коллекция всех действий, которые когда либо будут иметь место в вашем приложении, что вы можете просто вызвать reduce и получить на выходе финальное состояние приложения. Конечно, обычно у вас нет такой коллекции. Действия осуществляются постепенно, по мере возникновения разных событий: когда пользователь взаимодействует с приложением, когда данные приходят из сети, по триггеру таймаута. Приспособиться к ситуации помогает хранилище — Redux Store. Как подсказывает логика, это объект, в котором хранится состояние нашего приложения. Хранилище инициализируется reducer-функцией, наподобие уже реализованной нами: import {createStore} from 'redux'; const store = createStore(reducer); Далее можно передать (dispatch) действия в store, который затем воспользуется reducer-ом для применения этих действий к текущему состоянию. В качестве результата этой процедуры мы получим следующее состояние, которое будет находиться в Redux-Store. store.dispatch({type: 'NEXT'}); Вы можете получить из хранилища текущее состояние в любой момент времени: store.getState(); Давайте настроим и экспортируем Redux Store в файл store.js. Но сначала протестируем: нам нужно создать хранилище, считать его начальное состояние, передать action и наблюдать изменённое состояние: test/store_spec.js import {Map, fromJS} from 'immutable'; import {expect} from 'chai'; import makeStore from '../src/store'; describe('store', () => { it('хранилище сконфигурировано с помощью правильного преобразователя', () => { const store = makeStore(); expect(store.getState()).to.equal(Map()); store.dispatch({ type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later'] }); expect(store.getState()).to.equal(fromJS({ entries: ['Trainspotting', '28 Days Later'] })); }); }); Перед созданием Store нам нужно добавить Redux в проект: npm install --save redux Теперь можно создавать store.js, в котором вызовем createStore с нашим reducer-ом: src/store.js import {createStore} from 'redux'; import reducer from './reducer'; export default function makeStore() { return createStore(reducer); } Итак, Redux Store соединяет части нашего приложения в целое, которое можно использовать как центральную точку — здесь находится текущее состояние, сюда приходят actions, которые переводят приложение из одного состояния в другое с помощью логики ядра, транслируемой через reducer. Вопрос: Сколько переменных в Redux-приложении вам нужно? Ответ: Одна. Внутри хранилища. На первый взгляд это звучит странно. По крайне мере, если у вас не так много опыта в функциональном программировании. Как можно сделать хоть что-то полезное всего лишь с одной переменной? Но больше нам и не нужно. Текущее дерево состояний — единственная вещь, которая изменяется со временем в нашем базовом приложении. Всё остальное — это константы и неизменяемые значения. Примечательно, насколько мала площадь соприкосновения между кодом нашего приложения и Redux. Благодаря тому, что у нас есть шаблонная reducer-функция, нам достаточно уведомить Redux лишь о ней. А всё остальное есть в нашем собственном, не зависящем от фреймворка, портируемом и исключительно функциональном коде! Если мы теперь создадим входную точку нашего приложения — index.js, то сможем создать и экспортировать Store: index.js import makeStore from './src/store'; export const store = makeStore(); А раз уж мы его экспортировали, то можем теперь завести и Node REPL (например, с помощью babel-node), запросить файл index.js и взаимодействовать с приложением с помощью Store. 4.8. Настройка сервера Socket.io Наше приложение будет работать в качестве сервера для другого браузерного приложения, имеющего пользовательский интерфейс для голосования и просмотра результатов. Нам нужно организовать взаимодействие клиентов с сервером, и наоборот. Наше приложение только выиграет от внедрения общения в реальном времени, поскольку пользователям понравится сразу же наблюдать результаты своих действий и действий других. Для этой цели давайте воспользуемся WebSocket’ами. Точнее, возьмём библиотеку Socket.io, предоставляющую хорошую абстракцию для работающих в браузерах WebSocket’ов. К тому же тут есть и несколько запасных механизмов для клиентов, не поддерживающих WebSocket’ы. Добавляем Socket.io в проект: npm install --save socket.io Создаём файл server.js, экспортирующий функцию создания сервера Socket.io: src/server.js import Server from 'socket.io'; export default function startServer() { const io = new Server().attach(8090); } Этот код создает сервер Socket.io, а также поднимает на порте 8090 обычный HTTP-сервер. Порт выбран произвольно, он должен совпадать с портом, который позднее будет использоваться для связи с клиентами. Теперь вызовем эту функцию из index.js, и сервер будет запущен с началом работы приложения: index.js import makeStore from './src/store'; import startServer from './src/server'; export const store = makeStore(); startServer(); Можно немного упростить процедуру запуска, добавив команду start в наш package.json: package.json "scripts": { "start": "babel-node index.js", "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js --recursive", "test:watch": "npm run test -- --watch" }, Теперь после ввода следующей команды будет запускаться сервер и создаваться Redux-Store: npm run start Команда babel-node взята из ранее установленного нами пакета babel-cli. Она позволяет легко запускать Node-код с включённой поддержкой Babel-транспилирования. В целом, это не рекомендуется делать для боевых серверов, потому что производительность несколько снижается. Но зато хорошо подходит для наших учебных задач. 4.9. Трансляция Store из Redux Listener Теперь у нас есть сервер Socket.io и контейнер Redux состояния, но они пока никак не интегрированы. Изменим это. Сервер должен сообщать клиентам о текущем состоянии приложения (например, «за что сейчас голосуем?», «каков текущий результат?», «есть ли уже победитель?»). Это можно делать при каждом изменении с помощью передачи события из Socket.io всем подключённым клиентам. А как узнать, что что-то изменилось? Для этого можно подписаться на Redux store, предоставив функцию, которая будет вызываться хранилищем при каждом применении action, когда состояние потенциально изменилось. По сути, это callback на изменения состояния внутри store. Мы будем делать это в startServer, так что предоставим ему Redux store для начала: index.js import makeStore from './src/store'; import {startServer} from './src/server'; export const store = makeStore(); startServer(store); Подпишем получателя событий (listener) на наше хранилище. Он считывает текущее состояние, превращает его в простой JavaScript-объект и передаёт его на сервер Socket.io в виде события state. В результате мы получаем JSON-сериализованный снэпшот состояния, рассылаемый на все активные подключения Socket.io. src/server.js import Server from 'socket.io'; export function startServer(store) { const io = new Server().attach(8090); store.subscribe( () => io.emit('state', store.getState().toJS()) ); } Теперь при каждом изменении мы передаём полное состояние всем клиентам. Но это может повлечь за собой серьёзный рост трафика. Можно предложить различные способы оптимизации (например, отправлять только актуальную часть состояния, отправлять дифы вместо снэпшотов, и т.д.). В нашей реализации не будем этого делать в целях сохранения простоты кода. Помимо передачи снэпшота состояния было бы хорошо, если бы клиенты немедленно получали текущее состояние при подключении к серверу. Это позволит сразу синхронизировать состояние клиентских приложений с текущим состоянием сервера. На сервере Socket.io мы можем слушать события connection, передаваемые клиентами при каждом подключении. В обработчике события мы можем сразу отдавать текущее состояние: src/server.js import Server from 'socket.io'; export function startServer(store) { const io = new Server().attach(8090); store.subscribe( () => io.emit('state', store.getState().toJS()) ); io.on('connection', (socket) => { socket.emit('state', store.getState().toJS()); }); } 4.10. Получение Remote Redux Actions Вдобавок к передаче клиентам состояния приложения, нам нужно уметь получать от них обновления: пользователи будут голосовать, а модуль управления голосованием будет обрабатывать события с помощью действия NEXT. Для этого достаточно напрямую скармливать в Redux store события action, генерируемые клиентами. src/server.js import Server from 'socket.io'; export function startServer(store) { const io = new Server().attach(8090); store.subscribe( () => io.emit('state', store.getState().toJS()) ); io.on('connection', (socket) => { socket.emit('state', store.getState().toJS()); socket.on('action', store.dispatch.bind(store)); }); } Здесь мы уже выходим за рамки «стандартного Redux», потому что фактически принимаем в store удалённые (remote) actions. Но архитектура Redux вовсе не мешает нам: действия являются JavaScript-объектами, которые можно легко посылать по сети, поэтому мы сразу получаем систему, в которой принимать участие в голосовании может любое количество клиентов. А это большой шаг! Конечно, с точки зрения безопасности здесь есть ряд моментов, ведь мы позволяем любому клиенту, подключившемуся к Socket.io, отправлять любое действие в Redux store. Поэтому в реальных проектах нужно использовать что-то вроде файрвола, наподобие Vert.x Event Bus Bridge. Также файрвол нужно внедрять в приложения с механизмом аутентификации. Теперь наш сервер работает следующим образом: Клиент отправляет на сервер какое-то действие (action). Сервер пересылает его в Redux store. Store вызывает reducer, который исполняет логику, связанную с этим action. Store обновляет состояние на основании возвращаемого reducer-ом значения. Store исполняет соответствующий listener, подписанный сервером. Сервер генерирует событие state. Все подключённые клиенты — включая того, кто инициировал первоначальное действие — получают новое состояние. Прежде, чем мы закончим работу над сервером, давайте загрузим в него тестовый набор записей, чтобы посмотреть, как работает система. Записи можно поместить в файл entries.json. Пусть это будет список фильмов Дэнни Бойла. entries.json [ "Shallow Grave", "Trainspotting", "A Life Less Ordinary", "The Beach", "28 Days Later", "Millions", "Sunshine", "Slumdog Millionaire", "127 Hours", "Trance", "Steve Jobs" ] Далее просто загружаем список в index.js, а затем запускаем голосование с помощью действия NEXT: index.js import makeStore from './src/store'; import {startServer} from './src/server'; export const store = makeStore(); startServer(store); store.dispatch({ type: 'SET_ENTRIES', entries: require('./entries.json') }); store.dispatch({type: 'NEXT'}); Теперь можно перейти к клиентскому приложению. 5. Клиентское приложение Далее мы будем писать React-приложение, которое подключается к серверу и позволяет пользователям голосовать. И здесь мы тоже воспользуемся Redux. Собственно, это одно из наиболее распространённых его применений: в качестве движка в основании React-приложений. Мы уже познакомились с его работой, и скоро узнаем, как он совмещается с React и какое оказывает влияние на архитектуру. Рекомендую писать приложение с нуля, но можете скачать код с GitHub. 5.1. Настройка клиентского проекта В первую очередь мы создадим свежий NPM-проект, как мы это делали в случае с сервером. mkdir voting-client cd voting-client npm init –y Теперь для нашего приложения нужна стартовая HTML-страница. Положим её в dist/index.html: dist/index.html <!DOCTYPE html> <html> <body> <div src="bundle.js"></script> </body> </html> Документ содержит лишь <div> с ID app, сюда мы и поместим наше приложение. В ту же папку надо будет положить и файл bundle.js. Создадим первый JavaScript-файл, который станет входной точкой приложения. Пока что можно просто поместить в него простое логирующее выражение: src/index.js console.log('I am alive!'); Для облегчения процесса создания приложения воспользуемся Webpack и его сервером разработки, добавив их к нашему проекту: npm install --save-dev webpack webpack-dev-server Если вы их пока не устанавливали, стоит установить эти же пакеты глобально, чтобы можно было удобно запускать всё необходимое из командной строки: npm install -g webpack webpack-dev-server. Добавим файл конфигурации Webpack, соответствующий созданным ранее файлам, в корень проекта: webpack.config.js module.exports = { entry: [ './src/index.js' ], output: { path: __dirname + '/dist', publicPath: '/', filename: 'bundle.js' }, devServer: { contentBase: './dist' } }; Он обнаружит нашу входную точку index.js и встроит всё необходимое в бандл dist/bundle.js. Папка dist будет базовой и для сервера разработки. Теперь можно запустить Webpack для создания bundle.js: Webpack Далее запустим сервер, после чего тестовая страница станет доступна в localhost:8080 (включая логирующее выражение из index.js). webpack-dev-server Поскольку мы собрались использовать в клиентском коде React JSX синтаксис и ES6, то нам нужна еще пара инструментов. Babel умеет работать с ними обоими, поэтому подключим его и его Webpack-загрузчик: npm install --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react Включаем в package.json поддержку Babel’ем ES6/ES2015 и React JSX, активируя только что установленные пресеты: package.json "babel": { "presets": ["es2015", "react"] } Теперь изменим конфигурационный файл Webpack, чтобы он мог найти .jsx и .js файлы и обработать их с помощью Babel: webpack.config.js module.exports = { entry: [ './src/index.js' ], module: { loaders: [{ test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel' }] }, resolve: { extensions: ['', '.js', '.jsx'] }, output: { path: __dirname + '/dist', publicPath: '/', filename: 'bundle.js' }, devServer: { contentBase: './dist' } }; Не будем тратить время на CSS. Если вы хотите сделать приложение красивее, то можете сами добавить в него стили. Либо можете воспользоваться стилями из этого коммита. В дополнение к CSS-файлу будет добавлена Webpack-поддержка для подключения стилей (и автопрефиксов), а также компонент, немного улучшенный для визуализации. 5.1.1. Поддержка модульного тестирования Для клиентского кода мы тоже будем писать модульные тесты. Для этого воспользуемся теми же библиотеками — Mocha и Chai: npm install --save-dev mocha chai Также будем тестировать и React-компоненты, для чего нам понадобится DOM. В качестве альтернативы можно предложить прогнать в настоящем веб-браузере тесты библиотекой наподобие Karma. Но это не является необходимостью для нас, поскольку мы можем обойтись средствами jsdom, реализацией DOM на чистом JavaScript внутри Node: npm install --save-dev jsdom Для последней версии jsdom требуется io.js или Node.js 4.0.0. Если вы пользуетесь более старой версией Node, то вам придётся установить и более старый jsdom: npm install --save-dev jsdom@3 Также нам понадобится несколько строк настройки jsdom для использования React. В частности, создадим jsdom-версии объектов document и window, предоставляемых браузером. Затем положим их в глобальный объект, чтобы React мог найти их, когда будет обращаться к document или window. Для этой настройки подготовим вспомогательный тестовый файл: test/test_helper.js import jsdom from 'jsdom'; const doc = jsdom.jsdom('<!doctype html><html><body></body></html>'); const win = doc.defaultView; global.document = doc; global.window = win; Кроме того, нам нужно взять все свойства, содержащиеся в jsdom-объекте window (например, navigator), и добавить их в объект global в Node.js. Это делается для того, чтобы предоставляемые объектом window свойства можно было использовать без префикса window., как это происходит в браузерном окружении. От этого зависит часть кода внутри React: test/test_helper.js import jsdom from 'jsdom'; const doc = jsdom.jsdom('<!doctype html><html><body></body></html>'); const win = doc.defaultView; global.document = doc; global.window = win; Object.keys(window).forEach((key) => { if (!(key in global)) { global[Перевод] Руководство по работе с Redux = window[Перевод] Руководство по работе с Redux; } }); Также мы воспользуемся Immutable коллекциями, поэтому придётся прибегнуть к той же уловке, что и в случае с сервером, чтобы внедрить поддержку Chai. Установим оба пакета — immutable и chai-immutable: npm install --save immutable npm install --save-dev chai-immutable Далее пропишем их в тестовом вспомогательном файле: test/test_helper.js import jsdom from 'jsdom'; import chai from 'chai'; import chaiImmutable from 'chai-immutable'; const doc = jsdom.jsdom('<!doctype html><html><body></body></html>'); const win = doc.defaultView; global.document = doc; global.window = win; Object.keys(window).forEach((key) => { if (!(key in global)) { global[Перевод] Руководство по работе с Redux = window[Перевод] Руководство по работе с Redux; } }); chai.use(chaiImmutable); Последний шаг перед запуском тестов: добавим в файл package.json команду для их запуска: package.json "scripts": "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js \"test/**/*@(.js, Почти такую же команду мы использовали в серверном package.json. Разница лишь в спецификации тестового файла: на сервере мы использовали --recursive, но в этом случае не будут обнаруживаться .jsx-файлы. Для возмодности найти и .js, и .jsx-файлы используем glob. Было бы удобно непрерывно прогонять тесты при любых изменениях в коде. Для этого можно добавить команду test:watch, идентичную применяемой на сервере: package.json "scripts": jsx)'", "test:watch": "npm run test -- --watch", 5.2. React и react-hot-loader Инфраструктура Webpack и Babel готова, займемся React! При построении React-приложений с помощью Redux и Immutable мы можем писать так называемые чистые компоненты (Pure Components, их ещё иногда называют Dumb Components). Идея та же, что и в основе чистых функций, должны соблюдаться два правила: Чистый компонент получает все данные в виде свойств, как функция получает данные в виде аргументов. Не должно быть никаких побочных эффектов — чтения данных откуда либо, инициации сетевых запросов и т.д. В целом у чистого компонента нет внутреннего состояния. Отрисовка зависит исключительно от входных свойств. Если дважды что-то отрисовать с помощью одного компонента, имеющего одни и те же свойства, то в результате мы получим один и тот же интерфейс. У компонента нет скрытого состояния, которое может повлиять на процесс отрисовки. Использование чистых компонентов упрощает код, как и использование чистых функций: мы можем понять, что делает компонент, посмотрев на его входные данные и результат отрисовки. Больше нам ничего не нужно знать о компоненте. Тестировать его также не сложно, почти как и тестировать логику приложения но основе чистых функций. Но если компонент не может обладать состоянием, то где оно будет находиться? В неизменяемой структуре данных внутри Redux store! Отделить состояние от кода пользовательского интерфейса — отличная идея. React-компоненты представляют собой всего лишь не имеющую состояния проекцию состояния на данный момент времени. Но не будем забегать вперёд. Добавим React в наш проект: npm install --save react react-dom Также настроим react-hot-loader. Этот инструмент сильно ускорит процесс разработки благодаря перезагрузке кода без потери текущего состояния приложения. npm install --save-dev react-hot-loader Было бы глупо пренебрегать react-hot-loader, ведь наша архитектура только поощряет его использование. По сути, создание Redux и react-hot-loader — две части одной истории! Для поддержки этого загрузчика сделаем несколько обновлений в webpack.config.js. Вот что получилось: webpack.config.js var webpack = require('webpack'); module.exports = { entry: [ 'webpack-dev-server/client?http://localhost:8080', 'webpack/hot/only-dev-server', './src/index.js' ], module: { loaders: [{ test: /\.jsx?$/, exclude: /node_modules/, loader: 'react-hot!babel' }] }, resolve: { extensions: ['', '.js', '.jsx'] }, output: { path: __dirname + '/dist', publicPath: '/', filename: 'bundle.js' }, devServer: { contentBase: './dist', hot: true }, plugins: [ new webpack.HotModuleReplacementPlugin() ] }; В секцию entry включены две новые вещи для входных точек нашего приложения: клиентская библиотека от Webpack сервера разработки и загрузчик модулей (hot module loader) Webpack. Благодаря этому мы сможем использовать инфраструктуру Webpack для горячей замены модулей. По умолчанию такая замена не поддерживается, поэтому в секции plugins придётся подгружать соответствующий плагин и активировать поддержку в секции devServer. В секции loaders мы настраиваем загрузчик react-hot, чтобы он наряду с Babel мог работать с файлами .js и .jsx. Теперь при запуске или рестарте сервера разработки мы увидим в консоли сообщение о включении поддержки горячей замены модулей (Hot Module Replacement). 5.3. Создание пользовательского интерфейса для экрана голосования Этот экран будет очень простым: пока голосование не завершилось, всегда будут отображаться две кнопки, по одной для каждой из двух записей. А по завершении голосования будет показан победитель. По большей части пока мы занимались разработкой через тестирование, при создании React-компонентов применим другой подход: сначала пишем компоненты, а затем тесты. Дело в том, что Webpack и react-hot-loader имеют ещё более короткий контур обратной связи, чем модульные тесты. Кроме того, при создании интерфейса нет ничего эффективнее, чем наблюдать его работу своими глазами. Допустим, нам нужно создать компонент Voting и рендерить его в качестве входной точки приложения. Можно смонтировать его в div #app, который ранее был добавлен в index.html. И придётся переименовать index.js в index.jsx, ведь теперь он содержит JSX-разметку: src/index.jsx import React from 'react'; import ReactDOM from 'react-dom'; import Voting from './components/Voting'; const pair = ['Trainspotting', '28 Days Later']; ReactDOM.render( <Voting app') ); Компонент Voting получает пару записей в виде свойств. Пока что мы эту пару захардкодим, а позднее заменим реальными данными. Компонент чистый, поэтому ему не важно, откуда берутся данные. Изменим имя стартового файла в webpack.config.js: webpack.config.js entry: [ 'webpack-dev-server/client?http://localhost:8080', 'webpack/hot/only-dev-server', './src/index.jsx' ], Теперь при запуске или рестарте webpack-dev-server мы увидим сообщение об отсутствии компонента Voting. Напишем его первую версию: src/components/Voting.jsx import React from 'react'; export default React.createClass([];, render: function() { return <div > Пара записей выводятся в виде кнопок, их можно увидеть в браузере. Попробуйте внести в код компонента какие-нибудь изменения, они немедленно появятся в браузере. Без рестартов и перезагрузок страницы. Это к вопросу о скорости обратной связи. Если вы видите не то, что ожидаете, то проверьте выходные данные webpack-dev-server, а также лог браузера. Теперь можно добавить первый модульный тест. Он будет расположен в файле Voting_spec.jsx: test/components/Voting_spec.jsx import Voting from '../../src/components/Voting'; describe('Voting', () => { }); Для проверки отрисовки кнопок по свойству pair, нужно отрендерить компонент и проверить результат. Для этого воспользуемся вспомогательной функцией renderIntoDocument из пакета тестовых утилит React, который сначала нужно установить: npm install --save react-addons-test-utils test/components/Voting_spec.jsx import React from 'react'; import ReactDOM from 'react-dom'; import { renderIntoDocument } from 'react-addons-test-utils'; import Voting from '../../src/components/Voting'; describe('Voting', () => { it('renders a pair of buttons', () => { const component = renderIntoDocument( <Voting > После отрисовки компонента для поиска кнопок можно использовать другую вспомогательную функцию React — scryRenderedDOMComponentsWithTag. Их должно быть две, а что текстовое содержимое элементов должно совпадать с нашими двумя записями. test/components/Voting_spec.jsx import React from 'react'; import ReactDOM from 'react-dom'; import { renderIntoDocument, scryRenderedDOMComponentsWithTag } from 'react-addons-test-utils'; import Voting from '../../src/components/Voting'; import {expect} from 'chai'; describe('Voting', () => { it('renders a pair of buttons', () => { const component = renderIntoDocument( <Voting button'); expect(buttons.length).to.equal(2); expect(buttons[0].textContent).to.equal('Trainspotting'); expect(buttons[1].textContent).to.equal('28 Days Later'); }); }); Запускаем тест и проверяем: npm run test При клике на любую кнопку компонент должен вызвать callback-функцию. Она должна быть передана компоненту в виде свойства, как и пара записей. Добавим в тест соответствующую проверку. Эмулируем клик с помощью объекта Simulate из тестовых утилит React: test/components/Voting_spec.jsx import React from 'react'; import ReactDOM from 'react-dom'; import { renderIntoDocument, scryRenderedDOMComponentsWithTag, Simulate } from 'react-addons-test-utils'; import Voting from '../../src/components/Voting'; import {expect} from 'chai'; describe('Voting', () => { // ... it('invokes callback when a button is clicked', () => { let votedWith; const vote = (entry) => votedWith = entry; const component = renderIntoDocument( <Voting button'); Simulate.click(buttons[0]); expect(votedWith).to.equal('Trainspotting'); }); }); Написать этот тест не сложно. Для кнопок нам лишь нужен обработчик onClick, вызывающий vote с правильной записью: src/components/Voting.jsx import React from 'react'; export default React.createClass([];, render: function() [];, isDisabled: function() { return !!this.props.hasVoted; }, render: function() return <div > Добавим небольшой label на кнопку, который будет становиться видимым при получении свойства hasVoted. Сделаем вспомогательный метод hasVotedFor, который будет решать, нужно ли его отрисовывать: src/components/Voting.jsx import React from 'react'; export default React.createClass( getPair: function() return this.props.pair, isDisabled: function() { return !!this.props.hasVoted; }, hasVotedFor: function(entry) , isDisabled: function() { return !!this.props.hasVoted; }, hasVotedFor: function(entry) [];, isDisabled: function() { return !!this.props.hasVoted; }, hasVotedFor: function(entry) { return this.props.hasVoted > А сам компонент голосования теперь просто принимает решение, какой из двух компонентов нужно отрисовать: src/components/Voting.jsx import React from 'react'; import Winner from './Winner'; import Vote from './Vote'; export default React.createClass({ render: function() { return <div> {this.props.winner ? <Winner > Обратите внимание, что в компонент победителя добавлен ref. Мы будем использовать его в модульных тестах для получения необходимого DOM-элемента. У нас готов чистый компонент голосования! Заметьте, мы до сих пор не реализовали никакую логику: есть только кнопки, которые пока ничег

Метки:  

 Страницы: [1]