Керівництво по роботі з Redux

Redux

Сьогодні Redux – це одне з найцікавіших явищ світу JavaScript. Він виділяється із сотні бібліотек і фреймворків тим, що грамотно вирішує безліч різних питань шляхом введення простий і передбачуваною моделі станів, спрямщвані на функціональне програмування і незмінні дані, надання компактного API. Що ще потрібно для щастя? Redux – бібліотека дуже маленька, і вивчити її API не складно. Але у багатьох людей відбувається своєрідний розрив шаблону – невелика кількість компонентів і добровільні обмеження чистих функцій і незмінних даних можуть здатися невиправданим примусом. Яким саме чином працювати в таких умовах?

У цьому керівництві ми розглянемо створення з нуля full-stack додатки з використанням Redux і Immutable-js. Застосувавши підхід TDD, пройдемо всі етапи конструювання Node + Redux бекенд і React + Redux фронтенд додатку. Крім цього ми будемо використовувати такі інструменти, як ES6, BabelSocket.ioWebpack і Mocha.

1. Що вам знадобиться

Даний посібник буде найбільш корисним для розробників, які вже вміють писати JavaScript-додатки. Як уже згадувалося, ми будемо використовувати Node, ES6, React , Webpack і Babel , і якщо ви хоча б трохи знайомі з цими інструментами, ніяких проблем з просуванням не буде.

В якості гарної допомоги по розробці веб-додатків за допомогою React, Webpack і ES6, можна порадити SurviveJS . Що стосується інструментів, то вам знадобиться Node з NPM і ваш улюблений текстовий редактор.

2. Додаток

Ми будемо робити додаток для «живих» голосувань на вечірках, конференціях, зустрічах та інших зборах. Ідея полягає в тому, що користувачеві буде пропонуватися колекція позицій для голосування: фільми, пісні, мови програмування, цитати з Horse JS , і так далі. Додаток буде мати у своєму розпорядженні пари елементів, щоб кожен міг проголосувати за свого фаворита. В результаті серії голосувань залишиться один елемент – переможець. Приклад голосування за кращий фільм Денні Бойла:

Redux

Додаток буде мати два різних призначених для користувача інтерфейсу:

  • Інтерфейс для голосування можна буде використовувати на будь-якому пристрої, де запускається веб-браузер.
  • Інтерфейс результатів голосування може бути виведений на проектор або якийсь великий екран. Результати голосування будуть оновлюватися в реальному часі.

app

3. Архітектура

Структурно система буде складатися з двох додатків:

  • Браузерний додаток на React, що надає обидва призначених для користувача інтерфейси.
  • Серверний додаток на Node, що містить логіку голосування.

Взаємодія між додатками буде здійснюватися за допомогою WebSockets. Redux допоможе нам організувати код клієнтської і серверної частин. А для зберігання станів будемо застосовувати структури Immutable .

Незважаючи на велику схожість клієнта і сервера – наприклад, обидва будуть використовувати Redux, – це не універсальний / изоморфний додаток , і додатки не будуть спільно використовувати будь-якої код. Швидше це можна охарактеризувати як розподілену систему з двох додатків, що взаємодіють один з одним за допомогою передачі повідомлень.

4. Серверний додаток

Спочатку напишемо Node-додаток, а потім – React. Це дозволить нам не відволікатися від реалізації базової логіки додатка, перш ніж ми перейдемо до інтерфейсу. Оскільки ми створюємо серверний додаток, будемо знайомитися з Redux і Immutable і дізнаємося, як буде влаштовано побудований на них додаток. Зазвичай Redux асоціюється з React-проектами, але його застосування зовсім ними не обмежується. Зокрема, ми дізнаємося, наскільки Redux може бути корисний і в інших контекстах!

4.1. Розробка дерева станів додатку

Створення програми за допомогою Redux часто починається з продумування структури даних стану програми (application state) . З її допомогою описується, що відбувається в додатку в кожен момент часу. Стан (state) є у будь-якого фреймворка і архітектури. У додатках на базі Ember і Backbone стан зберігається в моделях (Models). У додатках на базі Angular стан найчастіше зберігається в фабриках (Factories) і сервісах (Services). У більшості Flux-додатків стан є сховищем (Stores). А як це зроблено в Redux?

Головна його відмінність в тому, що їхній стан додатку зберігаються в єдиній структурі дерева. Таким чином все, що необхідно знати про стан додатку, міститься в одній структурі даних з асоціативних (map) та звичайних масивів. Як ви незабаром побачите, у цього рішення є чимало наслідків. Одним з найважливіших є те, що ви можете відокремити стан додатку від його поведінки . Стан – це чисті дані. Він не містить ніяких методів або функцій, і він не схований у середину інших об’єктів. Все знаходиться в одному місці. Це може здатися обмеженням, особливо якщо у вас є досвід об’єктно-орієнтованого програмування. Але насправді це прояв більшої свободи, оскільки ви можете сконцентруватися на одних лише даних. Дуже багато логічного витече з проектування станів додатки якщо ви приділите цьому достатньо часу.

Я не хочу сказати, що вам завжди потрібно спочатку повністю розробляти дерево станів, а потім створювати інші компоненти програми. Зазвичай це роблять паралельно. Але мені здається, що корисніше спочатку в загальних рисах уявити собі, як має виглядати дерево в різних ситуаціях, перш ніж приступати до написання коду. Давайте уявимо, яким може бути дерево станів для нашого застосування голосувань. Мета програми – мати можливість голосувати всередині пар об’єктів (фільми, музичні групи). В якості початкового стану програми доцільно зробити просто колекцію з позицій, які братимуть участь в голосуванні. Назвемо цю колекцію entries (записи) :

entries

Після початку голосування потрібно якось відокремити позиції, які беруть участь в голосуванні в даний момент. У стані може бути сутність vote , що містить пару позицій, з яких користувач повинен вибрати одну. Природно, ця пара повинна бути залучена з колекції entries:

Entries

Також нам потрібно вести облік результатів голосування. Це можна робити за допомогою іншої структури всередині vote:

Entries

По завершенні поточного голосування запис що програв викидається, а який переміг повертається назад в entries і поміщається в кінець списку. Пізніше він знову братиме участь у голосуванні. Потім зі списку береться наступна пара:

entries

Ці стани циклічно змінюють один одного до тих пір, поки в колекції є записи. В кінці залишиться тільки один запис, який оголошується переможцем, а голосування завершується:

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) . Це означає, що поки дерево існує, воно не змінюється. Воно завжди зберігає один і той же стан. І перехід до іншого стану здійснюється за допомогою створення іншого дерева, в яке внесені необхідні зміни. Тобто два наступних один за одним states програми зберігаються в двох окремих і незалежних деревах. А перемикання між деревами здійснюється за допомогою виклику функції , що приймає поточний стан і повертає наступне.

tree

Чи хороша це ідея? Зазвичай відразу вказують на те, що якщо їхні states зберігаються в одному дереві і ви вносите всі ці безпечні поновлення, то можна без особливих зусиль зберігати історію states додатку. Це дозволяє реалізувати undo / redo “безкоштовно” – можна просто поставити попереднє або наступне state (дерево) з історії. Також можна серіалізовать історію і зберегти її на майбутнє, або помістити її в сховище для подальшого програвання, що може надати неоціненну допомогу в налагодженні.

Але мені здається, що, крім усіх цих додаткових можливостей, головна перевага використання незмінних даних полягає в спрощенні коду. Вам доводиться програмувати чисті функції: Вони тільки приймають і повертають дані, і більше нічого. Ці функції поводяться передбачувано. Ви можете викликати їх скільки завгодно разів, і вони завжди будуть вести себе однаково. Давайте їм одні і ті ж аргументи, і будете отримувати одні і ті ж результати. Тестування стає тривіальним, адже вам не потрібно налаштовувати заглушки або інші фальшивки, щоб «підготувати всесвіт» до виклику функції. Є просто вхідні і вихідні дані.

Оскільки ми будемо описувати стан нашого застосування незмінними структурами, давайте витратимо трохи часу на знайомство з ними, написавши кілька 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, тому застосовуємо ту ж семантику, що і в попередньому прикладі з числами.

Ця ідея також добре застосовна і до повноцінних деревах станів. Дерево є вкладеною структурою списків (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 === 1) {
    return state.remove('vote')
                .remove('entries')
                .set('winner', entries.first());
  } else {
    return state.merge({
      vote: Map({pair: entries.take(2)}),
      entries: entries.skip(2)
    });
  }
}

Тут можна було б просто повернути Map({winner: entries.first()}). Але замість цього ми знову беремо старий стан і явно прибираємо з нього ключі vote і entries. Це робиться з прицілом на майбутнє: може статися так, що в нашому стані з’являться якісь сторонні дані, які потрібно буде в незмінному вигляді передати за допомогою цієї функції. В цілому, в основі функцій трансформування станів лежить гарна ідея – завжди перетворювати старе стан в нове, замість створення нового стану з нуля.

Тепер у нас є цілком прийнятна версія основний логіки нашого застосування, виражена у вигляді декількох функцій. Також ми написали для них unit тести, які далися нам досить легко: ніяких преднастроек і заглушок. В цьому і проявляється краса чистих функцій. Можна просто викликати їх і перевірити повернені значення.

Зверніть увагу, що ми поки ще навіть не встановили Redux. При цьому спокійно займалися розробкою логіки додатка, не привертаючи «фреймворк» до цього завдання. Є в цьому щось чортовски приємне.

4.5. Використання Actions і Reducers

Отже, у нас є основні функції, але ми не будемо викликати їх в Redux безпосередньо. Між функціями і зовнішнім світом розташований шар непрямої адресації: дії ( Actions).

Це прості структури даних, що описують зміни, які повинні відбутися зі станом вашого застосування. По суті це опис виклику функції, упаковане в маленький об’єкт. За угодою, кожна дія має атрибут type, що описує, для якої операції ця дія призначене. Також можуть використовуватися і додаткові атрибути. Ось кілька прикладів дій, які підходять для наших основних функцій:

{type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']}

{type: 'NEXT'}

{type: 'VOTE', entry: 'Trainspotting'}

При такому способі вираження нам ще знадобиться перетворити їх в нормальні виклики основних функцій. У випадку з VOTE повинен виконуватися наступний виклик:

// Этот action
let voteAction = {type: 'VOTE', entry: 'Trainspotting'}
// должен сделать это:
return vote(state, voteAction.entry);

Тепер потрібно написати шаблонну функцію (generic function), приймаючу будь-яку дію – в рамках поточного стану – і викликає відповідну функцію ядра. Така функція називається перетворювачем ( reducer):

src/reducer.js
export default function reducer(state, action) {
  // Определяет, какую функцию нужно вызвать, и делает это
}

Тепер потрібно переконатися, що наш 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 . Також файрвол потрібно впроваджувати в додатки з механізмом аутентифікації.

Тепер наш сервер працює наступним чином:

  1. Клієнт відправляє на сервер якусь дію (action).
  2. Сервер пересилає його в Redux store.
  3. Store викликає reducer, який виконує логіку, пов’язану з цим action.
  4. Store оновлює стан на підставі повертається reducer-му значення.
  5. Store виконує відповідний listener, підписаний сервером.
  6. Сервер генерує подія state.
  7. Всі підключені клієнти – включаючи того, хто ініціював початкове дію – отримують новий стан.

Перш, ніж ми закінчимо роботу над сервером, давайте завантажимо в нього тестовий набір записів, щоб подивитися, як працює система. Записи можна помістити в файл 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 і яке впливає на архітектуру.

5.1. Налаштування клієнтського проекту

В першу чергу ми створимо свіжий NPM-проект, як ми це робили у випадку з сервером.

mkdir voting-client
cd voting-client
npm init –y

Тепер для нашого застосування потрібна стартова HTML-сторінка. Покладемо її в dist/index.html:

dist/index.html
<!DOCTYPE html>
<html>
<body>
  <div id="app"></div>
  <script 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. Якщо ви хочете зробити додаток гарніше, то можете самі додати в нього стилі.

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[key] = window[key];
  }
});

Також ми скористаємося 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[key] = window[key];
  }
});

chai.use(chaiImmutable);

Останній крок перед запуском тестів: додамо в файл package.jsonкоманду для їх запуску:

package.json
"scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js \"test/**/*@(.js|.jsx)\""
},

Майже таку ж команду ми використовували в серверному package.json. Різниця лише в специфікації тестового файлу: на сервері ми використовували --recursive, але в цьому випадку не будуть виявлятися .jsx-файли. Для можлівості знайти і .js, і .jsx-файли використовуємо glob .

Було б зручно безперервно проганяти тести при будь-яких змінах в коді. Для цього можна додати команду test:watch, ідентичну застосовуваної на сервері:

package.json
"scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js 'test/**/*.@(js|jsx)'",
  "test:watch": "npm run test -- --watch"
},

5.2. React і react-hot-loader

Інфраструктура Webpack і Babel готова, займемося React!

При побудові React-додатків за допомогою Redux і Immutable ми можемо писати так звані чисті компоненти (Pure Components, їх ще іноді називають Dumb Components). Ідея та ж, що і в основі чистих функцій, повинні дотримуватися два правила:

  1. Чистий компонент отримує всі дані у вигляді властивостей, як функція отримує дані у вигляді аргументів. Не повинно бути ніяких побічних ефектів – читання даних звідки або, ініціації мережевих запитів і т.д.
  2. В цілому у чистого компонента немає внутрішнього стану. Отрісовка залежить виключно від вхідних властивостей. Якщо двічі щось отрисовать за допомогою одного компонента, що має одні й ті ж властивості, то в результаті ми отримаємо один і той же інтерфейс. У компонента немає прихованого стану, яке може вплинути на процес відтворення.

Використання чистих компонентів спрощує код, як і використання чистих функцій: ми можемо зрозуміти, що робить компонент, подивившись на його вхідні дані і результат відтворення. Більше нам нічого не потрібно знати про компоненті. Тестувати його також не складно, майже як і тестувати логіку додатку на основі чистих функцій.

Але якщо компонент не може володіти станом, то де воно буде знаходитися? У незмінної структурі даних всередині Redux store! Відокремити стан від коду призначеного для користувача інтерфейсу – відмінна ідея.

Але не будемо забігати вперед. Додамо 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. Створення призначеного для користувача інтерфейсу для екрану голосування

Цей екран буде дуже простим: поки голосування не завершилося, завжди будуть відображатися дві кнопки, по одній для кожної з двох записів. А по завершенні голосування буде показаний переможець.

Interface

Здебільшого поки ми займалися розробкою через тестування, при створенні 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 pair={pair} />,
  document.getElementById('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({
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}>
          <h1>{entry}</h1>
        </button>
      )}
    </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 pair={["Trainspotting", "28 Days Later"]} />
    );
  });

});

Після відтворення компонента для пошуку кнопок можна використовувати іншу допоміжну функцію 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 pair={["Trainspotting", "28 Days Later"]} />
    );
    const buttons = scryRenderedDOMComponentsWithTag(component, '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 pair={["Trainspotting", "28 Days Later"]}
              vote={vote}/>
    );
    const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
    Simulate.click(buttons[0]);

    expect(votedWith).to.equal('Trainspotting');
  });

});

Написати цей тест не складно. Для кнопок нам лише потрібен обробник onClick, що викликає vote до здійснення неправильного запису:

src/components/Voting.jsx
import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

Таким чином ми за допомогою чистих компонентів будемо управляти призначеним для користувача введенням і діями: компоненти не будуть самостійно обробляти actions, а будуть просто викликати callback-й.

Тут ми повернулися до розробки через тестування. В ході створення інтерфейсу ми будемо ще не раз перемикатися з одного підходу на інший, залежно того, що буде корисніше в поточних обставинах.

Коли користувач проголосував за якусь позицію, не варто дозволяти йому робити це повторно. Ми могли б обробити цю ситуацію всередині стану компонента, але оскільки намагаємося зберігати компоненти чистими, винесемо цю логіку назовні. Компонент отримає властивість hasVoted, і обраний елемент ми поки захардкодім:

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 pair={pair} hasVoted="Trainspotting" />,
  document.getElementById('app')
);

І допишемо компонент голосування:

src/components/Voting.jsx
import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
        </button>
      )}
    </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) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
          {this.hasVotedFor(entry) ?
            <div className="label">Voted</div> :
            null}
        </button>
      )}
    </div>;
  }
});

Коли у нас з’явиться фінальний переможець, то відображатися буде тільки він. Для нього ми зробимо інше властивість, значення якого також тимчасово захардкодім:

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 pair={pair} winner="Trainspotting" />,
  document.getElementById('app')
);

Чи можемо обробити це в компоненті, відмальовуя div переможця або кнопки вибору в залежності від значення властивості winner:

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) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.props.winner ?
        <div ref="winner">Winner is {this.props.winner}!</div> :
        this.getPair().map(entry =>
          <button key={entry}
                  disabled={this.isDisabled()}
                  onClick={() => this.props.vote(entry)}>
            <h1>{entry}</h1>
            {this.hasVotedFor(entry) ?
              <div className="label">Voted</div> :
              null}
          </button>
        )}
    </div>;
  }
});

Тепер ми отримали потрібну нам функціональність, але код відтворення поки виглядає трохи неохайно. Краще взяти з нього окремі компоненти, щоб компонент екрану голосування (vote screen) малював або компонент переможця (winner), або компонент голосування (vote). У разі компонента winner буде відмальовуватись просто div:

src/components/Winner.jsx
import React from 'react';

export default React.createClass({
  render: function() {
    return <div className="winner">
      Winner is {this.props.winner}!
    </div>;
  }
});

Компонент голосування буде практично таким же, як і раніше, потрібні лише кнопки голосування:

src/components/Vote.jsx
import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
          {this.hasVotedFor(entry) ?
            <div className="label">Voted</div> :
            null}
        </button>
      )}
    </div>;
  }
});

А сам компонент голосування тепер просто приймає рішення, який з двох компонентів потрібно відмальовувати:

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="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

Зверніть увагу, що в компонент переможці доданий ref . Ми будемо використовувати його в модульних тестах для отримання необхідного DOM-елемента.

У нас готовий чистий компонент голосування! Зауважте, ми до сих пір не реалізували ніяку логіку: є тільки кнопки, які поки нічого не роблять, за винятком виклику callback-ів. Компоненти відповідальні лише за відмальовку інтерфейсу. Пізніше ми додамо логіку додатка, підключивши інтерфейс до Redux store.

Тепер напишемо ще кілька модульних тестів для перевірки нової функціональності. Наявність властивості hasVoted має приводити до відключення кнопок голосування:

test/components/Voting_spec.jsx
it('отключает кнопку, как только пользователь проголосует', () => {
  const component = renderIntoDocument(
    <Voting pair={["Trainspotting", "28 Days Later"]}
            hasVoted="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

  expect(buttons.length).to.equal(2);
  expect(buttons[0].hasAttribute('disabled')).to.equal(true);
  expect(buttons[1].hasAttribute('disabled')).to.equal(true);
});

Label Voted з’являється на тій кнопці, чия запис збігається зі значенням властивості hasVoted:

test/components/Voting_spec.jsx
it('добавляет label к записи, за которую проголосовали', () => {
  const component = renderIntoDocument(
    <Voting pair={["Trainspotting", "28 Days Later"]}
            hasVoted="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

  expect(buttons[0].textContent).to.contain('Voted');
});

Коли у нас з’являється переможець, то повинні відмалюватися НЕ кнопки, а елемент з ref’ом переможця:

test/components/Voting_spec.jsx
it('отрисовывает только победителя', () => {
  const component = renderIntoDocument(
    <Voting winner="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
  expect(buttons.length).to.equal(0);

  const winner = ReactDOM.findDOMNode(component.refs.winner);
  expect(winner).to.be.ok;
  expect(winner.textContent).to.contain('Trainspotting');
});

Можна було б написати тести для кожного компонента окремо, але я вважаю, що в даному випадку правильніше тестувати екран голосування в якості «модуля». Ми тестуємо зовнішню поведінку компонента, а той факт, що всередині нього є більш дрібні компоненти, це вже деталі реалізації.

5.4. Незмінні дані і чистий рендеринг (Pure Rendering)

Ми вже обговорили основні переваги незмінних даних, але є ще одне, вкрай практична перевага, пов’язана з їх використанням разом з React. Якщо в якості властивостей компонента ми будемо використовувати тільки незмінні дані, а самі компоненти напишемо відповідно до критеріїв чистоти, то змусимо React застосовувати більш ефективну стратегію виявлення змін у властивостях.

Для цього застосуємо PureRenderMixin з add-on-пакета . Якщо додати mixin в компонент, то React стане по-іншому перевіряти властивості (і стан) компонента на наявність змін. Порівняння буде не глибоким, а поверхневим, що набагато швидше.

Доцільність цього рішення полягає в тому, що змін в immutable структурах бути не може. Так що якщо властивості компонента є незмінні дані, і вони вказують на ті ж значення між відмальовками, то немає необхідності рендерить компонент знову!

Давайте напишемо модульні тести на цей випадок. Передбачається, що у нас чистий компонент, так що якщо дати йому змінюваний масив, а потім зробити в ньому якусь зміну, то компонент не повинен   бути перемальованим:

test/components/Voting_spec.jsx
it('отрисовывается как чистый компонент', () => {
  const pair = ['Trainspotting', '28 Days Later'];
  const container = document.createElement('div');
  let component = ReactDOM.render(
    <Voting pair={pair} />,
    container
  );

  let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
  expect(firstButton.textContent).to.equal('Trainspotting');

  pair[0] = 'Sunshine';
  component = ReactDOM.render(
    <Voting pair={pair} />,
    container
  );
  firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
  expect(firstButton.textContent).to.equal('Trainspotting');
});

Замість renderIntoDocument ми вручну створюємо батьківський <div> і двічі відмальовуваємих в нього, емулюючи перемальовку.

Потрібно явно задати у властивостях новий незмінний список, щоб зміни відбулися в інтерфейсі:

test/components/Voting_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithTag,
  Simulate
} from 'react-addons-test-utils';
import {List} from 'immutable';
import Voting from '../../src/components/Voting';
import {expect} from 'chai';

describe('Voting', () => {

  // ...

  it('обновляет DOM при изменении свойства', () => {
    const pair = List.of('Trainspotting', '28 Days Later');
    const container = document.createElement('div');
    let component = ReactDOM.render(
      <Voting pair={pair} />,
      container
    );

    let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
    expect(firstButton.textContent).to.equal('Trainspotting');

    const newPair = pair.set(0, 'Sunshine');
    component = ReactDOM.render(
      <Voting pair={newPair} />,
      container
    );
    firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
    expect(firstButton.textContent).to.equal('Sunshine');
  });

});

Зазвичай я не переймаюсь написанням подібних тестів, а просто припускаю використання PureRenderMixin. Але в нашому випадку тести просто допомагають розібратися в тому, що відбувається. Тут вони демонструють, що компонент поводиться не так, як очікується: оновлення інтерфейсу відбувається в обох випадках. Це означає проведення глибоких перевірок властивостей, чого ми якраз і хотіли уникнути за допомогою незмінних даних.

Все стає на свої місця після того, як ми включимо PureRenderMixin в нашому компоненті. Спочатку встановимо пакет:

npm install --save react-addons-pure-render-mixin

Після його додавання в компоненти тести починають виконуватися успішно:

src/components/Voting.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Winner from './Winner';
import Vote from './Vote';

export default React.createClass({
  mixins: [PureRenderMixin],
  // ...
});

src/components/Vote.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  // ...
});

src/components/Winner.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  // ...
});

Строго кажучи, ми почнемо проходити тести навіть при простому включенні PureRenderMixin в компоненті голосування, не звертаючи уваги на інші два компонента. Справа в тому, що коли React не знаходить змін у властивостях Voting, то він пропускає перемальовку всього піддерева компоненту.

Але все ж правильніше буде послідовно використовувати PureRenderMixin у всіх компонентах. По-перше, це підтвердить їх чистоту, а по-друге, їх поведінка не зміниться навіть після перегруповування.

5.5. Створення призначеного для користувача інтерфейсу для екрану результатів і обробка переходів (Routing Handling)

Закінчили з екраном голосування, тепер перейдемо до іншого важливого екрану нашого застосування: до екрану відображення результатів.

В якості даних для відображення використовуються ті ж пари записів, що і на екрані голосування, а також поточні лічильники голосів по кожному запису. Внизу додається маленька кнопка, при натисканні якої ми переходимо до голосування по наступній парі.

Виходить, що у нас є два окремих екрана, і в кожен момент часу повинен відображатися один з них. Для вибору конкретного екрану для відображення можна використовувати URL’и. Призначимо шлях #/для відображення екрану голосування, а шлях #/results– для відображення екрану результатів.

Подібні речі легко виконуються за допомогою бібліотеки react-router, Завдяки якій можна асоціювати один з одним різні компоненти і шляхи. Додамо її в наш проект:

npm install --save react-router@2.0.0

Тепер сконфігуріруем шляхи. Для цього скористаємося роутером (Router) з React-компонента Route, за допомогою якого декларативно опишемо таблицю відповідностей. Поки що у нас є лише один шлях:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Route} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

const routes = <Route component={App}>
  <Route path="/" component={Voting} />
</Route>;

ReactDOM.render(
  <Voting pair={pair} />,
  document.getElementById('app')
);

У нас є один шлях, який вказує на компонент Voting. Також ми визначили компонент кореневого шляху, який може використовуватися всіма конкретними шляхами всередині нього. Він вказує на компонент App, який скоро буде створений.

Завдання компонента кореневого шляху полягає в відображенні загальної для всіх розмітки. Так має виглядати наш кореневої компонент App:

src/components/App.jsx
import React from 'react';
import {List} from 'immutable';

const pair = List.of('Trainspotting', '28 Days Later');

export default React.createClass({
  render: function() {
    return React.cloneElement(this.props.children, {pair: pair});
  }
});

Цей компонент відмальовує свої дочірні компоненти, що передаються у властивості children. Далі react-router підключає компоненти, визначені для поточного маршруту. Оскільки поки у нас є тільки один маршрут для Voting, то на даний момент компонент завжди буде малювати Voting.

Зверніть увагу, що заглушка pair переміщених з index.jsx у App.jsx. Для клонування вихідних компонентів з передачею кастомних властивостей pair ми скористаємося API cloneElement. Це тимчасовий захід, пізніше можна буде прибрати клонуючий виклик.

Вище ми говорили про те, що краще використовувати PureRenderMixin у всіх компонентах. Винятком з цього правила є компонент App: через особливості взаємодії між роутером і React маршрути можуть не змінитися. Можливо, в найближчому майбутньому ситуація зміниться.

Тепер повернемося до index.js, з якого запустимо сам роутер, щоб вони Ініціалізувати наш додаток:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';

const routes = <Route component={App}>
  <Route path="/" component={Voting} />
</Route>;

ReactDOM.render(
  <Router history={hashHistory}>{routes}</Router>,
  document.getElementById('app')
);

Компонент Router з пакета react-routerє кореневим для нашого застосування, і він буде використовувати механізм збереження історії на базі #hash (на відміну від API для збереження історії в HTML 5). Передамо йому нашу таблицю відповідностей у вигляді дочірнього компонента.

Тепер ми відновили попередню функціональність нашого застосування: воно всього лише відмальовує компонент Voting. Але в цей раз це робиться за допомогою роутера React, а значить ми легко можемо додавати нові шляхи. Зробимо це для екрану результатів, який буде обслуговуватися новим компонентом Results:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';
import Results from './components/Results';

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={Voting} />
</Route>;

ReactDOM.render(
  <Router history={hashHistory}>{routes}</Router>,
  document.getElementById('app')
);

Тут задали в компоненті <Route> для шляху /results відмальовку компонента results. Всі інші шляхи ведут до Voting.

Давайте створимо просту реалізацію Resultsта подивимося на роботу роутінга:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>Hello from results!</div>
  }
});

Якщо відкрити в браузері localhost:8080/#/results, то ви побачите повідомлення від компонента Results. Корневной маршрут повинен відобразити кнопки голосування. За допомогою кнопок «вперед» і «назад» в браузері ви можете перемикатися між шляхами, і відображається компонент буде змінюватися. Ось він, роутер в дії!

Більше в нашому додатку ми нічого не будемо робити за допомогою роутера React. Хоча у бібліотеки куди більше можливостей, можете подивитися її документацію .

Тепер, коли у нас є тимчасовий компонент Results, давайте змусимо його зробити щось корисне. Нехай він відображає ті ж два записи, які зараз беруть участь в голосуванні в компоненті Voting:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="results">
      {this.getPair().map(entry =>
        <div key={entry} className="entry">
          <h1>{entry}</h1>
        </div>
      )}
    </div>;
  }
});

Раз це екран результатів, то потрібно відобразити поточний розподіл голосів, адже саме це люди очікують побачити. Передамо в компонент з кореневого компонента App тимчасовий результат голосування Map:

src/components/App.jsx
import React from 'react';
import {List, Map} from 'immutable';

const pair = List.of('Trainspotting', '28 Days Later');
const tally = Map({'Trainspotting': 5, '28 Days Later': 4});

export default React.createClass({
  render: function() {
    return React.cloneElement(this.props.children, {
      pair: pair,
      tally: tally
    });
  }
});

Тепер налаштуємо компонент Results для відображення цих чисел:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return <div className="results">
      {this.getPair().map(entry =>
        <div key={entry} className="entry">
          <h1>{entry}</h1>
          <div className="voteCount">
            {this.getVotes(entry)}
          </div>
        </div>
      )}
    </div>;
  }
});

А тепер давайте змінимо передачу і додамо модульний тест для поточного поведінки компонента Results, щоб упевнитися, що пізніше ми його не зламаємо. Компонент повинен малювати div’и для кожного запису, всередині яких відображати імена самих записів і поточну кількість голосів. Якщо за запис ніхто не проголосував, то нехай відображається нуль:

test/components/Results_spec.jsx
import React from 'react';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithClass
} from 'react-addons-test-utils';
import {List, Map} from 'immutable';
import Results from '../../src/components/Results';
import {expect} from 'chai';

describe('Results', () => {

  it('renders entries with vote counts or zero', () => {
    const pair = List.of('Trainspotting', '28 Days Later');
    const tally = Map({'Trainspotting': 5});
    const component = renderIntoDocument(
      <Results pair={pair} tally={tally} />
    );
    const entries = scryRenderedDOMComponentsWithClass(component, 'entry');
    const [train, days] = entries.map(e => e.textContent);

    expect(entries.length).to.equal(2);
    expect(train).to.contain('Trainspotting');
    expect(train).to.contain('5');
    expect(days).to.contain('28 Days Later');
    expect(days).to.contain('0');
  });

});

Тепер поговоримо про кнопку «Next», використовуваної для переходу до наступного голосування. З точки зору компонента, у властивостях повинна бути просто callback-функція. Вона повинна викликатися компонентом, коли всередині нього натискається кнопка «Next». Сформулюємо досить простий модульний тест, вельми схожий на той, що ми робили для кнопок голосування:

test/components/Results_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithClass,
  Simulate
} from 'react-addons-test-utils';
import {List, Map} from 'immutable';
import Results from '../../src/components/Results';
import {expect} from 'chai';


describe('Results', () => {

  // ...

  it('вызывает callback при нажатии кнопки Next', () => {
    let nextInvoked = false;
    const next = () => nextInvoked = true;

    const pair = List.of('Trainspotting', '28 Days Later');
    const component = renderIntoDocument(
      <Results pair={pair}
               tally={Map()}
               next={next}/>
    );
    Simulate.click(ReactDOM.findDOMNode(component.refs.next));

    expect(nextInvoked).to.equal(true);
  });

});

Реалізація багато в чому схожа з кнопками голосування. Вийшло трохи простіше, оскільки не потрібно передавати аргументи:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return <div className="results">
      <div className="tally">
        {this.getPair().map(entry =>
          <div key={entry} className="entry">
            <h1>{entry}</h1>
            <div class="voteCount">
              {this.getVotes(entry)}
            </div>
          </div>
        )}
      </div>
      <div className="management">
        <button ref="next"
                className="next"
                onClick={this.props.next}>
          Next
        </button>
      </div>
    </div>;
  }
});

Як і у випадку з екраном голосування, на екрані результатів повинен відобразитися переможець:

test/components/Results_spec.jsx
it('отрисовывает финального победителя', () => {
  const component = renderIntoDocument(
    <Results winner="Trainspotting"
             pair={["Trainspotting", "28 Days Later"]}
             tally={Map()} />
  );
  const winner = ReactDOM.findDOMNode(component.refs.winner);
  expect(winner).to.be.ok;
  expect(winner.textContent).to.contain('Trainspotting');
});

Можна реалізувати це за допомогою повторного використання компонента Winner, вже розробленого для екрану голосування. Як тільки у нас визначається фінальний переможець, то ми відмальовуємо відповідний компонент замість стандартного екрану результатів:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Winner from './Winner';

export default React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
        </div>
      </div>;
  }
});

Цьому компоненту також пішло б на користь поділ на більш дрібні складові. Наприклад, компонент Tally міг би відображати пари записів. Якщо вам подобається ця ідея, то сміливо рефакторьте!

І це майже весь інтерфейс, необхідний нашому простому додатку. Поки що написані нами компоненти нічого не роблять, тому що не отримують реальних даних або дій. Примітно, як далеко нам вдається зайти і без цього. Ми навіть змогли впровадити в ці компоненти прості заглушки, щоб сконцентруватися на структурі інтерфейсу.

Тепер, коли ми завершили його створення, поговоримо про те, як вдихнути в нього життя за допомогою підключення Redux store до вхідних і вихідних каналів.

5.6. Використання клієнтського Redux Store

Redux був спроектований для використання в якості контейнера станів додатків, що мають користувальницький інтерфейс. Наш додаток повністю підходить під цей критерій. Поки що ми використовували Redux тільки на сервері і з’ясували, що він і там дуже корисний! Тепер можна подивитися, як він поведе себе з React-додатком.

У нас є інтерфейс з двома екранами. На обох відображається пара записів, які беруть участь в голосуванні. Має сенс зробити стан vote з парою елементів для голосування:

interface

В той же стан помістимо екран результатів, куди виводяться поточний розподіл голосів.

interface

Компонент голосування (Voting) відмальовуеться інакше, коли користувач вже проголосував в поточній парі. Це також має відстежуватися станом:

interface

Коли з’являється фінальний переможець, тільки він і повинен бути присутнім в стані:

interface

Зверніть увагу, що все тут є підмножиною станів сервера, за винятком суті hasVoted. Це призводить нас до думок про реалізацію основної логіки, дій (actions) і перетворювачів (reducers), які будуть використовуватися Redux store. Якими вони повинні бути?

Давайте розглянемо це з точки зору того, що може змінити стан виконання додатку. Одне джерело змін стану – дії користувача. Зараз в інтерфейсі передбачено два можливих сценарії взаємодії:

  • Користувач клацає на одну з кнопок на екрані голосування.
  • Користувач клацає на кнопку “Next” на екрані результатів.

Крім того, наш сервер налаштований на відправку свого поточного стану. Скоро ми напишемо код для його отримання. Це третє джерело зміни стану.

Можна почати з оновлення стану сервера, оскільки зробити це найпростіше. Вище ми вже налаштовували наш сервер, щоб він генерував подія state, чиє корисне навантаження являє собою практично точну копію намальованих нами клієнтських дерев станів. Це не збіг, саме таким ми його і розробили. З точки зору нашого клієнтського reducer-a, доцільно мати action, який одержує від сервера снепшот стану і об’єднує його з клієнтським станом. Цей action виглядав би так само:

{
  type: 'SET_STATE',
  state: {
    vote: {...}
  }
}

Подивимося за допомогою модульних тестів, як це працює. Отримавши дію, на зразок наведеного вище, reducer повинен об’єднувати його дані з поточним станом:

test/reducer_spec.js
import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {

  it('handles SET_STATE', () => {
    const initialState = Map();
    const action = {
      type: 'SET_STATE',
      state: Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({Trainspotting: 1})
        })
      })
    };
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }));
  });

});

Reducer повинен вміти отримувати від сокета просту JS-структуру даних. Вона повинна бути перетворена в незмінну структуру до моменту свого повернення у вигляді наступного значення:

test/reducer_spec.js
it('обрабатывает SET_STATE с простой JS-нагрузкой', () => {
  const initialState = Map();
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }
  };
  const nextState = reducer(initialState, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

Початковий стан undefined також має бути коректно Ініціалізуваний reducer-м у вигляді незмінної структури:

test/reducer_spec.js
it('обрабатывает SET_STATE без начального состояния', () => {
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }
  };
  const nextState = reducer(undefined, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

Такі наші технічні умови. Давайте подивимося, як їх можна виконати. У нас є функція-reducer, експортована reducer-модулем:

src/reducer.js
import {Map} from 'immutable';

export default function(state = Map(), action) {

  return state;
}

Reducer повинен обробити action SET_STATE. За допомогою функції merge з Map можна просто об’єднати новий стан зі старим в функції-обробнику. Це дозволить нам пройти тести!

src/reducer.js
import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return setState(state, action.state);
  }
  return state;
}

Зверніть увагу, що ми не стали морочитися з «основним» модулем, відокремленим від reducer-модуля. Це пов’язано з тим, що логіка в перетворювачі настільки проста, що про неї не потрібно хвилюватися. Просто виконуємо злиття, в той час як на сервері знаходиться повна логіка системи голосування. Якщо виникне необхідність, можна буде пізніше і на клієнті розділити функціональності.

У нас залишилося ще дві причини зміни стану, пов’язані з діями користувача: голосування і натискання кнопки «Next». В обох випадках мається на увазі взаємодію з сервером, тому ми повернемося до цього трохи пізніше, коли розберемося з архітектурою підключення до сервера.

Настав час додати Redux в наш проект:

npm install --save redux

Хорошим місцем для ініціалізації store є вхідні точка index.jsx. Створимо його з яким-небудь станом, передавши дію SET_STATE(це тимчасове рішення, поки у нас не з’являться реальні дані):

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import reducer from './reducer';
import App from './components/App';
import Voting from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={Voting} />
</Route>;

ReactDOM.render(
  <Router history={hashHistory}>{routes}</Router>,
  document.getElementById('app')
);

Store готовий. Як нам тепер передати з нього дані в React-компоненти?

5.7. Передача вхідних даних з Redux в React

У Redux Store міститься незмінний стан додатку. У нас є React-компоненти, які беруть незмінні дані на вході. Якщо ми зможемо придумати спосіб надійно передавати актуальні дані з store в компоненти, то буде чудово. При змінах стану React буде перемальовуватись, а PureRenderMixin буде стежити за тим, щоб не перемальовували ті частини інтерфейсу, які не повинні.

Замість самостійного написання коду синхронізації, можна використовувати Redux React Біндінг з пакета react-redux :

npm install --save react-redux

react-redux підключає наші чисті компоненти до Redux store за допомогою:

  • Відображення стану з store у вхідні властивості компонента.
  • Відображення actions в властивості callback-ів компонента.

Але поперше нам потрібно обернути наш головний компонент в компонент-провайдер ( Provider ) з react-redux. Він з’єднає наше дерево компонентів з Redux Store, що дозволить нам пізніше зв’язати store c окремими компонентами.

Помістимо провайдера навколо компонента-роутера. В результаті провайдер стане спадкоємцем усіх компонентів нашого застосування.

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

});

Тепер треба подумати про те, які з компонентів потрібно «підключити», щоб з store надходили всі необхідні дані. У нас є п’ять компонентів, які можна розділити на три категорії:

  • Кореневий компонент Appні в чому не потребує, тому що не використовує дані.
  • Voteі Winnerвикористовуються тільки батьківськими компонентами, що зраджують їм всі необхідні властивості. Вони теж не потребують підключення.
  • Залишаються тільки ті компоненти, які використовувалися при завданні шляхів: Votingі Results. Зараз вони отримують від Appзаглушки властивостей. Ось їх-то і треба підключити до store.

Почнемо з компонента Voting. Візьмемо з react-redux функцію connect , за допомогою якої будемо підключати компонент. Вона бере сопоставляющую функцію як аргумент, а повертає іншу функцію, приймаючу клас React-компонента:

connect(mapStateToProps)(SomeComponent);

Маппінг-функція здійснює зіставлення стану з Redux Store в якості об’єкта. Потім ці властивості будуть об’єднані з властивостями підключаємого компоненту. У випадку з Voting нам всього лише потрібно замапіть pairі winnerз Store:

src/components/Voting.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';

const Voting = React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    winner: state.get('winner')
  };
}

connect(mapStateToProps)(Voting);

export default Voting;

Це не зовсім вірно. З точки зору функціонального підходу, функція connect насправді не повинна змінювати компонент Voting. Він залишається чистим, не підключеним компонентом. Замість цього connect повертає підключену версію Voting . А це означає, що наш поточний код по суті нічого не робить. Візьмемо повертається значення і назвемо його VotingContainer:

src/components/Voting.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';

export const Voting = React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    winner: state.get('winner')
  };
}

export const VotingContainer = connect(mapStateToProps)(Voting);

Тепер модуль експортує чистий компонент Voting і підключений компонент VotingContainer. У документації react-redux перший називається «тупим» (dumb) компонентом, а другий – «розумним» (smart). Особисто я віддаю перевагу терміни «чистий» і «підключений». Називайте їх, як зручніше, але потрібно розуміти, чим вони відрізняються:

  • Чистий / тупий компонент повністю залежить від наданих йому властивостей. Являє собою еквівалент чистої функції, тільки компонент.
  • Підключений / розумний компонент обертає чисту версію в якусь логіку, яка дозволяє синхронізуватися із змінним станом з Redux store. Логіка береться з react-redux.

Оновимо нашу роутинг-таблицю, щоб замість Votingвикористовувався VotingContainer. Після цього екран голосування буде отримувати дані, які ми покладемо в Redux-сховище.

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Змінимо спосіб імпортування в модульному тесті для Voting, адже нам більше не потрібно використовувати Voting як експортера за замовчуванням:

test/components/Voting_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithTag,
  Simulate
} from 'react-addons-test-utils';
import {List} from 'immutable';
import {Voting} from '../../src/components/Voting';
import {expect} from 'chai';

Більше нічого змінювати не потрібно. Тести написані для чистого компонента Voting, який залишається незмінним. Ми просто додали обгортку, щоб підключити його до store.

Тепер те ж саме зробимо з екраном результатів, якому будемо передавати атрибути стану pair і winner. Крім того, для відображення результатів йому знадобиться і tally:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';

export const Results = React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
      </div>
      </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    tally: state.getIn(['vote', 'tally']),
    winner: state.get('winner')
  }
}

export const ResultsContainer = connect(mapStateToProps)(Results);

У index.jsxзмінимо шлях передачі результатів, замість Results буде ResultsContainer:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Нарешті, в тесті результатів оновимо вираз імпорту для Results:

test/components/Results_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithClass,
  Simulate
} from 'react-addons-test-utils';
import {List, Map} from 'immutable';
import {Results} from '../../src/components/Results';
import {expect} from 'chai';

Таким ось чином можна підключати чисті React-компоненти до Redux-сховища, щоб вони могли отримувати звідти потрібні дані.

Для дуже маленьких додатків, що володіють єдиним кореневим компонентом і не використовують роутинг, в більшості випадків буде достатньо підключення кореневого компонента. А потім вже корінь делегує ці дані у вигляді властивостей для своїх дочірніх компонентів.

У додатках з роутингом, начебто ми створювали, звичайно краще підключати кожен з компонентів роутера. Але кожен компонент може бути підключений окремо, тому ви вільні застосовувати різні стратегії в залежності від архітектури додатку. На мій погляд, має сенс у всіх можливих випадках використовувати звичайні властивості, оскільки з ними простіше зрозуміти, які дані подаються на вхід. До того ж вам не доведеться розбиратися з кодом «підключення».

Отже, ми тепер можемо передавати в інтерфейс дані з Redux. У App.jsx нас більше не потрібні заглушки властивостей, так що код спрощується:

src/components/App.jsx
import React from 'react';

export default React.createClass({
  render: function() {
    return this.props.children;
  }
});

5.8. Налаштування клієнта Socket.io

Оскільки в наш клієнт є Redux-додаток, поговоримо про спосіб підключення до серверного Redux-додатку. На даний момент вони обидва існують в своїх власних світах, які не взаємодіючи один з одним.

Сервер вже готовий до прийняття вхідних socket-підключень і передачі їм стану голосування. А у клієнта є Redux-сховище, в яке можна легко записати вхідні дані. Залишилося тільки зв’язати їх.

Почнемо з інфраструктури. Нам потрібно створити Socket.io-канал від браузера до сервера. Для цього скористаємося бібліотекою socket.io-client , що є клієнтським аналогом бібліотеки, яку ми використовували на сервері:

npm install --save socket.io-client

Після імпорту бібліотеки ми отримали функцію io, яку можна використовувати для підключення до сервера Socket.io. Підключимося до порту 8090 (його ми використовували для сервера):

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const socket = io(`${location.protocol}//${location.hostname}:8090`);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Перевірте, що сервер запущений, відкрийте в браузері клієнтську програму і перегляньте мережевий трафік. Повинно бути встановлено WebSocket-підключення, в яке відправляються контрольні сигнали Socket.io.

Під час розробки ми будемо використовувати на сторінці два Socket.io-підключення: одне наше, а друге для підтримки гарячої Webpack-перезавантаження.

5.9. Отримання actions з сервера

Отримати вхідні дані з Socket.io каналу досить просто. При першому підключенні і кожній зміні сервер відправляє нам події state, досить їх просто слухати. Після отримання подібної події передаємо в наше сховище дію SET_STATE. Для його обробки там вже є reducer:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch({type: 'SET_STATE', state})
);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Зверніть увагу, що ми прибрали заглушечную передачу SET_STATE. Вона нам більше не потрібна, тому що сервер почав передавати реальний стан.

Погляньте на інтерфейс – голосування або результатів: там повинна відображатися перша пара записів, переданих з сервера. Підключення між клієнтом і сервером встановлено!

5.10. Передача actions від React-компонентів

Ми знаємо, як передавати в інтерфейс вхідні дані від Redux store. Давайте тепер поговоримо про передачу з інтерфейсу вихідних дій.

Найкраще почати з кнопок голосування. При створенні інтерфейсу ми прийняли, що компонент Voting буде отримувати властивість vote, значенням якого є callback-функція. Компонент викликає її, коли користувач клацає на кнопки. Але ми поки не забезпечили підтримку цієї функції, за винятком модульних тестів.

Що має статися, коли користувач голосує за якийсь запис? Очевидно, що його голос повинен бути відправлений на сервер. Детальніше ми поговоримо про це нижче, але там теж задіюється клієнтська логіка: компоненту має призначатися властивістьhasVoted, щоб користувач не міг двічі голосувати в рамках будь-якої пари.

Крім SET_STATEу нас буде другий клієнтський Redux action – VOTE. Він буде додавати запис hasVotedв стан:

test/reducer_spec.js
it('обрабатывает VOTE с помощью назначения hasVoted', () => {
  const state = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  });
  const action = {type: 'VOTE', entry: 'Trainspotting'};
  const nextState = reducer(state, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    },
    hasVoted: 'Trainspotting'
  }));
});

Також має сенс не брати це властивість в стан, якщо дія VOTE з якоїсь причини приходить із записом, яка в даний момент не бере участі в голосуванні:

test/reducer_spec.js
it('в случае неправильной записи не назначает hasVoted для VOTE', () => {
  const state = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  });
  const action = {type: 'VOTE', entry: 'Sunshine'};
  const nextState = reducer(state, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

Розширимо логіку reducer-a для обробки цього випадку:

src/reducer.js
import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

function vote(state, entry) {
  const currentPair = state.getIn(['vote', 'pair']);
  if (currentPair && currentPair.includes(entry)) {
    return state.set('hasVoted', entry);
  } else {
    return state;
  }
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return setState(state, action.state);
  case 'VOTE':
    return vote(state, action.entry);
  }
  return state;
}

Властивість hasVoted не повинна вічно залишатися в стані. При переході до наступної пари вона повинна перепризначатися, щоб користувач міг голосувати далі. Ми будемо обробляти цю логіку в SET_STATE, перевіряючи, чи міститься в парі голосування поточного стану запис, за яку користувач вже проголосував. Якщо не міститься, то ми видаляємо властивість hasVoted:

test/reducer_spec.js
it('если пара изменилась, то очищает hasVoted в SET_STATE', () => {
  const initialState = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    },
    hasVoted: 'Trainspotting'
  });
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Sunshine', 'Slumdog Millionaire']
      }
    }
  };
  const nextState = reducer(initialState, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Sunshine', 'Slumdog Millionaire']
    }
  }));
});

Це можна реалізувати поєднанням функції resetVote і обробника дії SET_STATE:

src/reducer.js
import {List, Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

function vote(state, entry) {
  const currentPair = state.getIn(['vote', 'pair']);
  if (currentPair && currentPair.includes(entry)) {
    return state.set('hasVoted', entry);
  } else {
    return state;
  }
}

function resetVote(state) {
  const hasVoted = state.get('hasVoted');
  const currentPair = state.getIn(['vote', 'pair'], List());
  if (hasVoted && !currentPair.includes(hasVoted)) {
    return state.remove('hasVoted');
  } else {
    return state;
  }
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return resetVote(setState(state, action.state));
  case 'VOTE':
    return vote(state, action.entry);
  }
  return state;
}

Ця логіка визначення актуальності властивості hasVoted для поточної пари має вади. Зверніть увагу на вправи нижче для поліпшення логіки.

Прийшла пора підключити властивість hasVoted до властивостей Voting:

src/components/Voting.jsx
function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    hasVoted: state.get('hasVoted'),
    winner: state.get('winner')
  };
}

Нам ще потрібно якось передати в Voting vote callback, який приведе до обробки цієї нової дії. Votingповинен залишатися чистим і незалежним від actions або Redux, тому задіємо функцію connect з react-redux.

react-redux можна використовувати для підключення як вхідних властивостей, так і вихідних дій . Але спочатку ми задіємо ще одну ключову ідею Redux: творці дій (Action creators) .

Як ми вже знаємо, дії в Redux є простими об’єкти, що володіють (за згодою) атрибутом type і іншими специфічними даними. Ми створювали ці дії в міру потреби за допомогою об’єктних литералов. Але краще використовувати маленькі фабричні функції на зразок цієї:

function vote(entry) {
  return {type: 'VOTE', entry};
}

Такі функції ще називають «творцями дій». Вони є чистими функціями, які всього лише повертають об’єкти дій. Але при цьому таким чином інкапсулюють внутрішню структуру об’єктів дій, щоб вона більше не була ніяк пов’язана з іншою кодовою базою. За допомогою творців дій також зручно документувати всі дії, які можуть бути передані в вашу програму. Було б важче збирати подібну інформацію, будь вона розкидана по всій кодової базі у вигляді об’єктних литералов.

Створимо новий файл, який визначає творців дій для двох наших вже існуючих клієнтських дії:

src/action_creators.js
export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    type: 'VOTE',
    entry
  };
}

Для цих функцій дуже легко писати модульні тести. Але зазвичай я цього не роблю, якщо творець дії тільки повертає об’єкт. Але якщо хочете, то можете додати.

Тепер у файлі index.jsx в Socket.io-обробнику події ми можемо використовувати творця дії setState:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import {setState} from './action_creators';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Ще одна перевага творців дій полягає тому, як react-redux підключає їх до React-компонентів. У нас є callback-властивість vote в Voting і творець дії vote. Імена однакові, як і сигнатури функції: один аргумент, який є записом, за яку проголосували. Так що ми можемо просто передати творця дії в функцію connect з react-redux як другий аргумент:

src/components/Voting.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';
import * as actionCreators from '../action_creators';

export const Voting = React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    hasVoted: state.get('hasVoted'),
    winner: state.get('winner')
  };
}

export const VotingContainer = connect(
  mapStateToProps,
  actionCreators
)(Voting);

В результаті властивість vote буде передано в Voting. Це властивість являє собою функцію, яка створює дію за допомогою творця vote, і відправляє цю дію в Redux Store. Тобто дія буде відправлятися при кліці на кнопку голосування! Можете відразу перевірити це в браузері: після натискання на кнопку вона деактивується.

5.11. Відправка дій на сервер за допомогою Redux Middleware

Тепер нам потрібно вирішити останнє питання – отримання сервером результатів призначених для користувача дій. Це повинно відбуватися при голосуванні і натисканні на кнопку “Next” на екрані результатів.

Почнемо з голосування. Що у нас вже є?

  • Коли користувач голосує, створюється дію VOTE і відправляється в клієнтський Redux store.
  • Дії VOTE обробляються клієнтським reducer-му у вигляді призначення властивості hasVoted.
  • Сервер готовий до прийняття actions від клієнтів за допомогою Socket.io-подій action. Всі отримані дії будуть передаватися в серверний Redux Store.
  • Дії VOTE обробляються серверним reducer-му реєструванням голосу і оновленням результатів.

Схоже, у нас є майже все, що потрібно. Бракує тільки відправки на сервер клієнтських дій VOTE, щоб обробити їх в обох Redux stores. Цим ми і займемося.

З чого почати? У Redux немає нічого підходящого для цього, оскільки в число його основних завдань не входить підтримка розподілених систем на зразок нашої. Так що будемо самі вирішувати, як нам організувати відправку дій на сервер.

Redux надає шаблонний спосіб подцепленний до actions, що відправляється в redux store – Middleware .

Middleware (посередник) – це функція, яка викликається при передачі дії ще до того, як це дію потрапить в reducer і store. Middleware можна використовувати в різних цілях, від логгіруванія і обробки винятків до модифікування дій, кешування результатів і зміни способу та часу попадання дії в store. Ми ж скористаємося цими функціями для відправки клієнтських actions на сервер.

Зверніть увагу на різницю між middleware і listeners:

  • Перші викликаються до того, як дія потрапляє в store, тому вони можуть вплинути на нього.
  • Другі викликаються після виконання дії, і вже ніяк не можуть вплинути на його долю.

Різні інструменти для різних завдань.

Створимо remote action middleware, завдяки якому за допомогою Socket.io-підключення дію буде відправлено не тільки в початковий store, але і в віддалений.

Налаштуємо каркас нашого middleware. Це функція, яка бере Redux store і повертає іншу функцію, приймаючу callback «next». Ця інша функція повертає третю , приймаючу Redux action. Саме ця остання функція і відображає реалізацію middleware:

src/remote_action_middleware.js
export default store => next => action => {

}

Ви можете порахувати попередній код трохи дивним, але це більш конкретний спосіб запису:

export default function(store) {
  return function(next) {
    return function(action) {

    }
  }
}

Подібний спосіб вкладання одноаргументних функцій називається каррінг . У цьому випадку ми можемо легко конфігурувати посередника: якби всі аргументи містилися в одній функції ( function(store, next, action) { }), то нам довелося б передавати їх при кожному використанні посередника. А завдяки каррінг ми можемо раз викликати першу функцію і отримати значення, що повертається, яке «пам’ятає», який store потрібно використовувати.

Те ж саме відноситься і до аргументу next. Це callback, який повинен викликатися middleware по завершенні роботи, коли потрібно передати action в store (або наступного middleware):

src/remote_action_middleware.js
export default store => next => action => {
  return next(action);
}

Посередник може вирішити не викликати next, якщо вважатиме за потрібне затримати дію. Тоді воно вже ніколи не потрапить в reducer або store.

Давайте щось залоггіруем в middleware, щоб дізнатися, коли воно викликається:

src/remote_action_middleware.js
export default store => next => action => {
  console.log('in middleware', action);
  return next(action);
}

Якщо додати це middleware до нашого Redux store, то всі дії будуть залогуватися. Активувати middleware можна за допомогою Redux функції applyMiddleware. Вона бере middleware, яке ми хочемо зареєструвати, і повертає функцію, яка, в свою чергу, бере функцію createStore. Потім ця друга функція створює для нас store з уже увімкненим в нього middleware:

src/components/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import {setState} from './action_creators';
import remoteActionMiddleware from './remote_action_middleware';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const createStoreWithMiddleware = applyMiddleware(
  remoteActionMiddleware
)(createStore);
const store = createStoreWithMiddleware(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Це ще один хороший приклад використання каррінг. І його дуже активно використовують API Redux.

Тепер, якщо ми перезавантажити додаток, то побачимо, що middleware логгіруе все що відбуваються actions: спочатку вихідне SET_STATE, а в ході голосування – VOTE.

Middleware має відправляти отримане дію в Socket.io-підключення та передавати наступному middleware. Але для початку потрібно надати йому це підключення. Воно вже є у нас в index.jsx, залишилося тільки дати middleware доступ. Це легко здійснити за допомогою ще одного каррінг у визначенні middleware. Зовнішня функція повинна брати сокет Socket.io:

src/remote_action_middleware.js
export default socket => store => next => action => {
  console.log('in middleware', action);
  return next(action);
}

 

src/index.jsx
const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const createStoreWithMiddleware = applyMiddleware(
  remoteActionMiddleware(socket)
)(createStore);
const store = createStoreWithMiddleware(reducer);

Зверніть увагу, що нам потрібно поміняти місцями ініціалізацію сокета і store: сокет повинен створюватися першим, оскільки він нам знадобиться в ході ініціалізації store.

Залишилося тільки, щоб middleware згенерувало подію action:

src/remote_action_middleware.js
export default socket => store => next => action => {
  socket.emit('action', action);
  return next(action);
}

От і все! Тепер при кліці на одну з кнопок голосування ви побачите в тому ж вікні браузера оновлення поточних результатів. Те ж саме відбудеться і в інших браузерах, в яких буде запущено програму. Голос зареєстрований!

Але тут є одна проблема: коли ми отримуємо з сервера оновлення стану і передаємо дію SET_STATE, воно також потрапляє на сервер. І хоча він нічого не робить з цією дією, але все ж його одержувач запитів спрацьовує, генеруючи нове SET_STATE. Виходить нескінченний цикл.

Middleware віддаленого action не повинен відправляти на сервер кожна дія. Деякі з них, на зразок SET_STATE, повинні оброблятися локально, на клієнті. Нехай посередник відправляє на сервер тільки конкретні дії, що містять властивість {meta: {remote: true}}:

(цей підхід взято з прикладів rafScheduler з документації middleware )

src/remote_action_middleware.js
export default socket => store => next => action => {
  if (action.meta && action.meta.remote) {
    socket.emit('action', action);
  }
  return next(action);
}

Творець дії повинен призначати це властивість для VOTE, і не повинен для SET_STATE:

src/action_creators.js
export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    meta: {remote: true},
    type: 'VOTE',
    entry
  };
}

Резюмуємо, що відбувається:

  1. Користувач клацає кнопку голосування. Передається дію VOTE.
  2. Middleware віддаленої дії відправляє action через Socket.io-підключення.
  3. Дія обробляється клієнтським Redux store, в результаті чого призначається локальне стан hasVote.
  4. Коли повідомлення приходить на сервер, то серверний Redux store обробляє action і оновлює голос в поточних результатах.
  5. Одержувач запитів в серверному store транслює снепшот стану всім підключеним клієнтам.
  6. У Redux store кожного з підключених клієнтів передаються дії SET_STATE.
  7. Вони обробляються сховищами на основі оновленого сервером стану.

Для завершення нашої програми залишилося тільки змусити працювати кнопку “Next”. На сервері вже є необхідна логіка, як і в модулі голосування. Залишилося тільки з’єднати їх один з одним.

Творець дії для NEXT повинен створювати віддалене дію правильного типу:

src/action_creator.js
export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    meta: {remote: true},
    type: 'VOTE',
    entry
  };
}

export function next() {
  return {
    meta: {remote: true},
    type: 'NEXT'
  };
}

Творці дій підключаються у вигляді властивостей до компоненту ResultsContainer:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import * as actionCreators from '../action_creators';

export const Results = React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
        </div>
      </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    tally: state.getIn(['vote', 'tally']),
    winner: state.get('winner')
  }
}

export const ResultsContainer = connect(
  mapStateToProps,
  actionCreators
)(Results);

Ну от і все! Тепер наше додаток повністю готовий і функціонує. Спробуйте відкрити екран результатів на комп’ютері та екран голосування на мобільному пристрої. Ви побачите, що після виконання дії на одному пристрої результат відразу відображається на іншому. Чарівне видовище. Натисніть на кнопку «Next» на екрані результатів і подивіться, як просувається голосування на іншому пристрої.

Переклад статті Руководство по работе с Redux