Это краткое руководство и обучение по фронтэнеду для бэкендера. В данном руководстве я решаю проблему быстрого построения пользовательского интерфейса к серверному приложению в виде одностраничного веб-приложения (single page app).
Основной целью моего исследования является возможность за разумное время (для одного нормального человека) получить удобный и простой в использовании интерфейс-черновик к серверному приложению. Мы (как разработчики серверной части) понимаем, что наш приоритет — серверная часть. Когда (в гипотетическом проекте) появятся во фронте профи своего дела, они все сделают красиво и "правильно".
В роли учебной задачи представлена страничка чата с каким-то умозрительным "ботом", который работает на стороне сервера и принимает сообщение только через WebSocket. Бот при этом выполняет эхо ваших сообщений (мы тут не рассматриваем серверную часть вообще).
Мне для изложения материала требуется, чтобы вы имели:
базовое знание javascript (тут нужно поискать в интернете справочник по крайней версии js стандартов ES-2015)
Вообще я разрабатываю приложение на языке Python. Погоди-погоди уходить ...
Что мне было нужно:
мне нужно чтобы реализация интерфейса не диктовала мне выбор технологий на стороне серверной части
современные технологии (мне нечего было терять или быть чем-то обязанным "старым проверенным приемам")
это должно быть одностраничное приложений (я уже сам выберу, где можно обновлять страницу целиком)
мне нужна реакция пользовательского интерфейса в реальном времени на серверные события
мне нужен обмен информацией сервер-клиент (а не клиент-сервер) в реальном времени
мне нужна возможность генерировать обновления клиента на сервере
Что было испробовано:
вариации на тему на чистом js (устарело, есть много полезных моделей велосипеда)
JQuery (уже не могу ТАК извратить так свой мозг, крайне сложный для быстрого старта синтаксис и… это дело профессионалов)
Angular (переход на 2 версию спугнул и не нашел за отведенное время лазейки к решению моей задачи)
Socket.io (там все реализовано, если вы node.js программист вы уже его используете, но он слишком сильно привязывает серверную часть на node, мне нужен только клиент без третьих лиц)
Выбрано в итоге:
React (понятно и доступно/просто + babel = делает язык вполне понятным)
Redux (импонирует использование единой помойки единого хранилища)
WebSockets (очень просто и не связывает руки, а позволяет внутри себя уже применять такой формат какой позволит фантазия)
Упрощения и допущения:
Мы не будем использовать авторизации в приложении
Мы не будет использовать авторизации в WebSocket-ах
Часть первая. Первоначальная настройка. Настройка одной страницы
Часть вторая. Проектирование будущего приложения
Часть третья. Изумительный 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
Прежде чем начнем. Я все разрабатывал в обратном порядке — сначала крутил мидлваре, потом прокидывал экшены и только потом уже прикручивал адекватный интерфейс в reactjs. Мы в руководстве будем делать все в правильном порядке, потому что так действительно быстрее и проще. Минус моего подхода в том, что я использовал в разы больше отладки и "костылей", чем нужно на самом деле. Будем рациональными.
Часть вторая. Проектирование будущего приложения
Сначала мы проектируем интерфейс пользователя. Для этого мы примерно представляем, как должен выглядеть скелет нашего интерфейса и какие действия будут происходить в нем.
В руководстве для начинающих React представлен подход по проектированию динамических приложений на React, от которого мы не будем отклоняться, а прямо будем следовать по нему.
Дэн Абрамов писал в своей документации много про то, что и как нужно разделять в приложении и как организовывать структуру приложения. Мы будем следовать его примеру.
Итак начнем.
Прежде всего хочу сказать, что для наглядности и отладки прямо при написании приложения мы будем добавлять элементы уберем с формы после окончания работы.
Пользовательский интерфейс "Вариант 1"
Мы добавляем два новых раздела на нашу страницу.
В логе подключения сокетов будем кратко выводить текущие события, связанные с подключением отключением. Изменяем файл ./src/containers/SocketExample/SocketExamplePage.js.
Создаем файл ./src/redux/modules/socketexamplemodule.js и наполняем базовыми экшенами и редюсерами. Вот тут базовом примере есть странная нестыковка, все предлагается писать в одном файле, не разделяя на файл экшенов и редюсеров, ну допустим. Все равно — мы тут все взрослые люди (we are all adults).
Все экшены мы будем запускать по нажатию кнопок, кроме события SOCKETS_MESSAGE_RECEIVING, который мы будем синтетически вызывать вслед за отправкой сообщения. Это делается, чтобы в процессе разработки эмулировать недостающие в настоящий момент (или на конкретном этапе) функционал серверной части приложения.
Более подробно про структуру reducer и зачем Object.assign({}, state,{}); можно прочитать тут.
Вы заметили инициализацию state = initialState, которой мы не объявили (поставьте ESLint или его аналог — сильно упростит жизнь Нормального Человека). Добавим объявление до редюсера. Это будет первое состояние, которое мы будем иметь в нашем сторе на момент загрузки страницы, ну точнее страница будет загружаться уже с этим первоначальным состоянием.
А стор у нас уже создан и редюсер в него подключен. Ничего не делаем.
Если подробнее, то вы должны помнить, как мы добавили наш редюсер в 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';
Пишем коннектор, которым будем забирать данные из нашего редюсера.
Мы передали новые данные (через react) в компонент. Теперь переписываем наш компонент, который уже ничего не знает про стор (redux), а только обрабатывает переданные ему данные.
В файле ./src/components/SocketExampleComponents/SocketConnectionLog.js действуем по списку:
проверяем полученные props
присваиваем их внутри render
используем в нашем компоненте
Начнем, импортируем недостающие библиотеки:
import React, {Component, PropTypes} from 'react';
Теперь переходим к компоненту SocketExampleMessageLog и сделаем его абсолютно самостоятельным, в смысле работы со стором. Мы не будем передавать в него никакие props, он будет получать все, что ему нужно из стор сам.
import React, {Component, PropTypes} from 'react';
import {connect} from 'react-redux';
import * as socketExampleActions from 'redux/modules/socketexamplemodule';
добавляем connect, проверку типов и используем полученные данные
В предыдущих частя мы все подготовили к тому, чтобы начать использовать стор.
В этой части мы будем связывать события в 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:
Теперь давайте обеспечим прием преданных в компонент экшенов в файле ./src/components/SocketExampleComponents/SocketConnectionLog.js.
Мы будем добавлять их (экшены) в проверку и использовать в наших обработчиках действий на форме. Обработчиков сделаем два: по клику кнопки "Connect" и "Disconnect".
Запускаем. Проверяем. Ура, оно живо! Можно посмотреть в DevTools, что события создаются в сторе.
Если внимательно проследить как меняются состояния, то можно заметить, что компонент истории сообщений работает как-то не так (хотя он написан правильно). Дело в том, что при нажатии кнопки подключения у нас состояние connected = false, а при разрыве подключения у нас состояние connected = true. Давай-те поправим.
Для этого в файле ./src/redux/modules/socketexamplemodule.js правим странные строчки
НО далее мы поменяем эти значения на исходные, это важный момент. Событие попытки подключения не тождественно состоянию подключено (да я кэп).
Реализуем историю подключения. Главное ограничение принцип работы самого стора. Мы нее можем изменять само состояние, но мы можем его целиком пересоздавать и присваивать. Поэтому чтобы накапливать историю мы будем ее копировать, прибавлять к копии текущее состояние и присваивать это значение оригиналу (с которого сняли копию).
Делаем отображение в том же элементе. Прежде всего передаем переменную истории через props в файле ./src/containers/SocketExample/SocketExamplePage.js. Далее в файле ./src/components/SocketExampleComponents/SocketConnectionLog.js принимает переданную переменную.
Приступим в файле ./src/containers/SocketExample/SocketExamplePage.js забираем из стора:
Проверяем. И получаем нашу запись в истории подключения, но почему то с запятыми. Javascript, WTF? Ну да ладно, если мы добавим после мапа и реверса .join(''), то это все решает.
".join('') все решает.", Карл!
Какой у нас результат? Читаем и пишем в стор! Можно себя похвалить! Но этого явно мало, ведь мы делаем это только внутри своей же собственной странички и никак не общаемся с внешним миром.
У нас есть заготовка для подключения/отключения к сокету. Теперь мы должны сделать оболочку для чата, она станет нашим рабочем моделью (прототип у нас уже есть).
С чатом мы выполним такие же действия, как и с логом (историей) подключений — добавим историю чата и научим ее выводить.
Полный цикл будет выглядеть так:
В редаксе нужно:
объявить новую переменную и инициализировать,
описать для нее экшены,
описать как данная переменна будет изменяться.
В компоненте нужно:
принять эту переменную,
включить ее в отображение,
связать кнопку на интерфейсе и экшены.
Настройка редюсера
Начнем с файле ./src/redux/modules/socketexamplemodule.js нам нужно
добавить новую переменную.
Обратите внимание на переменные переменный 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.
Подробнее, забираем по ссылке из поля message_text. Передаем message_text в наш экшен оправки сообщения. Стираем значение в этом поле для ввода нового.
Не пытайтесь использовать более вложенные ветвления — это у вас не получится. Т.е. не пытайтесь использовать вложенные ' '?' ':' '. Вас будут от этого защищать. Причина — здесь не место вычислений данных. Здесь вообще про интерфейс.
Давайте создадим наш собственный мидлваре, в котором будем реализовывать интерфейс к сервису, построенному на 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(); оставили просто, чтобы не ругался валидатор, вообще они нам понадобятся в будущем).
Не проверяем, у нас ничего не работает, потому что мы не подключили наш класс в мидлваре. Исправляем.
Мы проверили концепцию и готовы делать нашу боевую модель. Теперь будем подключаться к websockets.
Здесь и далее даются варианты написания кода, которые иллюстрируют ход разработки. Эти примеры содержат преднамеренные ошибки, которые показывают основные технические проблемы и особенности, с которыми я столкнулся в рамках подготовительных работ.
Добавляем в файл ./src/redux/middleware/socketExampleMiddleware.js функции, которыми будем обрабатывать события подключения и отключения.
добавляем наши редюсеры и убираем лишнее логирование.
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 экшена новыми в файле srcreduxmodulessocketexamplemodule.js.
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.
Ну вроде все — проверяем. Нужно использовать закладку Network или ее аналог в вашем браузере, чтобы увидеть подключения к веб сокетам.
Для тестирования давайте проверим, что будет если мы на самом деле не смогли подключиться к сокетам.
socketExample = new WebSocket('ws://echo.websocket.org123/');
Подробнее. Эта проверка связана с тем, что обработка событий у нас идет в асинхронном режиме. Мы не знаем в каком порядке от сокета нам будут прилетать события — последовательно, в обратном порядке или парами. Наш код должен быть способным корректно обрабатывать любые варианты.
Попробуйте самостоятельно переместить store.dispatch(socketExampleActions.socketsDisconnect()); из метода onClose в кейс редюсера и посмотреть что же изменится.
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, чтобы получить возможность запускать экшен от пользователя.
Подробнее. Мы получим новые сообщения сразу их стора по факту в переменной message_history и react на сразу отрисует их. Для того, чтобы отправить сообщение мы вызываем экщен мидлваре this.props.socketsMessageSend(this.refs.message_text.value), тем самым в action мы передаем наше сообщение, которое обрабывается редюсером мидлваре SOCKETS_MESSAGE_SEND, который в свою очередь вызывает событие SOCKETS_MESSAGE_SENDING, которое обрабатывается и отрисовывается интефейсным редюсером.
Запускаем. Проверяем.
Финиш!
[Заметки на полях] Оглянитесь, вспомните себя в начале этой статьи. Сейчас вы сможете развернуть и быстро создать интерфейс к вашему бэкэнду с получением и обработкой данных в реальном времени. Если у вас появились интересные задумки, не откладывайте — делайте.