Это краткое руководство и обучение по фронтэнеду для бэкендера. В данном руководстве я решаю проблему быстрого построения пользовательского интерфейса к серверному приложению в виде одностраничного веб-приложения (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, там же инструкция по установке.
Тут привожу последовательность действий
- Скачиваем и разархивируем архив/форкаем/что-угодно-как-вам-нравится.
- Через node.js command line или терминал переходим в эту папку
- Запускаем
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.
Для этого нужно:
- Создать редюсер
- Создать экшены
- И подключить все это в общую систему
Про экшены написано В официальной документации
Про редюсеры написано Там же
Создаем файл
Создаем файл ./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 мы видим.
Если вопросы с 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
действуем по списку:
- проверяем полученные props
- присваиваем их внутри render
- используем в нашем компоненте
Начнем, импортируем недостающие библиотеки:
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/
Добавить комментарий