SoundCloud клиент на React+Redux

SoundCloud клиент на React+Redux
mangohost

В начале 2016 года я глубоко погрузился в мир ReactJS. Я читал тонны статей про React и его среду окружения, особенно Redux. Некоторые мои коллеги использовали его в сторонних проектах и на теоретическом уровне я мог бы поучаствовать с ними в дискуссиях.

На тот момент, в моей компании основной упор был на Angular. Так как у нас он используется в довольно большой кодовой базе, мы знаем о его недостатках. В 2015 году у нас уже была принята собственная flux-архитектура в Angular приложениях с использованием хранилищ (store) и однонаправленным потоком данных. Разумеется мы были хорошо осведомлены обо всех изменениях окружающей среды React в ближайшем времени.

Разработка FaveSound - простого клиента для SoundCloud, заняла у меня несколько недель. Являясь активным пользователем SoundCloud я был убеждён, что должен сделать собственный клиент для него на ReactJS + Redux.

Я вырос, с профессиональной точки зрения, а также вошёл в OpenSource сообщество предоставив ему большой пример для начинающих в использовании React + Redux. Так как я сам получил огромный опыт в этой сфере, я хотел предоставить людям руководство, которое направит и поможет начать работать с React + Redux на реальном примере.

В конце этого учебника вы можете рассчитывать на запуск React + Redux приложения, которое использует SoundCloud API. Через это приложение вы сможете заходить на SoundCloud под своим аккаунтом, смотреть список ваших последних треков и слушать их в браузере. Кроме того, вы узнаете много нового о таких инструментах как Webpack и Babel.

Содержание

  1. Проект с нуля
  2. Зависимости
  3. Давайте начнём
  4. Настройка Webpack
    1. Hot Reloading
  5. Babel
  6. Первый React Component
  7. Настройка тестов
  8. Redux
    1. Redux Cycle
    2. Отправка действий (Dispatching an Action)
    3. Константы типов действий (Constant Action Type)
    4. Создатели действий (Action Creators)
    5. Редьюсеры (Reducers)
    6. Store с глобальным состоянием
  9. Соединяем Redux и React
    1. Провайдер (Provider)
    2. Подключение
    3. Контейнер и компонент Presenter
  10. SoundCloud App
    1. Регистрация
    2. React Router
    3. Aвторизация
    4. Redux Thunk
    5. Set Me
    6. Получение треков
  11. SoundCloud Player
    1. Новый Redux Cycle
    2. Слушаем музыку!
  12. Заключительное слово

Проект с нуля

Я должен сказать, что многому научился когда реализовывал проект с нуля. А это делает разработку с  нуля совершенно осмысленной. Вы узнаете тонну нового материла не только о React + Redux, но и о JavaScript в целом, об его окружении. В этом руководстве мы будем учиться в процессе работы, стараться понять каждый шаг, так же, как это было со мной, когда я делал этот проект, но только с некоторыми полезными объяснениями. По окончании вы сможете создать собственный сторонний React + Redux проект.

Зависимости

Инструменты, которые я использовал в ходе реализации FaveSound:

  • Терминал (например, iTerm);
  • Текстовый редактор (например, Sublime Text);
  • Node и NPM;
node --version
*v5.0.0
npm --version
*v3.3.6

В связи с тем, что зависимости в проекте могут измениться, ниже предоставлен список зависимостей, которые вам придётся установить в этом руководстве. Пожалуйста сообщите, если в будущем появятся значительные изменения.

package.json

"devDependencies": {
  "babel-core": "^6.9.1",
  "babel-loader": "^6.2.4",
  "babel-preset-es2015": "^6.9.0",
  "babel-preset-react": "^6.5.0",
  "babel-preset-stage-2": "^6.5.0",
  "chai": "^3.5.0",
  "enzyme": "^2.3.0",
  "exports-loader": "^0.6.3",
  "imports-loader": "^0.6.5",
  "jsdom": "^9.2.1",
  "mocha": "^2.5.3",
  "react-addons-test-utils": "^15.1.0",
  "react-hot-loader": "^1.3.0",
  "webpack": "^1.13.1",
  "webpack-dev-server": "^1.14.1"
},
"dependencies": {
  "react": "^15.1.0",
  "react-dom": "^15.1.0",
  "react-redux": "^4.4.5",
  "react-router": "^2.4.1",
  "react-router-redux": "^4.0.5",
  "redux": "^3.5.2",
  "redux-logger": "^2.6.1",
  "redux-thunk": "^2.1.0",
  "soundcloud": "^3.1.2",
  "whatwg-fetch": "^1.0.0"
}

Давайте начнём

Примечание: В последнее время, команда React'а выпустила более лёгкий и официально поддерживаемый способ создания приложений с React, Webpack и Babel. Вы можете использовать инструмент Create React App чтобы создать проект без конфигурации. Тем не менее, следующие главы этой статьи расскажут вам, как настроить Webpack и Babel в вашем проекте с нуля. 

Цель этой главы заключается в установке проекта. Мы создаём новую папку и инициализируем в ней новый npm проект.

Введите в ваш терминал следующие:

mkdir sc-react-redux
cd sc-react-redux
npm init -y

Последняя команда должна создать package.json файл, в котором будет находится список установленных пакетов и скрипты для сборки приложения.

Сейчас мы создадим папку dist, которая в дальнейшем будет обслуживать наше одностраничное приложение (SPA - Singe Page Application). Всё наше SPA приложение будет состоять из двух файлов: .html и .js. Пока что мы можем создать наш .html файл, который будет служить точкой входа для нашего приложения, а файл .js будет генерироваться позже, из всех наших исходных файлов (через Webpack).

Примечание: Папка dist нужна для того, чтобы в дальнейшем мы могли опубликовать наше приложение на хостинге, так, как я это сделал с FaveSound.

Из корневой папки проекта выполните следующие команды:

mkdir dist
cd dist
touch index.html

dist/index.html

<!DOCTYPE html>
<html>
  <head>
      <title>SoundCloud React Redux App</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="bundle.js"></script>
  </body>
</html>

Два важных замечания:

  • Файл bundle.js будет создан через Webpack автоматически.
  • Атрибут id="app" поможет нашему корневому React компоненту найти свою точку входа.

Возможные, дальнейшие шаги в этом руководстве:

  1. Установить Webpack и объединить наши исходные файлы в один файл bundle.js;
  2. Разработать наш первый, корневой React компонент, который будет использовать нашу точку входа id="app";

Давайте пойдём по порядку.

Настройка Webpack

Мы будем использовать WebPack в качестве инструмента для сборки. Кроме этого, мы будем использовать webpack-dev-server, для обслуживания уже собранного приложения в нашей локальной среде.

Из корневой папки проекта выполните следующие команды:

npm install --save-dev webpack webpack-dev-server

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

Структура проекта:

- dist
-- index.html
- node_modules
- package.json

В файл package.json нам нужно добавить скрипт запуска, в дополнение к скриптам по умолчанию, чтобы запускать наш webpack-dev-server.

package.json

...
"scripts": {
    "start": "webpack-dev-server --progress --colors --hot --config ./webpack.config.js",
    ...
},
...

Мы указали, что хотим использовать webpack-dev-server с некоторыми базовыми параметрами и файлом конфигурации webpack.config.js.

Теперь нужно создать этот файл конфигурации.

Из корневой проекта:

touch webpack.config.js

webpack.config.js

module.exports = {
  entry: [
    './src/index.js'
  ],
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist'
  }
};

Грубо говоря, этот файл конфигурации описывает то, что (1) мы хотим использовать файл src/index.js в качестве точки входа для объединения всех наших файлов. (2) Сборка проекта в результате приведёт к файлу bundle.js, который будет создан в папке /dist. (3) Папка /dist будет использоваться для обслуживания нашего приложения.

В нашем проекте не хватает файла src/index.js. Нужно его создать.

Из корневой папки:

mkdir src
cd src
touch index.js

src/index.js

console.log('My SoundCloud React Redux App');

Структура проекта:

- dist
-- index.html
- node_modules
- src
-- index.js
- package.json
- webpack.config.js

Теперь мы можем запустить наш webpack-dev-server, открыть наше приложение в браузере  (по умолчанию localhost:8080) и посмотреть на вывод в Developer Console.

Из корневой папки запустите команду:

npm start

Наше приложение обслуживается через Webpack! Мы собираем наши файлы через точку входа src/index.js в bundle.js и подключаем его в dist/index.html, после чего мы можем видеть уже собранный вывод в Developer Console.

Hot reloading

Огромное ускорение в разработке нам даст react-hot-loader, который сократит время обратной связи между сервером и браузером. В двух словах, каждый раз, когда мы что-то изменим в нашем исходном коде, все изменения отобразятся в браузере без перезагрузки страницы.

Перейдите в корневую папку проекта и выполните следующую команду:

npm install --save-dev react-hot-loader

webpack.config.js

module.exports = {
  entry: [
    'webpack-dev-server/client?http://localhost:8080',
    'webpack/hot/only-dev-server',
    './src/index.js'
  ],
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist',
    hot: true
  }
};

Опять же из корневой папки проекта:

npm start

В этот раз вы также увидите вывод информации в консоль, но на этот раз её будет больше, так как там будет присутствовать информация о hot reloading'е. Мы почти закончили подготовку к написанию нашего первого React компонента, но всё-таки ещё одного кирпичика не хватает.

Babel

Babel позволяет писать наш код на ES6 (ES2015). В дальнейшем код будет скомпилирован в ES5, чтобы браузеры, у которых нет поддержки ES6, могли нормально его интерпретировать.

Кроме того, мы хотим использовать некоторые экспериментальные особенности ES6 (например, object spread), которые могут быть активированы с помощью stages.

Ну и в конце концов, так как мы используем React, нам нужна ещё одна конфигурация, чтобы конвертировать родные для React .jsx файлы в .js.

Из корневой папки проекта:

npm install --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react babel-preset-stage-2

Теперь нам необходимо подкорректировать package.json и webpack.config.js файлы и внести изменения, связанные с Babel. Эти изменения включают в себя все выше перечисленные функции.

package.json

...
"keywords": [],
"author": "",
"license": "ISC",
"babel": {
  "presets": [
    "es2015",
    "react",
    "stage-2"
  ]
},
"devDependencies": {
...

webpack.config.js

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
  }
};

Запущенный npm скрипт прямо сейчас нужно приостановить, так как наше приложение ещё ничего не знает о React. Но теперь мы готовы написать наш первый React компонент, поэтому давайте начнём.

Примечание переводчика: В связи с обновлением некоторых пакетов, после того, как вы проделаете эти действия, скорее всего у вас в консоли появится сообщение об ошибке:

ERROR in ./src/index.js
Module build failed: Error: React Hot Loader: The Webpack loader is now exported separately. If you use Babel, we recommend that you remove "react-hot-loader" from the "loaders" section of your Webpack configuration altogether, and instead add "react-hot-loader/babel" to the "plugins" section of your .babelrc file. If you prefer not to use Babel, replace "react-hot-loader" or "react-hot" with "react-hot-loader/webpack" in the "loaders" section of your Webpack configuration.

Чтобы его исправить, вам нужно заменить loader в файле webpack.config.js на babel-loader. Чтобы получилось следующее:

...
module: {
  loaders: [{
    test: /\.jsx?$/,
    exclude: /node_modules/,
    loader: 'babel-loader'
  }]
},
...

И в файле package.json нужно добавить плагин в секцию babel.

...
"keywords": [],
"author": "",
"license": "ISC",
"babel": {
  "presets": [
    "es2015",
    "react",
    "stage-2"
  ],
  "plugins": ["react-hot-loader/babel"]
},
"devDependencies": {
...

Первый React Component

После установки react и react-dom наша сборка должна работать так же, как и до этого.

npm install --save react react-dom

В нашем src/index.js мы можем реализовать нашу первую зацепку в мире React и избавиться от скучного вывода в консоль.

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import Stream from './components/Stream';

const tracks = [
  {
    title: 'Some track'
  },
  {
    title: 'Some other track'
  }
];

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

С помощью функции ReactDOM.render мы получаем элемент с id "app" из нашего файла index.html. Наш первый компонент Stream будет отображён в этом месте. К тому же, мы предоставляем нашему компоненту Stream жёстко зашитый в коде массив неизменяемых объектов Track (что логично, так как мы собираемся написать клиент для SoundCloud). Компонент Stream сам по себе будет показывать список треков. Но пока что у нас нет компонента Stream, так что давайте его реализуем.

Из папки src:

mkdir components
cd components
touch Stream.js

У нашей папки src начинает появляться структура. Мы организовываем наши файлы, технически разделяя их. Начнём с папки components, но позже добавим несколько папок.

Давайте изменим наш недавно созданный файл.

src/components/Stream.js

import React from 'react';

function Stream({ tracks = [] }) {
  return (
    <div>
      {
        tracks.map((track) => {
          return <div className="track">{track.title}</div>;
        })
      }
    </div>
  );
}

export default Stream;

Наш компонент Stream представляет из себя функциональный компонент без состояния. Он получает массив tracks в аргументах, который по умолчанию является пустым массивом (это возможно благодаря ES6). Метод map оборачивает массив tracks и возвращает div'ы с track.title в качестве содержимого. Называется он функциональным компонентом без состояния (stateless functional component), потому что он только получает данные на вход и формирует данные на выходе. Компонент Stream ничего не знает о состоянии всего приложения (stateless). Это всего лишь функция, которая получает данные на вход и формирует данные на выходе - если быть более конкретными: у нас есть функция, которая получает состояние, а возвращает представление: (State) => View.

Структура проекта:

- dist
-- index.html
- node_modules
- src
-- components
--- Stream.js
-- index.js
- package.json
- webpack.config.js

Когда вы запустите ваше приложение снова, в браузере вы должны увидеть два заголовка треков. Кроме этого, информация выведенная в консоль, даст подсказку о том, что параметр key отсутствует. React компонентам необходим параметр key чтобы однозначно идентифицировать себя в списке компонентов. Давайте это исправим, сохраним файл и посмотрим как подключится hot reloading и перезагрузит страницу.

src/components/Stream.js

import React from 'react';

function Stream({ tracks = [] }) {
  return (
    <div>
      {
        tracks.map((track, key) => {
          return <div className="track" key={key}>{track.title}</div>;
        })
      }
    </div>
  );
}

export default Stream;

Готово. Мы написали наш первый React код.

Мы сделали уже много всего предыдущих главах. Давайте обобщим это в краткие заметки:

  • Мы используем webpack + webpack-dev-server для сборки и обслуживания нашего приложения;
  • Мы используем Babel:
    1. Чтобы писать в стиле ES6;
    2. Чтобы у нас были .js файлы, а не .jsx;
  • src/index.js файл используется Webpack'ом как точка входа для сборки всего, что подключается через import, в один файл bundle.js;
  • bundle.js используется в dist/index.html;
  • dist/index.html предоставляет нам идентификатор в качестве точки входа для нашего корневого React компонента;
  • Мы создали нашу первую React зацепку через атрибут id в файле src/index.js;
  • Мы реализовали наш первый дочерний компонент как функциональный компонент без состояния в src/components/Stream.js;

Настройка тестов

Я хочу показать вам простую настройку тестов для ваших React компонентов. Мы будем тестировать компонент Stream.

Мы будем использовать mocha в качестве фреймворка тестирования, chai как asserts библиотеку и jsdom для предоставления нам чистой реализации JavaScript DOM, которая работает в Node.

Из корневой папки:

npm install --save-dev mocha chai jsdom

Ещё нам нужен файл конфигурации для дополнительной настройки тестов, особенного для нашего Virtual DOM.

Из корневой папки:

mkdir test
cd test
touch setup.js

test/setup.js

import React from 'react';
import { expect } from 'chai';
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];
  }
});

global.React = React;
global.expect = expect;

По сути, мы глобально раскрываем созданный jsdom'ом документ и объект window, которые могут быть использованы React'ом во время тестов. Потом, мы раскрываем все свойства объекта window, чтобы в дальнейшем наши запущенные тесты могли использовать их. Последнее, но не менее важное, мы даём глобальный доступ к объектам React и expect. Это позволит нам не импортировать их в каждом из наших тестов.

В package.json нам нужно добавить ещё один скрипт для запуска наших тестов, который поддерживает Babel, использует mocha, использует наш ранее написанный test/setup.js файл и обходит все наши файлы в папке src с суффиксом spec.js.

...
  "scripts": {
    "start": "webpack-dev-server --progress --colors --hot --config ./webpack.config.js",
    "test": "mocha --compilers js:babel-core/register --require ./test/setup.js 'src/**/*spec.js'"
  },
...

В дополнение, существуют более аккуратные библиотеки, которые могут помочь нам с тестами React компонентов. Enzyme от Airbnb - это на данный момент статус-кво библиотека для тестирования React компонентов. Она основана на react-addons-test-utils и react-dom (последний мы уже установили через npm). Давайте установим оставшиеся.

Из корневой папки проекта:

npm install --save-dev react-addons-test-utils enzyme

Сейчас мы готовы написать наш первый тест для компонента.

touch Stream.spec.js

src/components/Stream.spec.js

import Stream from './Stream';
import { shallow } from 'enzyme';

describe('Stream', () => {

  const props = {
    tracks: [{ title: 'x' }, { title: 'y' }],
  };

  it('shows two elements', () => {
    const element = shallow(<Stream { ...props } />);

    expect(element.find('.track')).to.have.length(2);
  });

});

Здесь мы тестируем наш компонент Stream, массивом из двух треков. Как мы знаем, эти треки должны быть отображены. Утверждение expect проверяет, отобразились ли два элемента с классом .track. Если мы запустим наши тесты, они должны успешно пройти.

Из корневой директории:

npm test

Кроме того, мы можем усовершенствовать наш package.json с помощью скрипта test:watch.

...
  "scripts": {
    "start": "webpack-dev-server --progress --colors --hot --config ./webpack.config.js",
    "test": "mocha --compilers js:babel-core/register --require ./test/setup.js ‘src/**/*spec.js’”,
    "test:watch": "npm run test -- --watch"
  },
...

При запуске сценария мы видим, что тесты выполняются при каждом внесении изменений в исходный код.

Из корневой директории:

npm run test:watch

Структура проекта:

- dist
-- index.html
- node_modules
- src
-- components
--- Stream.js
--- Stream.spec.js
-- index.js
- test
-- setup.js
- package.json
- webpack.config.js

Мы не будем больше писать тесты в этом руководстве, но один у нас уже есть. Не стесняйтесь добавлять тесты сами в последующих главах.

Redux

Redux позиционирует себя как предсказуемый контейнер состояний для JavaScript приложений. Большую часть времени, в клиентских приложениях, вы будете наблюдать Redux в связке с React. На самом деле он может гораздо больше. Как сам JavaScript используется на стороне сервера или в IoT приложениях, так и Redux может использоваться везде где нужен предсказуемый контейнер состояний. Дальше вы увидите, что Redux не связан строго с React, так как у него есть свой отдельный модуль, в то же время вы можете установить любой другой модуль и подключить его к React. Также существуют модули чтобы подключать Redux к других платформам. Кроме этого, экосистема вокруг Redux просто огромна. Как только вы погрузитесь в неё, вы узнаете много новых вещей. В целом, это не просто ещё одна библиотека. Вам нужно заглянуть во внутрь чтобы понять, какую именно проблему он будет решать для вас. Только в этом случае нужно его использовать! Если он не решает ваших проблем, просто не используйте его.

На данный момент, я хочу сказать большое спасибо Dan Abramov, создателю Redux, который не только предоставил нам простой и продуманный способ контролировать наши состояния, но ещё и показывает нам как делать огромный вклад в OpenSource ежедневно. Посмотрите его доклад с React Europe 2016, где он рассказывает о том какой путь прошёл Redux и что сделало его успешным.

Redux Cycle

Я называю это Redux Cycle, потому что он позволяет использовать однонаправленный поток данных. Redux Cycle вырос из flux архитектуры. В общем, вы вызываете действие в компоненте, это может быть кнопкой или что-то ещё. Кто-то это действие слушает, использует его данные, и создаёт новый глобальный объект состояния, который может быть предоставлен всем компонентам. Компоненты могут обновлять цикл и завершаться.

Давайте приступим к реализации нашего первого цикла в Redux!

Из корня проекта:

npm install --save redux redux-logger

Отправка действий (Dispatching an Action)

Давайте отправим наше первое действие и объясним некоторые вещи.

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import configureStore from './stores/configureStore';
import * as actions from './actions';
import Stream from './components/Stream';

const tracks = [
  {
    title: 'Some track'
  },
  {
    title: 'Some other track'
  }
];

const store = configureStore();
store.dispatch(actions.setTracks(tracks));

ReactDOM.render(
  <Stream />,
  document.getElementById('app')
);

Как вы можете видеть, мы инициализировали объект store с некоторыми функциями, которые мы ещё не объявили. Store - это экземпляр Redux объекта и хранилище нашего глобального состояния. К тому же, можно использовать лёгкий Store API чтобы отправлять действия, получать состояние или подписываться на события при изменениях.

В нашем случае, мы отправляем первое действие с нашими жёстко зашитыми в коде треками. Так как мы хотим подключить компонент Stream к хранилищу позже, нам не нужно передавать треки в качестве параметров (атрибутов) компонента.

Что мы будем делать дальше? В принципе, мы можем написать нашу функцию configureStore, которая создаёт объект store, или же мы можем посмотреть на наше первое отправленное действие. Продолжим с последнего, объясним что такое действия и создатели действий (action creators), потом перейдём к редьюсерам (reducers), которые будут заниматься (изменять) глобальным объектом состояния, ну и в конце мы создадим хранилище, который собственно и будет хранить глобальный объект состояния. После этого наш компонент сможет подписаться на хранилище, чтобы получать обновления или использовать интерфейс для отправки новых действий при изменении состояния.

Константы типов действий (Constant Action Types)

На ранних этапах Redux проектов, вы часто будете останавливаться на константах для определения ваших действий (экшенов). Эти константы становятся доступными для действий и редьюсеров. Вообще, хороший подход когда константы действий, которые описывают изменения вашего глобального состояния, находятся в одном месте.

Примечание: Если ваш проект разрастается , существуют и другие способы организации папок/файлов, в Redux проектах. 

Из папки src, выполните следующие команды:

mkdir constants
cd constants
touch actionTypes.js

src/constants/actionTypes.js

export const TRACKS_SET = 'TRACKS_SET';

Создатели действий (Action Creators)

Теперь мы перейдём к шаблону создателей действий. Создатели действий возвращают объект с типом и данными. Тип - это константа, такая же как та которую мы объявляли в предыдущей главе. Данные могут быть чем угодно, что будет изменять глобальное состояние.

Из папки src, выполните следующие команды:

mkdir actions
cd actions
touch track.js

src/actions/track.js

import * as actionTypes from '../constants/actionTypes';

export function setTracks(tracks) {
  return {
    type: actionTypes.TRACKS_SET,
    tracks
  };
};

Наш первый создатель действий принимает в качестве параметров треки, которые мы хотим установить в глобальное состояние и возвращает тип действия и данные.

Для того чтобы сохранить нашу аккуратную структуру, нам нужно установить точку входа для наших создателей действий через файл index.js.

Из папки actions:

touch index.js

src/actions/index.js

import { setTracks } from './track';

export {
  setTracks
};

В этом файле мы будем собирать все наши создатели действий и экспортировать их как открытый интерфейс в остальную часть приложения. Всякий раз, когда нам нужно получить доступ к какому-то создателю действий откуда-то ещё, у нас для этого есть чётко определённый интерфейс. Не надо обращаться непосредственно к самим файлам создателей действий.

Редьюсеры (Reducers)

После того как мы отправили наше первое действие и написали наш первый создатель действий, кто-то должен быть в курсе того, что типы действий имеют доступы к глобальному состоянию. Эти функции называются редьюсерами, потому что они принимают действие с типом и данными, и приводят (reduce) его к новому состоянию (previousState, action) => newState.

Важно: Вместо того чтобы изменять previousState, мы возвращаем новый объект newState, так как состояние - неизменяемое.

Вы никогда не будете изменять previousState и всегда будете возвращать новый объект newState. Вам нужно сохранять структуру данных неизменной, чтобы избежать каких-либо побочных эффектов в приложении.

Давайте создадим наш первый редьюсер.

Из папки src:

mkdir reducers
cd reducers
touch track.js

src/reducers/track.js

import * as actionTypes from '../constants/actionTypes';

const initialState = [];

export default function(state = initialState, action) {
  switch (action.type) {
    case actionTypes.TRACKS_SET:
      return setTracks(state, action);
  }
  return state;
}

function setTracks(state, action) {
  const { tracks } = action;
  return [ ...state, ...tracks ];
}

Как вы можете видеть из примера выше, мы экспортируем анонимную функцию, редьюсер, как интерфейс к нашему уже существующему приложению. Редьюсер получает состояние и действие, как это было описано выше. Кроме того, мы можем определить параметр по умолчанию, благодаря ES6, в нашем случае это пустой массив.

Сам редьюсер содержит в себе конструкцию switch case, чтобы разделять типы действий. Сейчас он содержит в себе только один тип, но эта конструкция будет расти в соответствии с появлением новых типов в нашем приложении.

В конце концов, мы используем ES6 оператор spread (Примечание переводчика: в коде он выглядит как многоточие "...") чтобы сложить наше предыдущее состояние и переданные параметром треки в новый возвращаемый объект. Мы используем оператор spread, чтобы наш объект оставался неизменяемым. Я могу посоветовать библиотеку Immutable.js для обеспечения неизменных структур данных, но для простоты я буду использовать чистый синтаксис ES6.

Опять же чтобы сохранить аккуратную структуру нашего приложения, мы создадим точку входа для наших редьюсеров.

Из директории reducers:

touch index.js

src/reducers/index.js

import { combineReducers } from 'redux';
import track from './track';

export default combineReducers({
  track
});

Чтобы спасти нас от рефакторинга, я уже использую здесь функцию combineReducers. Обычно, нужно экспортировать один простой редьюсер и он будет возвращать всё состояние. При использовании combineReducers, у вас может быть несколько редьюсеров и каждый из них будет возвращать подсостояние (substate). Без combineReducers вы могли бы получить треки через глобальное состояние вот так:

state.tracks

Но с combineReducers у вас появится промежуточный слой чтобы добраться до подмножества состояний, создаваемый различными редьюсерами. В этом случае:

state.track.tracks

где track это подсостояние для обработки всех состояний треков в будущем.

Store c глобальным состоянием

Сейчас мы уже отправили наше первое действие, создали пару типов действий и создатель действий, и генерировали новое состояние с помощью редьюсера. Чего-то не хватает в нашем хранилище, который мы уже создали без некоторых ещё нереализованных функций в src/index.js.

Помните когда мы отправили наше первое действие через интерфейс хранилища store.dispatch(actionCreator(payload))? Таким образом хранилище в курсе наших редьюсеров и манипуляций со состоянием.

Давайте создадим файл хранилища.

Из папки src.

mkdir stores
cd stores
touch configureStore.js

src/stores/configureStore.js

import { createStore, applyMiddleware } from 'redux';
import createLogger from 'redux-logger';
import rootReducer from '../reducers/index';

const logger = createLogger();

const createStoreWithMiddleware = applyMiddleware(logger)(createStore);

export default function configureStore(initialState) {
  return createStoreWithMiddleware(rootReducer, initialState);
}

Redux предоставляет нам функцию createStore.  В конце концов, нам остаётся только вернуть созданное через combineReducers хранилище store = createStore(rootReducer, initialState = []).

В нашем файле происходит немного больше, чем просто возвращение готового Redux приложения. Redux хранилище знает о middleware, который может быть использован для того, чтобы что-то сделать между отправкой действия и моментом когда когда будет достигнут редьюсер. Есть куча middleware для Redux, но мы для начала будем использовать только logger middleware. logger middleware выводит в консоль информацию о каждом действии:  previousState, само действие и  nextState. Это помогает следить за состоянием приложения.

Ну и в конце мы используем вспомогательную функцию applyMiddleware чтобы связать наше хранилище с middleware, который мы указали.

Давайте запустим наше приложение и посмотрим, что получилось.

Из корня проекта:

npm start

В браузере мы не увидим треков из нашего глобального состояния, потому что не передаём глобального состояние в наш компонент Stream. Но в консоли мы можем увидеть наше первое действие, которое было отправлено.

Давайте соединим наш компонент Stream с наших Redux контейнером, чтобы завершить Redux cycle.

Соединяем Redux и React

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

npm install --save react-redux

Вы помните я рассказывал о лёгком Store API в Redux? Мы никогда не будем наслаждаться функциональностью store.subscribe для прослушивания изменений хранилища. С react-redux мы пропускаем этот шаг и позволяем этой библиотеке позаботиться о подключении компонентов в хранилищу для прослушивания изменений.

По сути нам надо выполнить всего два шага, чтобы связать наше Redux хранилище с React компонентами.

Провайдер (Provider)

Провайдер из react-redux помогает нам сделать хранилище и его функциональные возможности доступными во всех его дочерних компонентах. Единственное, что мы должны сделать, это инициализировать наше хранилище и обернуть наши дочерние компоненты в компонент Provider. В конце компонент Provider использует хранилище как свойство.

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from './stores/configureStore';
import * as actions from './actions';
import Stream from './components/Stream';

const tracks = [
  {
    title: 'Some track'
  },
  {
    title: 'Some other track'
  }
];

const store = configureStore();
store.dispatch(actions.setTracks(tracks));

ReactDOM.render(
  <Provider store={store}>
    <Stream />
  </Provider>,
  document.getElementById('app')
);

Сейчас мы сделали Redux хранилище доступным для всех дочерних компонентов, в нашем случае для компонента Stream.

Подключение

Подключение функциональных возможностей из react-redux помогает нам связать компоненты, которые встроены в вспомогательный компонент Provider, с Redux хранилищем. Мы можем расширить наш компонент Stream следующим образом, чтобы получить требуемое состояние из Redux хранилища.

Помните, когда у нас были жёстко зашитые в коде треки напрямую передаваемые в компонент Stream? Сейчас мы устанавливаем эти треки через Redux cycle в нашем глобальном состоянии и хотим получать часть этого состояния в компоненте Stream.

src/components/Stream.js

import React from 'react';
import { connect } from 'react-redux';

function Stream({ tracks = [] }) {
  return (
    <div>
      {
        tracks.map((track, key) => {
          return <div className="track" key={key}>{track.title}</div>;
        })
      }
    </div>
  );
}

function mapStateToProps(state) {
  const tracks = state.track;
  return {
    tracks
  }
}

export default connect(mapStateToProps)(Stream);

Как вы можете видеть, сам компонент не изменился вообще.

Если в двух словах то, мы используем функцию connect чтобы получить наш компонент Stream в качестве параметра для возвращаемого компонента высшего порядка. У компонента высшего порядка есть доступ к Redux хранилищу, в том время как компонент Stream может только представлять наши данные.

Кроме того, функция connect в качестве первого аргумента принимает функцию mapStateToProps, которая возвращает объект. Объект представляет собой часть нашего глобального состояния. В mapStateToProps мы раскрываем только ту часть глобального состояния, которая необходима компоненту.

К тому же стоит отметить, что у нас до сих пор есть доступ к свойствам из родительских компонентов с помощью <Stream something={thing} /> через функцию mapStateToProps. Функции предоставляют нам вторым аргументом свойства, которые мы можем передать с подсостоянием самому компоненту Stream.

function mapStateToProps(state, props) { … }

Теперь запустите приложение и на этот раз вы должны увидеть отображённый список треков в вашем браузере. Мы уже видели эти треки на предыдущем шаге, но теперь мы достали их из нашего Redux хранилища.

Тест должен провалиться, но мы исправим это на следующем шаге.

Контейнер и компонент Presenter

Сейчас у нашего компонента Stream есть две обязанности. Во-первых он подключает какое-то состояние к нашему компоненту, а во-вторых он отображает некоторый DOM. Мы можем разделить это на две части, контейнер и ведущий (presenter) компонент. Контейнер будет отвечать за подключение компонента к миру Redux, а ведущий компонент будет отображать некоторый DOM.

Давайте перепишем!

Для начала нам нужно организовать нашу папку. Так как в конечном итоге для компонента Stream у нас будет не один файл, нам нужно создать специальную папку Stream, в которой будут находится все файлы компонента.

Из папки components:

mkdir Stream
cd Stream
touch index.js
touch presenter.js
touch spec.js

Папка Stream содержит в себе index.js файл (контейнер), presenter.js (ведущий компонент) и spec.js файл (для тестов). В дальнейшем туда можно будет положить style.css/less/scss, story.js и т.д файлы.

Давайте перепишем все файлы. Так как появятся новые строки кода, я выделил важные новые, важные части, которые придут с этим рефакторингом. Большая часть старого кода будет просто разделена по файлам.

src/components/Stream/index.js

import React from 'react';
import { connect } from 'react-redux';
import Stream from './presenter';

function mapStateToProps(state) {
  const tracks = state.track;
  return {
    tracks
  }
}

export default connect(mapStateToProps)(Stream);

src/components/Stream/presenter.js

import React from 'react';

function Stream({ tracks = [] }) {
  return (
    <div>
      {
        tracks.map((track, key) => {
          return <div className="track" key={key}>{track.title}</div>;
        })
      }
    </div>
  );
}

export default Stream;

src/components/Stream/spec.js

import Stream from './presenter';
import { shallow } from 'enzyme';

describe('Stream', () => {

  const props = {
    tracks: [{ title: 'x' }, { title: 'y' }],
  };

  it('shows two elements', () => {
    const element = shallow(<Stream { ...props } />);

    expect(element.find('.track')).to.have.length(2);
  });

});

Сейчас вы можете удалить старые файлы Stream.js и Stream.spec.js, так как они были переработаны и теперь находятся в папке Stream.

После запуска приложения вы до сих пор должны видеть список треков.

В последних шагах мы завершили Redux Cycle и подключили наши компоненты к среде Redux. Сейчас давайте погрузимся в разработку реального приложения - клиента SoundCloud.

SoundCloud App

Нет ничего лучше чем разработка настоящего приложения основанного на реальных данных. Вместо того чтобы жёстко зашивать данные для отображения в коде, лучше испытать потрясающее чувство и достать данные из хорошо известного сервиса SoundCloud.

В этой главе мы будем реализовывать наш клиент для SoundCloud, а это означает что мы будем авторизовываться как пользователь и выводить список последних треков из потока. Кроме того мы сможем воспроизводить их.

Регистрация

Перед тем как мы сможем разработать клиент для SoundCloud, нам необходимо иметь учётную запись на SoundCloud и зарегистрировать приложение. Зайдите в раздел разработчиков Developers SoundCloud и нажмите на ссылку "Register a new app".  Придумайте имя для приложения и зарегистрируйте его.  (Примечание переводчика: На самом деле сейчас нужно заполнить немного больше полей и выглядит всё по другому, но там всё интуитивно понятно)

Регистрация приложения в SoundCloud

На последнем шаге регистрации вам необходимо предоставить "Redirect URI" своего приложения, чтобы можно было регистрироваться и авторизовываться позже через всплывающую форму входа. Так как мы разрабатываем локально, то в качестве Redirect URI мы установим "http://localhost:8080/callback".

Регистрация приложения в SoundCloud

На предыдущем шаге мы получили две константы, которые мы должны использовать в нашем приложении: Client ID и Redirect URI. Нам необходимы обе константы чтобы настроить процесс авторизации. Перенесём эти константы в файл.

В папке constants:

touch auth.js

src/constants/auth.js

export const CLIENT_ID = '1fb0d04a94f035059b0424154fd1b18c'; // Use your client ID
export const REDIRECT_URI = `${window.location.protocol}//${window.location.host}/callback`;

Теперь мы можем авторизовываться с помощью SoundCloud:

npm --save install soundcloud

src/index.js

import SC from 'soundcloud';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from './stores/configureStore';
import * as actions from './actions';
import Stream from './components/Stream';
import { CLIENT_ID, REDIRECT_URI } from './constants/auth';

SC.initialize({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI });

const tracks = [
  {
    title: 'Some track'
  },
  {
    title: 'Some other track'
  }
];

const store = configureStore();
store.dispatch(actions.setTracks(tracks));

ReactDOM.render(
  <Provider store={store}>
    <Stream />
  </Provider>,
  document.getElementById('app')
);

React Router

В нашем приложении процесс авторизации будет опираться на маршрут "/callback". Поэтому нам надо установить React Router, чтобы обеспечить наше приложение простой маршрутизацией.

Из корня проекта:

npm --save install react-router react-router-redux

Теперь нам нужно добавить следующую строчку в файл конфигурации Webpack.

webpack.config.js

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,
    historyApiFallback: true
  }
};

historyApiFallback позволяет делать нашему приложению чистую маршрутизацию на стороне клиента. Как правило изменение маршрута приводит к запросу к серверу для получения новых ресурсов.

Давайте напишем два роута: один для нашего приложения, другой для обратного вызова и обработки авторизации. Поэтому мы используем некоторые вспомогательные компоненты предоставляемые react-router. В целом, нам нужно указать соответствие путей и компонентов. Поэтому мы определили, что будем видеть компонент Stream на корневом пути "/" и компонент обратного вызова на пути "/callback" (где происходит авторизация). Кроме того, мы можем указать обёртку для компонента, такую как App. В его реализации мы увидим почему это хорошо. Также мы используем react-router-redux для синхронизации истории браузера с хранилищем. Это поможет нам оперативно реагировать на изменения роутеров.

src/index.js

import SC from 'soundcloud';
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Route, IndexRoute, browserHistory } from 'react-router';
import { syncHistoryWithStore } from 'react-router-redux';
import { Provider } from 'react-redux';
import configureStore from './stores/configureStore';
import * as actions from './actions';
import App from './components/App';
import Callback from './components/Callback';
import Stream from './components/Stream';
import { CLIENT_ID, REDIRECT_URI } from './constants/auth';

SC.initialize({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI });

const tracks = [
  {
    title: 'Some track'
  },
  {
    title: 'Some other track'
  }
];

const store = configureStore();
store.dispatch(actions.setTracks(tracks));

const history = syncHistoryWithStore(browserHistory, store);

ReactDOM.render(
  <Provider store={store}>
    <Router history={history}>
      <Route path="/" component={App}>
        <IndexRoute component={Stream} />
        <Route path="/" component={Stream} />
        <Route path="/callback" component={Callback} />
      </Route>
    </Router>
  </Provider>,
  document.getElementById('app')
);

В конце у нас есть два новых компонента: App как обёртка и Callback для авторизации. Давайте создадим первый из них.

Из папки components:

mkdir App
cd App
touch index.js

src/components/App/index.js

import React from 'react';

function App({ children }) {
  return <div>{children}</div>;
}

export default App;

Здесь приложение почти ничего не делает, но оно передаётся всем дочерним компонентам. Мы больше не будем использовать этот компонент в данном руководстве, но в будущих реализациях вы бы могли его использовать для статичного Header, Footer, Playlist или Player компонентов, тогда как дочерние будут изменяться.

Давайте создадим наш Callback компонент.

В папке components, выполните следующие команды.

mkdir Callback
cd Callback
touch index.js

src/components/Calback/index.js

import React from 'react';

class Callback extends React.Component {

  componentDidMount() {
    window.setTimeout(opener.SC.connectCallback, 1);
  }

  render() {
    return <div><p>This page should close soon.</p></div>;
  }
}

export default Callback;

Это стандартная реализация создания обратного вызова для API SoundCloud. В дальнейшем мы больше не будем трогать этот файл.

Последний шаг настройки маршрутизатора (Router'а), это предоставление нашему хранилищу состояния маршрутизатора при навигации по страницам.

src/reducers/index.js

import { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux';
import track from './track';

export default combineReducers({
  track,
  routing: routerReducer
});

src/stores/configureStore.js

import { createStore, applyMiddleware } from 'redux';
import createLogger from 'redux-logger';
import { browserHistory } from 'react-router';
import { routerMiddleware } from 'react-router-redux';
import rootReducer from '../reducers/index';

const logger = createLogger();
const router = routerMiddleware(browserHistory);

const createStoreWithMiddleware = applyMiddleware(router, logger)(createStore);

export default function configureStore(initialState) {
  return createStoreWithMiddleware(rootReducer, initialState);
}

Помимо этого, мы еще синхронизируем наше хранилище с историей браузера, что собственно позволит нам в дальнейшем слушать события базирующиеся на текущем маршруте. Мы больше не будет использовать это в текущем руководстве, но это может помочь вам получать данные об изменении маршрута например. Ну и в дополнении, свойства такие как путь браузера или параметры запроса теперь будут доступны в хранилище.

Авторизация

Давайте авторизуемся через SoundCloud! Нам создать новое действие, чтобы вызвать событие авторизации. Давайте раскроем функцию auth и затем создадим требуемый для действия файл.

src/actions/index.js

import { auth } from './auth';
import { setTracks } from './track';

export {
  auth,
  setTracks
};

Из папки actions:

touch auth.js

src/actions/auth.js

import SC from 'soundcloud';

export function auth() {
  SC.connect().then((session) => {
    fetchMe(session);
  });
};

function fetchMe(session) {
  fetch(`//api.soundcloud.com/me?oauth_token=${session.oauth_token}`)
    .then((response) => response.json())
    .then((data) => {
      console.log(data);
    });
}

Мы можем подключится к SoundCloud API, войти на сайт с нашими учётными данными и увидеть в консоли детали о нашем аккаунте.

Никто не вызывал это действие, так что давайте сделаем это в нашем компоненте Stream, для простоты.

import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as actions from '../../actions';
import Stream from './presenter';

function mapStateToProps(state) {
  const tracks = state.track;
  return {
    tracks
  }
}

function mapDispatchToProps(dispatch) {
  return {
    onAuth: bindActionCreators(actions.auth, dispatch)
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Stream);

В нашем компоненте контейнера мы лишь сопоставили некоторые состояния с нашими ведущими (presenter) компонентами. Теперь речь идёт о второй функции, в которую мы можем передать соединяющую функцию mapDispatchToProps. Эта функция поможет нам передать действия нашему ведущему компоненту. В mapDispatchToProps мы возвращаем объект с функциями, в нашем случае с одной функцией onAuth, и используем ранее созданное действие auth. Кроме этого нам нужно связать создатель действий с функцией отправки.

Давайте сейчас используем новое доступное действие в нашем ведущем компоненте.

src/components/Stream/presenter.js

import React from 'react';

function Stream({ tracks = [], onAuth }) {
  return (
    <div>
      <div>
        <button onClick={onAuth} type="button">Login</button>
      </div>
      <br/>
      <div>
        {
          tracks.map((track, key) => {
            return <div className="track" key={key}>{track.title}</div>;
          })
        }
      </div>
    </div>
  );
}

export default Stream;

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

Примечание: Возможно нам потребуется установка полифилла для fetch, так как некоторые браузеры ещё не поддерживают fetch API.

Из корня проекта:

npm --save install whatwg-fetch 
npm --save-dev install imports-loader exports-loader

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,
    historyApiFallback: true
  },
  plugins: [
    new webpack.ProvidePlugin({
      'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch'
    })
  ]
};

Redux Thunk

Мы можем увидеть объект текущего пользователя в консоле, но мы его ещё не храним. Ещё мы используем наше первое асинхронное действие, потому что мы должны дождаться пока сервер SoundCloud ответит на наш запрос. Redux среда предоставляет несколько middleware для решения проблем с асинхронными действиями (смотрите список ниже). Одним из них является redux-thunk. thunk возвращает нам функцию вместо действия. Так как мы имеем дело с асинхронными вызовами, мы можем задерживать функцию отправки с помощью middleware. Кроме того, внутренняя функция даёт нам доступ к хранилищу функций отправки и getState.

Примечание: Building React Applications with Idiomatic Redux от egghead.io и Dan Abramov как реализовать ваш собственный thunk middleware. 

Некоторые сторонние middleware в Redux:

Из корня проекта:

npm --save install redux-thunk

Давайте добавим thunk middleware к нашему хранилище.

src/stores/configurationStore.js

import { createStore, applyMiddleware } from 'redux';
import createLogger from 'redux-logger';
import thunk from 'redux-thunk';
import { browserHistory } from 'react-router';
import { routerMiddleware } from 'react-router-redux'
import rootReducer from '../reducers/index';

const logger = createLogger();
const router = routerMiddleware(browserHistory);

const createStoreWithMiddleware = applyMiddleware(thunk, router, logger)(createStore);

export default function configureStore(initialState) {
  return createStoreWithMiddleware(rootReducer, initialState);
}

Set Me

Сейчас у нас всё готово, чтобы сохранить объект пользователя в хранилище. Поэтому нам нужно создать новый набор данных, создателя действий и редьюсер.

src/constants/actionTypes.js

export const ME_SET = 'ME_SET';
export const TRACKS_SET = 'TRACKS_SET';

src/actions/auth.js

import SC from 'soundcloud';
import * as actionTypes from '../constants/actionTypes';

function setMe(user) {
  return {
    type: actionTypes.ME_SET,
    user
  };
}

export function auth() {
  return function (dispatch) {
    SC.connect().then((session) => {
      dispatch(fetchMe(session));
    });
  };
};

function fetchMe(session) {
  return function (dispatch) {
    fetch(`//api.soundcloud.com/me?oauth_token=${session.oauth_token}`)
      .then((response) => response.json())
      .then((data) => {
        dispatch(setMe(data));
      });
  };
}

Вместо того чтобы выводить информацию об извлечённом объекте пользователя в консоль, мы просто вызываем наш создатель действий. Так же мы можем видеть, что thunk middleware требует нас вернуть функцию вместо объекта. Функция предоставляет нам доступ к функциональным возможностям хранилища, связанными с отправкой действий.

Давайте добавим новый редьюсер.

src/reducers/index.js

import { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux';
import auth from './auth';
import track from './track';

export default combineReducers({
  auth,
  track,
  routing: routerReducer
});

Из папки reducers.

touch auth.js

src/reducers/auth.js

import * as actionTypes from '../constants/actionTypes';

const initialState = {};

export default function(state = initialState, action) {
  switch (action.type) {
    case actionTypes.ME_SET:
      return setMe(state, action);
  }
  return state;
}

function setMe(state, action) {
  const { user } = action;
  return { ...state, user };
}

Редьюсер учитывает новый тип действия и возвращает newState с нашим пользователем.

Сейчас мы хотим визуально видеть, в нашем DOM, зашёл ли пользователь на сайт. Поэтому мы можем заменять кнопку входа сразу после того как пользователь авторизовался.

src/components/Stream/index.js

import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as actions from '../../actions';
import Stream from './presenter';

function mapStateToProps(state) {
  const { user } = state.auth;
  const tracks = state.track;
  return {
    user,
    tracks
  }
}

function mapDispatchToProps(dispatch) {
  return {
    onAuth: bindActionCreators(actions.auth, dispatch)
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Stream);

В нашем компоненте контейнера мы сопоставили новое состояния, текущего пользователя, с нашим ведущим компонентом.

src/components/Stream/presenter.js

import React from 'react';

function Stream({ user, tracks = [], onAuth }) {
  return (
    <div>
      <div>
        {
          user ?
            <div>{user.username}</div> :
            <button onClick={onAuth} type="button">Login</button>
        }
      </div>
      <br/>
      <div>
        {
          tracks.map((track, key) => {
            return <div className="track" key={key}>{track.title}</div>;
          })
        }
      </div>
    </div>
  );
}

export default Stream;

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

Из корня проекта.

npm start

Получение треков

Сейчас мы уже авторизованы на SoundCloud сервере. Поэтому давайте достанем настоящие треки и заменим те, что жёстко зашиты коде.

src/index.js

import SC from 'soundcloud';
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Route, IndexRoute, browserHistory } from 'react-router';
import { syncHistoryWithStore } from 'react-router-redux';
import { Provider } from 'react-redux';
import configureStore from './stores/configureStore';
import App from './components/App';
import Callback from './components/Callback';
import Stream from './components/Stream';
import { CLIENT_ID, REDIRECT_URI } from './constants/auth';

SC.initialize({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI });

const store = configureStore();

const history = syncHistoryWithStore(browserHistory, store);

ReactDOM.render(
  <Provider store={store}>
    <Router history={history}>
      <Route path="/" component={App}>
        <IndexRoute component={Stream} />
        <Route path="/" component={Stream} />
        <Route path="/callback" component={Callback} />
      </Route>
    </Router>
  </Provider>,
  document.getElementById('app')
);

Здесь, мы только убрали жёстко зашитые в коде треки. И ещё мы больше не отправляем действия чтобы установить какое-то начальное состояние.

src/actions/auth.js

import SC from 'soundcloud';
import * as actionTypes from '../constants/actionTypes';
import { setTracks } from '../actions/track';

function setMe(user) {
  return {
    type: actionTypes.ME_SET,
    user
  };
}

export function auth() {
  return function (dispatch) {
    SC.connect().then((session) => {
      dispatch(fetchMe(session));
      dispatch(fetchStream(session));
    });
  };
};

function fetchMe(session) {
    return function (dispatch) {
      fetch(`//api.soundcloud.com/me?oauth_token=${session.oauth_token}`)
        .then((response) => response.json())
        .then((data) => {
          dispatch(setMe(data));
        });
    };
}

function fetchStream(session) {
  return function (dispatch) {
    fetch(`//api.soundcloud.com/me/activities?limit=20&offset=0&oauth_token=${session.oauth_token}`)
      .then((response) => response.json())
      .then((data) => {
        dispatch(setTracks(data.collection));
      });
  };
}

После авторизации мы просто отправляем новый асинхронный запрос чтобы получить данные по треку из SoundCloud API. Так как у нас уже был создатель действия чтобы установить треки в наше состояние, мы можем использовать его повторно.

Примечание: В возвращаемых данных содержится не только список треков, также содержатся некоторые метаданные, которые могут быть использованы в дальнейшем для извлечения данных с разбивкой на страницы. Вам придётся сохранить свойство next_href  для этого. 

Структура данных SoundCloud выглядит несколько иначе, чем наши жёстко зашитые треки. Нам нужно поправить ведущий компонент Stream.

src/components/Stream/presenter.js

import React from 'react';

function Stream({ user, tracks = [], onAuth }) {
  return (
    <div>
      <div>
        {
          user ?
            <div>{user.username}</div> :
            <button onClick={onAuth} type="button">Login</button>
        }
      </div>
      <br/>
      <div>
        {
          tracks.map((track, key) => {
            return <div className="track" key={key}>{track.origin.title}</div>;
          })
        }
      </div>
    </div>
  );
}

export default Stream;

Ещё нам нужно поправить наш тест чтобы он соблюдал новую структуру.

src/components/Stream/spec.js

import Stream from './presenter';
import { shallow } from 'enzyme';

describe('Stream', () => {

  const props = {
    tracks: [{ origin: { title: 'x' } }, { origin: { title: 'y' } }],
  };

  it('shows two elements', () => {
    const element = shallow(<Stream { ...props } />);

    expect(element.find('.track')).to.have.length(2);
  });

});

После того как вы запустите приложение и авторизуетесь в нём, вы должны увидеть некоторые треки из вашего личного канала.

Примечание: Я надеюсь, что треки отобразятся, даже если у вас новый аккаунт на SoundCloud. Если вы получаете какие-то пустые данные, вам нужно использовать непосредственно сам SoundCloud, чтобы добавить треки.

Из корня проекта:

npm start

SoundCloud Player

Насколько круто было бы иметь аудио-плеер в браузере? Поэтому наш последний шаг заключается в том, чтобы сделать треки воспроизводимыми.

Новый Redux Cycle

Вы уже знакомы с процедурой создания действий, создателей действий и редьюсеров. А ещё вы должны уметь вызывать это из компонента. Давайте начнём с того, что предоставим нашему компоненту Stream, ещё не существующую функциональность onPlay. Кроме того, мы отобразим кнопку Play возле каждого трека, которая будет запускать эту функциональность.

src/components/Stream/presenter.js

import React from 'react';

function Stream({ user, tracks = [], onAuth, onPlay }) {
  return (
    <div>
      <div>
        {
          user ?
            <div>{user.username}</div> :
            <button onClick={onAuth} type="button">Login</button>
        }
      </div>
      <br/>
      <div>
        {
          tracks.map((track, key) => {
            return (
              <div className="track" key={key}>
                {track.origin.title} 
                 <button type="button" onClick={() => onPlay(track)}>Play</button>
              </div>
            );
          })
        }
      </div>
    </div>
  );
}

export default Stream;

В контейнере компонента Stream мы можем сопоставить это действие с ведущим компонентом.

src/components/Stream/index.js

import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as actions from '../../actions';
import Stream from './presenter';

function mapStateToProps(state) {
  const { user } = state.auth;
  const tracks = state.track;
  return {
    user,
    tracks
  }
};

function mapDispatchToProps(dispatch) {
  return {
    onAuth: bindActionCreators(actions.auth, dispatch),
    onPlay: bindActionCreators(actions.playTrack, dispatch),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Stream);

Теперь мы должны реализовать несуществующий создатель действий  playTrack.

src/actions/index.js

import { auth } from './auth';
import { setTracks, playTrack } from './track';

export {
  auth,
  setTracks,
  playTrack
};

src/actions/track.js

import * as actionTypes from '../constants/actionTypes';

export function setTracks(tracks) {
  return {
    type: actionTypes.TRACKS_SET,
    tracks
  };
};

export function playTrack(track) {
  return {
    type: actionTypes.TRACK_PLAY,
    track
  };
}

Не забудьте экспортировать новый тип действия как константу.

export const ME_SET = 'ME_SET';
export const TRACKS_SET = 'TRACKS_SET';
export const TRACK_PLAY = 'TRACK_PLAY';

В нашем редьюсере мы оставляем место для другого начального состояния. Изначально не будет установлено никакого активного трека, но после того как мы вызовем триггер проигрывания трека, он будет установлен как activeTrack.

src/reducers/track.js

import * as actionTypes from '../constants/actionTypes';

const initialState = {
    tracks: [],
    activeTrack: null
};

export default function(state = initialState, action) {
  switch (action.type) {
    case actionTypes.TRACKS_SET:
      return setTracks(state, action);
    case actionTypes.TRACK_PLAY:
      return setPlay(state, action);
  }
  return state;
}

function setTracks(state, action) {
  const { tracks } = action;
  return { ...state, tracks };
}

function setPlay(state, action) {
  const { track } = action;
  return { ...state, activeTrack: track };
}

Ещё мы хотим показать воспроизведение текущего трека, поэтому нам необходимо сопоставить activeTrack в нашем контейнере компонента Stream.

src/components/Stream/index.js

import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as actions from '../../actions';
import Stream from './presenter';

function mapStateToProps(state) {
  const { user } = state.auth;
  const { tracks, activeTrack } = state.track;
  return {
    user,
    tracks,
    activeTrack
  }
};

function mapDispatchToProps(dispatch) {
  return {
    onAuth: bindActionCreators(actions.auth, dispatch),
    onPlay: bindActionCreators(actions.playTrack, dispatch),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Stream);

После запуска приложения мы должны авторизоваться чтобы увидеть список треков. redux-logger должен показать некоторую информацию в консоли, которую мы должны установить как activeTrack. Но музыки ещё нет! Давайте реализуем это.

Cлушаем музыку!

На предыдущем шаге мы уже передали activeTrack нашему ведущему компоненту Stream. Давайте посмотрим, что мы можем сделать с этим.

src/components/Stream/presenter.js

import React from 'react';
import { CLIENT_ID } from '../../constants/auth';

function Stream({ user, tracks = [], activeTrack, onAuth, onPlay }) {
  return (
    <div>
      <div>
        {
          user ?
            <div>{user.username}</div> :
            <button onClick={onAuth} type="button">Login</button>
        }
      </div>
      <br/>
      <div>
        {
          tracks.map((track, key) => {
            return (
              <div className="track" key={key}>
                {track.origin.title}
                <button type="button" onClick={() => onPlay(track)}>Play</button>
              </div>
            );
          })
        }
      </div>
      {
        activeTrack ?
          <audio id="audio" ref="audio" src={`${activeTrack.origin.stream_url}?client_id=${CLIENT_ID}`}></audio> :
          null
      }
    </div>
  );
}

export default Stream;

Нам нужен CLIENT_ID для авторизации аудио-плеера в SoundCloud API, чтобы получить поток трека через его stream_url. В React 15 вы можете вернуть null, если нет activeTrack. В более старых версиях вы должны вернуть <noscript />.

Когда мы запустим наше приложение и попытаемся воспроизвести трек, то вывод в консоле скажет, что мы не можем определять ссылки на функциональные компоненты без состояния. Но нам нужна ссылка на аудио элемент с возможностью использовать его Audio API. Давайте преобразуем ведущий компонент Stream к компоненту с состоянием. Мы увидим, что это даёт нам контроль над аудио элементом.

src/components/Stream/presenter.js

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { CLIENT_ID } from '../../constants/auth';

class Stream extends Component {

  componentDidUpdate() {
    const audioElement = ReactDOM.findDOMNode(this.refs.audio);

    if (!audioElement) { return; }

    const { activeTrack } = this.props;

    if (activeTrack) {
      audioElement.play();
    } else {
      audioElement.pause();
    }
  }

  render () {
    const { user, tracks = [], activeTrack, onAuth, onPlay } = this.props;

    return (
      <div>
        <div>
          {
            user ?
              <div>{user.username}</div> :
              <button onClick={onAuth} type="button">Login</button>
          }
        </div>
        <br/>
        <div>
          {
            tracks.map((track, key) => {
              return (
                <div className="track" key={key}>
                  {track.origin.title}
                  <button type="button" onClick={() => onPlay(track)}>Play</button>
                </div>
              );
            })
          }
        </div>
        {
          activeTrack ?
            <audio id="audio" ref="audio" src={`${activeTrack.origin.stream_url}?client_id=${CLIENT_ID}`}></audio> :
            null
        }
      </div>
    );
  }

}

export default Stream;

Давайте снова запустим наше приложение. Авторизуемся и увидим список треков. Теперь мы можем нажать на кнопочку и слушать музычку.

Заключительное слово

Надеюсь, что вам понравился урок и вы узнали много нового также как и я. Я не планировал писать так много, но я надеюсь что до конца дойдёт достаточно много людей.

Я должен поблагодарить таких людей как Christopher Chedeau и Dan Abramov, которые побуждают людей вроде меня делать вклад в OpenSource, в ходе разговоров.

Я открыт для обратной связи и сообщений об ошибках в этой статье. Вы можете писать в комментарии под статьёй или выйти на меня через Twitter.

Вы можете ещё раз взглянуть на favesound-redux. Не стесняйтесь его использовать, вносить в него свой вклад, поднимать вопросы когда вы нашли ошибки, или же просто использовать его в качестве плана для своего собственного приложения.

Комментарии

28 ноября 2016 23:03
Bang

Спасибо за перевод!

12 декабря 2016 22:57
Дима Дихтярь

Статья замечательная. У кого-то все получилось? 4 дня на регистрации в soundcloud вишу. Никто не желает заменить soundcloud чем-то более простым и выпустить свою редакцию? Было бы здорово. Спасибо за перевод. Качественно.

13 декабря 2016 11:14
Администратор

Спасибо и вам за отзыв. Если нет проблем с английским, то могу посоветовать ещё статью в которой разрабатывается клон Yelp, правда, она очень длинная. https://www.fullstackreact.com/articles/react-tutorial-cloning-yelp/

16 декабря 2016 22:48
Дима Дихтярь

Благодарю. С soundcloud не могу победить исключение при нажатии на PLAY: "Uncaught (in promise) DOMException: Failed to load because no supported source was found." Не подскажите от чего такое может быть?

8 марта 2017 04:52
Slava Vasilenko

не получается сделать по туториалу, два раза пробовал. С конфигом какйто fail. С гита идет.

8 марта 2017 11:28
Администратор

Может быть текст ошибки хотя бы напишите?

9 марта 2017 01:36
Slava Vasilenko

Invalid configuration object. Webpack has been initialised using a configuration object that does not match the API schema. - configuration.resolve.extensions[0] should not be empty.

25 марта 2018 14:47
Александр Бабин

Может кому нибудь пригодиться, стоит заменить "" на "*" 

resolve: {
  extensions: ["*", ".js", ".jsx"]
}

 

mangohost