Быстрый курс Redux + websockets для бэкендера

от автора

Это краткое руководство и обучение по фронтэнеду для бэкендера. В данном руководстве я решаю проблему быстрого построения пользовательского интерфейса к серверному приложению в виде одностраничного веб-приложения (single page app).

Основной целью моего исследования является возможность за разумное время (для одного нормального человека) получить удобный и простой в использовании интерфейс-черновик к серверному приложению. Мы (как разработчики серверной части) понимаем, что наш приоритет — серверная часть. Когда (в гипотетическом проекте) появятся во фронте профи своего дела, они все сделают красиво и "правильно".

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

Мне для изложения материала требуется, чтобы вы имели:

  • базовое знание javascript (тут нужно поискать в интернете справочник по крайней версии js стандартов ES-2015)
  • знание reactjs (на уровне обучения https://facebook.github.io/react/tutorial/tutorial.html)
  • понятие о websockets (это очень просто, главное чтобы ваш сервер это умел)
  • знание и умение использовать bootstrap (на уровне этого раздела http://getbootstrap.com/css/)

Что используем

Redux — официальная документация расположена по адресу http://redux.js.org. По-русски есть несколько вариантов, я лично использовал в основном https://rajdee.gitbooks.io/redux-in-russian/content/docs/introduction/index.html.

Статью exec64, она стала причиной написать этот тутриал https://exec64.co.uk/blog/websockets_with_redux/.

Готовый сервер с react и redux от https://github.com/erikras/react-redux-universal-hot-example(он нам спасает человеко-месяцы времени по настройке большой связки технологий, которые необходимы для современного js проекта)

Мотивация

Вообще я разрабатываю приложение на языке Python. Погоди-погоди уходить …

Что мне было нужно:

  • мне нужно чтобы реализация интерфейса не диктовала мне выбор технологий на стороне серверной части
  • современные технологии (мне нечего было терять или быть чем-то обязанным "старым проверенным приемам")
  • это должно быть одностраничное приложений (я уже сам выберу, где можно обновлять страницу целиком)
  • мне нужна реакция пользовательского интерфейса в реальном времени на серверные события
  • мне нужен обмен информацией сервер-клиент (а не клиент-сервер) в реальном времени
  • мне нужна возможность генерировать обновления клиента на сервере

Что было испробовано:

  • вариации на тему на чистом js (устарело, есть много полезных моделей велосипеда)
  • JQuery (уже не могу ТАК извратить так свой мозг, крайне сложный для быстрого старта синтаксис и… это дело профессионалов)
  • Angular (переход на 2 версию спугнул и не нашел за отведенное время лазейки к решению моей задачи)
  • Socket.io (там все реализовано, если вы node.js программист вы уже его используете, но он слишком сильно привязывает серверную часть на node, мне нужен только клиент без третьих лиц)

Выбрано в итоге:

  • React (понятно и доступно/просто + babel = делает язык вполне понятным)
  • Redux (импонирует использование единой помойки единого хранилища)
  • WebSockets (очень просто и не связывает руки, а позволяет внутри себя уже применять такой формат какой позволит фантазия)

Упрощения и допущения:

  • Мы не будем использовать авторизации в приложении
  • Мы не будет использовать авторизации в WebSocket-ах
  • Мы будем использовать самое доступное приложение Websocket Echo (https://www.websocket.org/echo.html)

Содержание

  • Часть первая. Первоначальная настройка. Настройка одной страницы
  • Часть вторая. Проектирование будущего приложения
  • Часть третья. Изумительный Redux
  • Часть четвертая. Оживляем историю
  • Часть пятая. Проектируем чат
  • Часть шестая. Мидлваре

Как читать

Не будете повторять — пропускайте часть 1
Знаете reactjs — пропускайте часть 2
Знаете redux — пропускайте части 3, 4 и 5
Знаете как работает middleware в redux — смело читайте часть 6 и далее в обратном порядке.

Часть первая. Первоначальная настройка. Настройка страницы.

Настройка окружения

Нам нужен node.js и npm.

Ставим node.js с сайта https://nodejs.org — а именно этот гайд написан на 6ой версии, версию 7 тестировал — все работает.
npm устанавливается вместе с node.js

Далее нужно запустить npm и обновить node.js (для windows все тоже самое без npm)

sudo npm cache clean -f sudo npm install -g n sudo n stable

проверяем

node -v

Настройка react-redux-universal-hot-example

Все выложено в react-redux-universal-hot-example, там же инструкция по установке.
Тут привожу последовательность действий

  1. Скачиваем и разархивируем архив/форкаем/что-угодно-как-вам-нравится.
  2. Через node.js command line или терминал переходим в эту папку
  3. Запускаем

npm install npm run dev

Переходим на http://localhost:3000 и должны видеть стартовую страницу.

Если все ок — приступаем.

Создаем новый контейнер

Для настройки раздела используем предоставленную справку от команды react-redux-universal-hot-example. Оригинал статьи находится тут.

cd ./src/containers && mkdir ./SocketExample

Копируем туда hello.js как шаблон странички

cp About/About.js Hello/SocketExamplePage.js

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

Правим скопированный файл

Создаем заглушку под нашу страница. Вводим элемент <p>. Позже будем выводить статус соединения в этот элемент.

import React, {Component} from 'react'; import Helmet from 'react-helmet';  export default class SocketExamplePage extends Component {   render() {     return (       <div className="container">         <h1>Socket Exapmle Page</h1>         <Helmet title="Socket Exapmle Page"/>         <p>Sockets not connected</p>       </div>     );   } }

Подключаем созданную страницу

Добавляем в ./src/containers/index.js новый компонент React

export SocketExamplePage from './SocketExample/SocketExamplePage';

Добавляем в ./src/routes.js, чтобы связать переход по пунти /socketexamplepage в карту ссылок

... import {     App,     Chat,     Home,     Widgets,     About,     Login,     LoginSuccess,     Survey,     NotFound,     SocketExamplePage   } from 'containers'; ...       { /* Routes */ }       <Route path="about" component={About}/>       <Route path="login" component={Login}/>       <Route path="survey" component={Survey}/>       <Route path="widgets" component={Widgets}/>       <Route path="socketexamplepage" component={SocketExamplePage}/> ...

Добавляем в ./src/containers/App/App.js, чтобы добавить пункт в меню

              <LinkContainer to="/socketexamplepage">                 <NavItem eventKey={99}>Socket Example Page</NavItem>               </LinkContainer>

Проверяем

npm run dev

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/69935996671fc5dd64062143526d1a00b49afcbd

На данный момент мы имеем:

  • Раздел веб приложения
  • Страничка на React для нашего приложения
  • Заготовка, чтобы идти дальше

Прежде чем начнем. Я все разрабатывал в обратном порядке — сначала крутил мидлваре, потом прокидывал экшены и только потом уже прикручивал адекватный интерфейс в reactjs. Мы в руководстве будем делать все в правильном порядке, потому что так действительно быстрее и проще. Минус моего подхода в том, что я использовал в разы больше отладки и "костылей", чем нужно на самом деле. Будем рациональными.

Часть вторая. Проектирование будущего приложения

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

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

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

Итак начнем.

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

Пользовательский интерфейс "Вариант 1"

Мы добавляем два новых раздела на нашу страницу.

В логе подключения сокетов будем кратко выводить текущие события, связанные с подключением отключением. Изменяем файл ./src/containers/SocketExample/SocketExamplePage.js.

// inside render () { return (...) }           <h3>Socket connection log</h3>           <textarea             className="form-control"             rows="1"             readOnly             placeholder="Waiting ..."             value="               index = 2, loaded = true, message = Connected, connected = true               index = 1, loaded = false, message = Connecting..., connected = false"/>

index — порядковый номер записи лога

loaded — признак загружен ли элемент на странице пользователя

message — переменна-сообщение для отладки и наглядности кода

connected — признак подключены ли мы сейчас к серверу

Конечно мы забыли про кнопки и поля ввода, добавляем:

  • подключиться к websocket
  • отключиться от websocket

          <button className="btn btn-primary btn-sm">             <i className="fa fa-sign-in"/> Connect           </button>           <button className="btn btn-danger btn-sm">             <i className="fa fa-sign-out"/> Disconnect           </button>

В логе сообщений будем отображать отправленные -> и полученные сообщения <-.

// inside render () { return (...) }       <h3>Message log</h3>       <ul>           <li key="1" className="unstyled">             <span className="glyphicon glyphicon-arrow-right"></span>             Socket string           </li>           <li key="2" className="unstyled">             <span className="glyphicon glyphicon-arrow-left"></span>             [ECHO] Socket string           </li>       </ul>

Кнопка и ввод для отправить сообщение

      <form className="form-inline">         <p></p>         <div className="form-group">           <input           className="form-control input-sm"           type="text"           ref="message_text"></input>         </div>         <button className="btn btn-primary btn-sm">           <i className="fa fa-sign-in"/> Send         </button>       </form>

Не нажимайте кнопку Send

Проверяем и закомитимся для получения полного кода.

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/510a59f732a9bf42e070e7f57e970a2307661739

Пользовательский интерфейс Вариант 2. Компоненты.

Давайте разделим все на компоненты. Ничего сложного.

Создаем новую папку в директории ./src/components назовем ее SocketExampleComponents.

Добавление компонента происходит в три шага:

1 — создаем файл с компонентом в нашей папке SocketConnectionLog.js

мы оборачиваем в содержимое компонента в div так как от нас этого ожидает React

import React, {Component} from 'react';  export default class SocketConnectionLog extends Component {   render() {     return (       <div>         <h3>Socket connection log</h3>         <textarea           className="form-control"           rows="1"           readOnly           placeholder="Waiting ..."           value="             index = 2, loaded = true, message = Connected, connected = true             index = 1, loaded = false, message = Connecting..., connected = false"/>         <button className="btn btn-primary btn-sm">           <i className="fa fa-sign-in"/> Connect         </button>         <button className="btn btn-danger btn-sm">           <i className="fa fa-sign-out"/> Disconnect         </button>       </div>     );   } }

2 — прописываем наш новый компонент в файле components/index.js

export SocketConnectionLog from './SocketExampleComponents/SocketConnectionLog';

3 — правим нашу страницу ./src/components/SocketExamplePage.js и вместо скопированного нами кода вставляем только один элемент

import {SocketConnectionLog} from 'components'; // ...         <SocketConnectionLog />

Добавляем другой новый компонент в ту же папку ./src/components/SocketExampleComponents.

Добавляем в три шага

1 — создаем файл с компонентом в нашей папке SocketMessageLog.js

import React, {Component} from 'react';  export default class SocketMessageLog extends Component {   render() {     return (       <div>         <h3>Message log</h3>         <ul>           <li key="1" className="unstyled">             <span className="glyphicon glyphicon-arrow-right"></span>             Socket string           </li>           <li key="2" className="unstyled">             <span className="glyphicon glyphicon-arrow-left"></span>             [ECHO] Socket string           </li>         </ul>         <form className="form-inline">           <p></p>           <div className="form-group">             <input             className="form-control input-sm"             type="text"             ref="message_text"></input>           </div>           <button className="btn btn-primary btn-sm">             <i className="fa fa-sign-in"/> Send           </button>         </form>       </div>     );   } }

2 — прописываем наш новый компонент в файле ./src/components/index.js

export SocketMessageLog from './SocketExampleComponents/SocketMessageLog';

3 — правим нашу страницу и вместо скопированного нами кода вставляем только один элемент

// ... import {SocketMessageLog} from 'components'; // ...         <SocketMessageLog/>

Проверяем. Ничего не изменилось и это успех.

Коммит:
https://github.com/valentinmk/react-redux-universal-hot-example/commit/97a6526020a549f2ddf91370ac70dbc0737f167b

Заканчиваем 2 часть.

Часть третья. Изумительный Redux

Переходим сразу к Redux.

Для этого нужно:

  1. Создать редюсер
  2. Создать экшены
  3. И подключить все это в общую систему

Про экшены написано В официальной документации
Про редюсеры написано Там же

Создаем файл

Создаем файл ./src/redux/modules/socketexamplemodule.js и наполняем базовыми экшенами и редюсерами. Вот тут базовом примере есть странная нестыковка, все предлагается писать в одном файле, не разделяя на файл экшенов и редюсеров, ну допустим. Все равно — мы тут все взрослые люди (we are all adults).

Экшены 1

export const SOCKETS_CONNECTING = 'SOCKETS_CONNECTING'; export const SOCKETS_DISCONNECTING = 'SOCKETS_DISCONNECTING'; export const SOCKETS_MESSAGE_SENDING = 'SOCKETS_MESSAGE_SENDING'; export const SOCKETS_MESSAGE_RECEIVING = 'SOCKETS_MESSAGE_RECEIVING';

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

Редюсер

Добавляем в тот же файл.

export default function reducer(state = initialState, action = {}) {   switch (action.type) {     case SOCKETS_CONNECTING:       return Object.assign({}, state, {         loaded: true,         message: 'Connecting...',         connected: false       });     case SOCKETS_DISCONNECTING:       return Object.assign({}, state, {         loaded: true,         message: 'Disconnecting...',         connected: true       });     case SOCKETS_MESSAGE_SENDING:       return Object.assign({}, state, {         loaded: true,         message: 'Send message',         connected: true       });     case SOCKETS_MESSAGE_RECEIVING:       return Object.assign({}, state, {         loaded: true,         message: 'Message receive',         connected: true       });     default:       return state;   } }

Более подробно про структуру reducer и зачем Object.assign({}, state,{}); можно прочитать тут.

Вы заметили инициализацию state = initialState, которой мы не объявили (поставьте ESLint или его аналог — сильно упростит жизнь Нормального Человека). Добавим объявление до редюсера. Это будет первое состояние, которое мы будем иметь в нашем сторе на момент загрузки страницы, ну точнее страница будет загружаться уже с этим первоначальным состоянием.

const initialState = {   loaded: false,   message: 'Just created',   connected: false, };

Экшены 2

Теперь продолжим с нашими экшенами и на этом завершим этот модуль. Мы должны описать, как они будут изменять состояние reducer’a.

Добавляем в тот же файл.

export function socketsConnecting() {   return {type: SOCKETS_CONNECTING}; } export function socketsDisconnecting() {   return {type: SOCKETS_DISCONNECTING}; } export function socketsMessageSending() {   return {type: SOCKETS_MESSAGE_SENDING}; } export function socketsMessageReceiving() {   return {type: SOCKETS_MESSAGE_RECEIVING}; }

Подключаем в общий редюсер

На данный момент в приложении ничего не поменяется. Включаем наш модуль в общий конструктор reducer’ов.

В фале ./src/redux/modules/reducer.js прописываем модуль.

import socketexample from './socketexamplemodule';

и включаем его в общую структуру результирующего редюсера

export default combineReducers({   routing: routerReducer,   reduxAsyncConnect,   auth,   form,   multireducer: multireducer({     counter1: counter,     counter2: counter,     counter3: counter   }),   info,   pagination,   widgets,   // our hero   socketexample });

Запускаем сервер, проверяем и ура в DevTools мы видим.

image

Если вопросы с initialState остались, то попробуйте их поменять или добавить новую переменную в него.

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/0c984e3b5bc25056aa578ee57f90895bc6baaf18

Стор

А стор у нас уже создан и редюсер в него подключен. Ничего не делаем.

Если подробнее, то вы должны помнить, как мы добавили наш редюсер в combineReducers выше по статье. Так вот этот combineReducers сам включается в стор, который создаётся в файле ./src/redux/create.js.

Подключаем стор к react компонентам

Подключаем все это теперь в наши модули. В целях всесторонней демонстрации начнем с модуля истории и сделаем из него чистый компонент react (в смысле чистый от redux).

Компонент SocketConnectionLog мы пока не трогаем, а идем сразу в контейнер SocketExamplePage.

В данном контейнере мы будем подключать и получать данные из redux.

Подключаем библиотеку в файле ./src/containers/SocketExample/SocketExamplePage.js.

import {connect} from 'react-redux';

Забираем экшены, чтобы потом их использовать у себя в react.

import * as socketExampleActions from 'redux/modules/socketexamplemodule';

а еще мы поменяем строку, чтобы подключить PropTypes

import React, {Component, PropTypes} from 'react';

Пишем коннектор, которым будем забирать данные из нашего редюсера.

@connect(   state => ({     loaded: state.socketexample.loaded,     message: state.socketexample.message,     connected: state.socketexample.connected}),   socketExampleActions)

Как вы видите state.socketexample.loaded это обращение в redux, в той структуре, которую мы видим в DevTools.

Теперь подключаем проверки данных, получаемых из redux, что видится целесообразным т.к. любые проверки данных на тип есть вселенское добро.

  static propTypes = {     loaded: PropTypes.bool,     message: PropTypes.string,     connected: PropTypes.bool   }

Мы получили данные теперь давайте их передавать. Внутри блока render объявляем и принимаем данные уже теперь из props.

const {loaded, message, connected} = this.props;

и спокойно и уверенно передаем их в наш модуль:

<SocketConnectionLog loaded={loaded} message={message} connected={connected} />

Мы передали новые данные (через react) в компонент. Теперь переписываем наш компонент, который уже ничего не знает про стор (redux), а только обрабатывает переданные ему данные.

В файле ./src/components/SocketExampleComponents/SocketConnectionLog.js действуем по списку:

  1. проверяем полученные props
  2. присваиваем их внутри render
  3. используем в нашем компоненте

Начнем, импортируем недостающие библиотеки:

import React, {Component, PropTypes} from 'react';

добавляем проверку:

  static propTypes = {     loaded: PropTypes.bool,     message: PropTypes.string,     connected: PropTypes.bool   }

объявляем и присваиваем переменные, переданные через props

    const {loaded, message, connected} = this.props;

используем для вывода наши переменные

          value={'index =' + 0 + ', loaded = ' + loaded + ', message = ' + message + ', connected = ' + connected}/>           {/* value="             index = 2, loaded = true, message = Connected, connected = true             index = 1, loaded = false, message = Connecting..., connected = false"/>           */}

Проверяем и видим, initialState прилетает к нам прямо из redux->react->props->props.

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/60ac05332e35dfdbc11b9415f5bf5c46cd740ba8

SocketExampleMessageLog

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

Открываем файл ./src/components/SocketExampleComponents/SocketMessageLog.js

в нем добавляем необходимые нам библиотеки

import React, {Component, PropTypes} from 'react'; import {connect} from 'react-redux'; import * as socketExampleActions from 'redux/modules/socketexamplemodule';

добавляем connect, проверку типов и используем полученные данные

@connect(   state => ({     loaded: state.socketexample.loaded,     message: state.socketexample.message,     connected: state.socketexample.connected}),   socketExampleActions) export default class SocketMessageLog extends Component {   static propTypes = {     loaded: PropTypes.bool,     message: PropTypes.string,     connected: PropTypes.bool   }  // ...

Не забываем передать значение в метод render() через props

    const {loaded, message, connected} = this.props;

Мы будем использовать loaded и connected, чтобы определять готовность к обмену сообщения, а message выведем просто для проверки.

        <ul>           <li key="1" className="unstyled">             <span className="glyphicon glyphicon-arrow-right"> </span>             {message}           </li>           <li key="2" className="unstyled">             <span className="glyphicon glyphicon-arrow-left"> </span>             [ECHO] {message}           </li>         </ul>

Я буду проверять переменные loaded и connected явно, чтобы быть более прозрачным для (возможных) потомков.

        <form className="form-inline">           <p></p>           <div className="form-group">             <input               className="form-control input-sm"               type="text"               ref="message_text" readOnly = {(loaded === true) ? false : true}></input>           </div>           <button             className="btn btn-primary btn-sm"             disabled = {(connected === true) ? false : true}>             <i className="fa fa-sign-in"/> Send           </button>         </form>

Полпути пройдено.

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/a473d6a86262f2d2b52c590974e77df9454de5a1.

Часть четвертая. Оживляем историю

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

В этой части мы будем связывать события в react и состояния в стор. Начнем.

Оживим историю подключений в нашем компоненте ./src/components/SocketExampleComponents/SocketConnectionLog.js.
Но как мы помним, он ничего про стор не знает. Это означает, что он ничего не знает про экшены и поэтому ему их нужно передать через контейнер ./src/containers/SocketExample/SocketExamplePage.js. Просто передаем компоненту их как будто это простые props.

Вообще все функции экшенов мы подключили через connect. Стоп. Подробней. Вспомним.

//.... import * as socketExampleActions from 'redux/modules/socketexamplemodule'; //.... @connect(   state => ({     loaded: state.socketexample.loaded,     message: state.socketexample.message,     connected: state.socketexample.connected}),   socketExampleActions)

Поэтому просто включаем их в проверку в файле ./src/containers/SocketExample/SocketExamplePage.js:

static propTypes = {   loaded: PropTypes.bool,   message: PropTypes.string,   connected: PropTypes.bool,   socketsConnecting: PropTypes.func,   socketsDisconnecting: PropTypes.func  }

и передаем в наш компонент

  render() {     const {loaded, message, connected, socketsConnecting, socketsDisconnecting} = this.props;     return (       <div className="container">         <h1>Socket Exapmle Page</h1>         <Helmet title="Socket Exapmle Page"/>         <SocketConnectionLog loaded={loaded} message={message} connected={connected} connectAction={socketsConnecting} disconnectAction={socketsDisconnecting}/>         <SocketMessageLog/>       </div>     );   }

Теперь давайте обеспечим прием преданных в компонент экшенов в файле ./src/components/SocketExampleComponents/SocketConnectionLog.js.

Мы будем добавлять их (экшены) в проверку и использовать в наших обработчиках действий на форме. Обработчиков сделаем два: по клику кнопки "Connect" и "Disconnect".

  static propTypes = {     loaded: PropTypes.bool,     message: PropTypes.string,     connected: PropTypes.bool,     connectAction: PropTypes.func,     disconnectAction: PropTypes.func   }   handleConnectButton = (event) => {     event.preventDefault();     this.props.connectAction();   }   handleDisconnectButton = (event) => {     event.preventDefault();     this.props.disconnectAction();   }

Прописываем вызов обработчиков функций по нажатию соответствующих кнопок.

  render() {     const {loaded, message, connected} = this.props;     return (       <div>         <h3>Socket connection log</h3>         <textarea           className="form-control"           rows="1"           readOnly           placeholder="Waiting ..."           value={'index =' + 0 + ', loaded = ' + loaded + ', message = ' + message + ', connected = ' + connected}/>           {/* value="             index = 2, loaded = true, message = Connected, connected = true             index = 1, loaded = false, message = Connecting..., connected = false"/>           */}         <button           className="btn btn-primary btn-sm"           onClick={this.handleDisconnectButton}>           <i className="fa fa-sign-in"/> Connect         </button>         <button           className="btn btn-danger btn-sm"           onClick={this.handleConnectButton}>           <i className="fa fa-sign-out"/> Disconnect         </button>       </div>     );

Запускаем. Проверяем. Ура, оно живо! Можно посмотреть в DevTools, что события создаются в сторе.

Если внимательно проследить как меняются состояния, то можно заметить, что компонент истории сообщений работает как-то не так (хотя он написан правильно). Дело в том, что при нажатии кнопки подключения у нас состояние connected = false, а при разрыве подключения у нас состояние connected = true. Давай-те поправим.

Для этого в файле ./src/redux/modules/socketexamplemodule.js правим странные строчки

 case SOCKETS_CONNECTING:   return Object.assign({}, state, {    loaded: true,    message: 'Connecting...',    connected: true   });  case SOCKETS_DISCONNECTING:   return Object.assign({}, state, {    loaded: true,    message: 'Disconnecting...',    connected: false   });

Ну теперь все работает правильно.

НО далее мы поменяем эти значения на исходные, это важный момент. Событие попытки подключения не тождественно состоянию подключено (да я кэп).

Реализуем историю подключения. Главное ограничение принцип работы самого стора. Мы нее можем изменять само состояние, но мы можем его целиком пересоздавать и присваивать. Поэтому чтобы накапливать историю мы будем ее копировать, прибавлять к копии текущее состояние и присваивать это значение оригиналу (с которого сняли копию).

    case SOCKETS_CONNECTING:       return Object.assign({}, state, {         loaded: true,         message: 'Connecting...',         connected: true,         history: [           ...state.history,           {             loaded: true,             message: 'Connecting...',             connected: true           }         ]       });     case SOCKETS_DISCONNECTING:       return Object.assign({}, state, {         loaded: true,         message: 'Disconnecting...',         connected: false,         history: [           ...state.history,           {             loaded: true,             message: 'Disconnecting...',             connected: false           }         ]       });

Делаем отображение в том же элементе. Прежде всего передаем переменную истории через props в файле ./src/containers/SocketExample/SocketExamplePage.js. Далее в файле ./src/components/SocketExampleComponents/SocketConnectionLog.js принимает переданную переменную.

Приступим в файле ./src/containers/SocketExample/SocketExamplePage.js забираем из стора:

@connect(  state => ({   loaded: state.socketexample.loaded,   message: state.socketexample.message,   connected: state.socketexample.connected,   history: state.socketexample.history  }),  socketExampleActions)

проверяем на тип

  static propTypes = {     loaded: PropTypes.bool,     message: PropTypes.string,     connected: PropTypes.bool,     history: PropTypes.array,     socketsConnecting: PropTypes.func,     socketsDisconnecting: PropTypes.func       }

присваиваем и передаем

  render() {     const {loaded, message, connected, socketsConnecting, socketsDisconnecting, history} = this.props;     return (       <div className="container">         <h1>Socket Exapmle Page</h1>         <Helmet title="Socket Exapmle Page"/>         <SocketConnectionLog loaded={loaded} message={message} connected={connected} connectAction={socketsConnecting} disconnectAction={socketsDisconnecting} history={history}/>         <SocketMessageLog/>       </div>     );

Принимаем уже в файле ./src/components/SocketExampleComponents/SocketConnectionLog.js.

 static propTypes = {    loaded: PropTypes.bool,    message: PropTypes.string,    connected: PropTypes.bool,    history: PropTypes.array,    connectAction: PropTypes.func,    disconnectAction: PropTypes.func  }

Для вывода истории в лог нам уже на самом деле не требуются текущие значения loaded, message, connected.

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

  render() {     const {history} = this.props;     return (       <div>         <h3>Socket connection log</h3>         <textarea           className="form-control"           rows="1"           readOnly           placeholder="Waiting ..."           value={             history.map((historyElement, index) =>               'index = ' + index +               ' loaded = ' + historyElement.loaded.toString() +               ' message = ' + historyElement.message.toString() +               ' connected = ' + historyElement.connected.toString() + ' \n').reverse()             }/>         <button           className="btn btn-primary btn-sm"           onClick={this.handleConnectButton}>           <i className="fa fa-sign-in"/> Connect         </button>         <button           className="btn btn-danger btn-sm"           onClick={this.handleDisconnectButton}>           <i className="fa fa-sign-out"/> Disconnect           </button>       </div>     );

Главное, что нужно не забыть это добавить history при инициализации редюсера, иначе наши проверки не будут срабатывать.

В файле ./src/redux/modules/socketexamplemodule.js.

const initialState = {   loaded: false,   message: 'Just created',   connected: false,   history: [] };

Проверяем. И получаем нашу запись в истории подключения, но почему то с запятыми. Javascript, WTF? Ну да ладно, если мы добавим после мапа и реверса .join(»), то это все решает.

".join(») все решает.", Карл!

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

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/24144226ea4c08ec1af5db3a5e9b37461be2dbdd

Часть пятая. Проектируем чат

У нас есть заготовка для подключения/отключения к сокету. Теперь мы должны сделать оболочку для чата, она станет нашим рабочем моделью (прототип у нас уже есть).

С чатом мы выполним такие же действия, как и с логом (историей) подключений — добавим историю чата и научим ее выводить.

Полный цикл будет выглядеть так:

  • В редаксе нужно:

    • объявить новую переменную и инициализировать,
    • описать для нее экшены,
    • описать как данная переменна будет изменяться.

  • В компоненте нужно:
    • принять эту переменную,
    • включить ее в отображение,
    • связать кнопку на интерфейсе и экшены.

Настройка редюсера

Начнем с файле ./src/redux/modules/socketexamplemodule.js нам нужно
добавить новую переменную.

const initialState = {   loaded: false,   message: 'Just created',   connected: false,   history: [],   message_history: [] };

У нас уже есть экшены SOCKETS_MESSAGE_SENDING и SOCKETS_MESSAGE_RECEIVING. Дополнительных экшенов создавать не будет.

Приступаем к описанию, как будет себя вести нам нужно просто описать как будет работать редюсер.

case SOCKETS_MESSAGE_SENDING:       return Object.assign({}, state, {         loaded: true,         message: 'Send message',         connected: true,         message_history: [           ...state.message_history,           {             direction: '->',             message: action.message_send           }         ]       });     case SOCKETS_MESSAGE_RECEIVING:       return Object.assign({}, state, {         loaded: true,         message: 'Message receive',         connected: true,         message_history: [           ...state.message_history,           {             direction: '<-',             message: action.message_receive           }         ]       });

Обратите внимание на переменные переменный action.message_receive и action.message_send. С помощью них мы изменяем состояние нашего стора. Переменные будут передаваться внутри экшенов.

Реализуем передачу переменных в стор из экшенов.

export function socketsMessageSending(sendMessage) {   return {type: SOCKETS_MESSAGE_SENDING, message_send: sendMessage}; } export function socketsMessageReceiving(sendMessage) {   return {type: SOCKETS_MESSAGE_RECEIVING, message_receive: sendMessage}; }

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

Мы выполнили работы со стороны редюсера и переходим к настройке отображения и управления из компонента.

Настройка интерфейса

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

Подключаем новую переменную, которую мы получаем из стора в файле ./src/components/SocketExampleComponents/SocketMessageLog.js.

@connect(   state => ({     loaded: state.socketexample.loaded,     message: state.socketexample.message,     connected: state.socketexample.connected,     message_history: state.socketexample.message_history   }),   socketExampleActions) export default class SocketMessageLog extends Component {   static propTypes = {     loaded: PropTypes.bool,     message: PropTypes.string,     connected: PropTypes.bool,     message_history: PropTypes.array,     socketsMessageSending: PropTypes.func   }

Теперь нам нужны функции, которые будут обрабатывать нажатия кнопок на форме.

handleSendButton = (event) => {     event.preventDefault();     this.props.socketsMessageSending(this.refs.message_text.value);     this.refs.message_text.value = '';   }

Подробнее, забираем по ссылке из поля message_text. Передаем message_text в наш экшен оправки сообщения. Стираем значение в этом поле для ввода нового.

Добавляем переменную в props.

const {loaded, connected, message_history} = this.props;

Выводим лог сообщений, по аналогии с подключением

        <ul>           {             message_history.map((messageHistoryElement, index) =>             <li key={index} className={'unstyled'}>               <span className={(messageHistoryElement.direction === '->') ? 'glyphicon glyphicon-arrow-right' : 'glyphicon glyphicon-arrow-left'}></span>               {messageHistoryElement.message}             </li>           )}         </ul>

Не пытайтесь использовать более вложенные ветвления — это у вас не получится. Т.е. не пытайтесь использовать вложенные ‘ ‘?’ ‘:’ ‘. Вас будут от этого защищать. Причина — здесь не место вычислений данных. Здесь вообще про интерфейс.

Обновляем форму и кнопки

        <form           className="form-inline"           onSubmit={this.handleSendButton}>           <p></p>           <div className="form-group">             <input               className="form-control input-sm"               type="text"               ref="message_text" readOnly = {(loaded && connected === true) ? false : true}>             </input>           </div>           <button             className="btn btn-primary btn-sm"             onClick={this.handleSendButton}             disabled = {(connected === true) ? false : true}>             <i className="fa fa-sign-in"/> Send           </button>         </form>

Тестируем и видим отправленные сообщения.

Давайте имитировать получение сообщения. Будем делать это в лоб.

handleSendButton = (event) => {     event.preventDefault();     this.props.socketsMessageSending(this.refs.message_text.value);     this.props.socketsMessageReceiving(this.refs.message_text.value);     this.refs.message_text.value = '';   }

Подробнее, в дополнение к предыдущей версии мы вызывает экшен получения сообщения и передаем в него наше сообщение this.refs.message_text.value.

Не забываем добавить новые элементы в проверку!

static propTypes = {     loaded: PropTypes.bool,     message: PropTypes.string,     connected: PropTypes.bool,     message_history: PropTypes.array,     socketsMessageSending: PropTypes.func,     socketsMessageReceiving: PropTypes.func   }

Отлично, скучная кропотливая часть закончилась!

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/5158391cdd53545408637fd732c981f17852e84b.

Мидлваре

Более подробно о мидлваре в редаксе можно почитать на официальном сайте http://redux.js.org/docs/advanced/Middleware.html.

А еще вот та самая статья https://exec64.co.uk/blog/websockets_with_redux/.

Давайте создадим наш собственный мидлваре, в котором будем реализовывать интерфейс к сервису, построенному на websockets.

Первый проход

Создаем новый файл ./src/redux/middleware/socketExampleMiddleware.js

В этот файл нам нужно добавить экшены, которыми мы будем манипулировать. По своему принципу мидлваре напоминает структуру редюсера, но этому будет проиллюстрировано ниже.

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

import * as socketExampleActions from 'redux/modules/socketexamplemodule';  export default function createSocketExampleMiddleware() {   let socketExample = null;   socketExample = true;   socketExampleActions();    return store => next => action => {     switch (action.type) {       default:         console.log(store, socketExample, action);         return next(action);     }   }; }

Подробнее. Вообще мидлваре управляет самим стором и как он обрабатывает события и состояния внутри себя. Использую конструкцию return store => next => action => мы вмешиваемся в каждый экшен происходящий в сторе и по полю switch (action.type) выполняем те или иные действия.

У нас сейчас действительно простой пример и логирование в консоль самый просто способ посмотреть, что у нас прилетает в переменных store, socketExample, action. (socketExampleActions(); оставили просто, чтобы не ругался валидатор, вообще они нам понадобятся в будущем).

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

В файле ./src/redux/create.js меняем пару строк.

import createSocketExampleMiddleware from './middleware/socketExampleMiddleware'; //...   const middleware = [     createMiddleware(client),     reduxRouterMiddleware,     thunk,     createSocketExampleMiddleware()   ];

Запускаем проверяем. Теперь в консоли полный беспорядок и это означает, что наш концепт работает!

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/7833a405be3445e58e8e672e9db03f8cfbfde022

Второй проход. Делаем лог историю.

Мы проверили концепцию и готовы делать нашу боевую модель. Теперь будем подключаться к websockets.

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

Добавляем в файл ./src/redux/middleware/socketExampleMiddleware.js функции, которыми будем обрабатывать события подключения и отключения.

  const onOpen = (token) => evt => {     console.log('WS is onOpen');     console.log('token ' + token);     console.log('evt ' + evt.data);   };   const onClose = () => evt => {     console.log('WS is onClose');     console.log('evt ' + evt.data);   };

убираем лишние объявления (нужно удалить)

  socketExample = true;   socketExampleActions();

добавляем наши редюсеры и убираем лишнее логирование.

      case 'SOCKETS_CONNECT':         if (socketExample !== null) {           console.log('SOCKETS_DISCONNECTING');           store.dispatch(socketExampleActions.socketsDisconnecting());           socket.close();         }         console.log('SOCKETS_CONNECTING');         socketExample = new WebSocket('ws://echo.websocket.org/');         store.dispatch(socketExampleActions.socketsConnecting());         socketExample.onclose = onClose();         socketExample.onopen = onOpen(action.token);         break;       default:         return next(action);

Подробнее. Начинаем разбираться. Мы ловим событие SOCKETS_CONNECT, проверяем подключены ли мы, если нет то запускаем принудительное закрытие подключения, создаем новый веб сокет и добавляем ему методы onClose() и onOpen(action.token). Понимает, что сейчас ничего не работает. Мы ловим экшен SOCKETS_CONNECT, которого у нас пока нет. Но у нас есть другой экшен SOCKETS_CONNECTING, почему бы не использовать его — меняем скрипт.

      case 'SOCKETS_CONNECTING':         if (socketExample !== null) {           console.log('SOCKETS_DISCONNECTING');           store.dispatch(SocketExampleActions.socketsDisconnecting());           socket.close();         }         console.log('SOCKETS_CONNECTING');         socketExample = new WebSocket('ws://echo.websocket.org/');         store.dispatch(SocketExampleActions.socketsConnecting());         socketExample.onclose = onClose();         socketExample.onopen = onOpen(action.token);         break;       default:         return next(action);

!!! Внимание после этого скрипт будет находиться в бесконечном цикле — сохраните все или не нажимайте кнопку подключиться на этом этапе.

Проверяем и видим, что все пошло не так. В консоли постоянные SOCKETS_CONNECTING и SOCKETS_DISCONNECTING. Закрываем вкладку или браузер.

Подробнее. Мидлваре "слушает" стор на предмет экшенов store => next => action => и включается в обработку, когда находит свой экшен SOCKETS_CONNECTING. Далее по коду идет вызов экшена store.dispatch(SocketExampleActions.socketsConnecting());, который в свою очередь вызывает экшен SOCKETS_CONNECTING, который ловит мидлваре и т.д.

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

Как быть дальше.

Наш вариант (я думаю он не один) будет таким:

  • пользователь будет вызывать нажатием кнопки экшены мидлвара,
  • который будет вызывать уже "интерфейсные" экшены.

Что на практике будет означать

  • SOCKETS_CONNECT вызывается пользователем
  • при его обработке будет вызываться SOCKETS_CONNECTING,
  • который будет уже обновлять стор и соответствующим образом представлять действие на стороне клиента.

Давайте исправим все это.

Во-первых, нам не хватает экшенов.

Дополняем наши 2 экшена новыми в файле src\redux\modules\socketexamplemodule.js.

export const SOCKETS_CONNECTING = 'SOCKETS_CONNECTING'; export const SOCKETS_CONNECT = 'SOCKETS_CONNECT'; export const SOCKETS_DISCONNECTING = 'SOCKETS_DISCONNECTING'; export const SOCKETS_DISCONNECT = 'SOCKETS_DISCONNECT';

И объявим функции они нам пригодятся.

export function socketsConnecting() {   return {type: SOCKETS_CONNECTING}; } export function socketsConnect() {   return {type: SOCKETS_CONNECT}; } export function socketsDisconnecting() {   return {type: SOCKETS_DISCONNECTING}; } export function socketsDisconnect() {   return {type: SOCKETS_DISCONNECT}; }

Теперь нужно дать возможность пользователю запускать данные действия. По идеи нужно лезть в ./src/components/SocketExampleComponents/SocketConnectionLog.jsр, но на самом деле управляющие функции ему передают через компонент react. Поэтому правим сначала ./src/containers/SocketExample/SocketExamplePage.js.

static propTypes = {     loaded: PropTypes.bool,     message: PropTypes.string,     connected: PropTypes.bool,     history: PropTypes.array,     socketsConnecting: PropTypes.func,     socketsDisconnecting: PropTypes.func, //HERE     socketsConnect: PropTypes.func,     socketsDisconnect: PropTypes.func   }   render() { //HERE     const {loaded, message, connected, socketsConnecting, socketsDisconnecting, history, socketsConnect, socketsDisconnect} = this.props;     return (       <div className="container">         <h1>Socket Exapmle Page</h1>         <Helmet title="Socket Exapmle Page"/>         <SocketConnectionLog           loaded={loaded}           message={message}           connected={connected}           connectAction={socketsConnecting}           disconnectAction={socketsDisconnecting}           history={history} //HERE           connectAction={socketsConnect}           disconnectAction={socketsDisconnect}           />         <SocketMessageLog/>       </div>     );   }

Возвращаемся к ./src/redux/middleware/SocketExampleMiddleware.js и наводим порядок.

Изменяем один кейс

      case 'SOCKETS_CONNECT':

Добавляем кейс на обработку отключения:

      case 'SOCKETS_DISCONNECT':         if (socketExample !== null) {           console.log('SOCKETS_DISCONNECTING');           store.dispatch(socketExampleActions.socketsDisconnecting());           socketExample.close();         }         socketExample = null;         break;

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

        socketExample.onclose = onClose(store);

и изменяем сам обработчик

  const onClose = (store) => evt => {     console.log('WS is onClose');     console.log('evt ' + evt.data);     store.dispatch(socketExampleActions.socketsDisconnect());   };

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

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

        socketExample = new WebSocket('ws://echo.websocket.org123/');

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

Попробуйте самостоятельно переместить store.dispatch(socketExampleActions.socketsDisconnect()); из метода onClose в кейс редюсера и посмотреть что же изменится.

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/7569536048df83f7e720b000243ed9798308df20

Проход второй. Делаем сообщения

Все аналогично первой части второго прохода.
Добавляем экшены в ./src/redux/modules/socketexamplemodule.js

export const SOCKETS_MESSAGE_SENDING = 'SOCKETS_MESSAGE_SENDING'; export const SOCKETS_MESSAGE_SEND = 'SOCKETS_MESSAGE_SEND'; export const SOCKETS_MESSAGE_RECEIVING = 'SOCKETS_MESSAGE_RECEIVING'; export const SOCKETS_MESSAGE_RECEIVE = 'SOCKETS_MESSAGE_RECEIVE';

Добавляем обработчики

export function socketsMessageSending(sendMessage) {   return {type: SOCKETS_MESSAGE_SENDING, message_send: sendMessage}; } export function socketsMessageSend(sendMessage) {   return {type: SOCKETS_MESSAGE_SEND, message_send: sendMessage}; } export function socketsMessageReceiving(receiveMessage) {   return {type: SOCKETS_MESSAGE_RECEIVING, message_receive: receiveMessage}; }

Стоп. Почему не 4 обработчика? Подробнее. Нам, на самом деле, нам не нужна обработка socketsMessageReceive, потому что пользователю не нужно вмешиваться в процесс получения сообщения. Хотя на будущее этим событием мы можем отмечать факт отображения сообщения у пользователя в его интерфейсе, т.е. тот самый признак "прочитано" (но это за пределами этой статьи).

Прием сообщения

Переходим к описанию обработки событий от сокета в файле ./src/redux/middleware/socketExampleMiddleware.js.

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

  const onMessage = (ws, store) => evt => {     // Parse the JSON message received on the websocket     const msg = evt.data;     store.dispatch(SocketExampleActions.socketsMessageReceiving(msg));   };

      case 'SOCKETS_CONNECT':         if (socketExample !== null) {           console.log('SOCKETS_DISCONNECTING');           store.dispatch(SocketExampleActions.socketsDisconnecting());           socket.close();         }         console.log('SOCKETS_CONNECTING');         socketExample = new WebSocket('wss://echo.websocket.org/');         store.dispatch(SocketExampleActions.socketsConnecting());         socketExample.onmessage = onMessage(socketExample, store);         socketExample.onclose = onClose(store);         socketExample.onopen = onOpen(action.token);         break;

Отправка сообщения

В самом мидлваре пишем редюсер.

      case 'SOCKETS_MESSAGE_SEND':         socketExample.send(action.message_send);         store.dispatch(SocketExampleActions.socketsMessageSending(action.message_send));         break;

Подробнее. action.message_send — это о чем? Все, что мы кладем в стор появляется в процессе обработки store => next => action => в этих переменных. Когда мы запускаем экшен, то в этой переменной передается все с чем мы этот экшен запустили.

Давайте реализуем как в экшене появится сообщение.

Правим файл ./src/components/SocketExampleComponents/SocketMessageLog.js, чтобы получить возможность запускать экшен от пользователя.

  static propTypes = {     loaded: PropTypes.bool,     message: PropTypes.string,     connected: PropTypes.bool,     message_history: PropTypes.array,     socketsMessageSend: PropTypes.func   }

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

  handleSendButton = (event) => {     event.preventDefault();     this.props.socketsMessageSend(this.refs.message_text.value);     this.refs.message_text.value = '';   }

Подробнее. Мы получим новые сообщения сразу их стора по факту в переменной message_history и react на сразу отрисует их. Для того, чтобы отправить сообщение мы вызываем экщен мидлваре this.props.socketsMessageSend(this.refs.message_text.value), тем самым в action мы передаем наше сообщение, которое обрабывается редюсером мидлваре SOCKETS_MESSAGE_SEND, который в свою очередь вызывает событие SOCKETS_MESSAGE_SENDING, которое обрабатывается и отрисовывается интефейсным редюсером.

Запускаем. Проверяем.

Финиш!

[Заметки на полях] Оглянитесь, вспомните себя в начале этой статьи. Сейчас вы сможете развернуть и быстро создать интерфейс к вашему бэкэнду с получением и обработкой данных в реальном времени. Если у вас появились интересные задумки, не откладывайте — делайте.

[Заметки на полях] А вдруг я это все не зря.

Коммит: https://github.com/valentinmk/react-redux-universal-hot-example/commit/40fd0b38f7a4b4acad141997e1ad9e7d978aa3b3

PS

Рабочая копия данного материала размещена тут.

ссылка на оригинал статьи https://habrahabr.ru/post/318148/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *