Марафон. Фронтенд. Создание приложения "Викторина" на ReactJs.
Фронтенд разработка на языке JavaScript. -> Задание викторины.
Задание викторины.
Создать фронтенд приложение программы “Попутчик” используя библиотеку jQuery.
Бекенд отрадает все возможные запросы на сервер, форматы запросов и ответов.
Суть приложения.
Пользователю доступно 4 раздела.
-
Форма заказа.
-
Поиск заказов.
-
Личный кабинет.
-
Регистрация.
Регистрация.
При регистрации пользователь вводит свое имя и выбирает город.
Форма заказа.
Пользователь заполняет следующие поля.
-
Начальная точка поездки.
-
Конечная точка поездки.
-
Дата и время поездки.
Поиск заказов.
В данном разделе пользователь может провести поиск интересующих заказов по выбранному раену города.
Далее пользователь может присоединиться к поездке.
Личный кабинет.
В данном разделе пользователь видит свои заказы или те заказы, к которым он присоеденен.
По каждому заказу он может отправить сообщение всем, кто к нему присоединен.
Технические требования.
Дизайн взять на основе bootstrap и сориентировать на мобильную версию клиента.
Фронтенд разработка на языке JavaScript. -> Работа с веб-сокетами.
Работа с сокет соединением.
Фронтенд разработка на языке JavaScript. -> Создаем онлайн викторину на ReactJs.
Фронтенд. Приложение “Викторина” на ReactJs.
Клонируем шаблон.
git clone git@github.com:zdimon/marafon-js-quiz-template.git
Создаем приложение.
npx create-react-app my-app --template typescript
Эта команда создаст простейшее приложение и установит в него все зависимости.
Запуск веб сервера приложения.
npm run start
Копируем все из шаблона в папку public c заменой index.html.
Ошибка появляется из за того, что теперь в index.html отсутствует элемент, к которому привязывается React.
Закоментарим запуск приложения по умолчанию.
// ReactDOM.render(
// <React.StrictMode>
// <App />
// </React.StrictMode>,
// document.getElementById('root')
// );
Запускаем сервер
npm run startу
В React все состоит из программных компонентов, которые привязываются к тегам страницы.
Создаем компонент Question в новой папке components.
Question.tsx
import React from 'react';
export function Question() {
return (
<div className="question">
Test question
</div>
);
}
Привяжем компонент к диву в index.tsx.
import {Question} from './components/Question';
ReactDOM.render(
<React.StrictMode>
<Question />
</React.StrictMode>,
document.getElementById('#currentQuestionBlock')
);
Создадим компонент формы.
components/LoginForm.tsx
import React from 'react';
export function LoginForm() {
return (
<div className="chat-start">
Как вас зовут?
<input type="text" className="round" />
<button id="chat-start" className="btn bg-white mt-3">Начать!</button>
</div>
);
}
Привяжем к элементу.
...
import {LoginForm} from './components/LoginForm';
...
ReactDOM.render(
<React.StrictMode>
<LoginForm />
</React.StrictMode>,
document.getElementById('#loginForm')
);
Создадим условие, где проверим залогиненность.
if(sessionStorage.getItem('username')) {
ReactDOM.render(
<React.StrictMode>
<Question />
</React.StrictMode>,
document.getElementById('#currentQuestionBlock')
);
} else {
ReactDOM.render(
<React.StrictMode>
<LoginForm />
</React.StrictMode>,
document.getElementById('#loginForm')
);
}
Создадим сервис, генерирующий запросы на сервер.
Установим библиотеку для http запросов.
npm install axios --save
Создаем класс в Request.ts
import axios from "axios";
const serverUrl = 'http://localhost:7777/v1/quiz/';
export class Request {
async get(url: string) {
let response = await axios.get(`${serverUrl}${url}`)
return response.data;
}
}
axios в своей работе использует промисы.
Существует специальный синтаксис для работы с промисами, который называется «async/await».
У слова async один простой смысл: эта функция всегда возвращает промис. Значения других типов оборачиваются в завершившийся успешно промис автоматически.
await axios.get() - запустит запрос и не пойдет дальше по коду пока он не завершится.
Используем в компоненте формы LoginForm.
Сделаем AJAX-запрос в событии компонента componentDidMount.
В компоненте могут присуствовать состояния - это те данные, которые меняются по мере событий приложения.
Для того, чтобы эти данные определить и менять, есть функция useState.
Эта функция возвращает переменную состояния и функцию для его изменения (т.к. состояние никогда не меняется напрямую через переменную присвоением).
Т.е. когда получим данные с сервера мы должны вызвать setState, чтобы передать их компоненту.
При получении данных с сервера мы должны убедится что на этот момент компонент существует в DOM.
Для того, чтобы подвязатся к моменту монтирования компонента используем хук useEffect
Хук useEffect представляет собой совокупность методов componentDidMount, componentDidUpdate, и componentWillUnmount.
Создание компонента одного стикера.
Так как мы планируем выводить стикеры в цикле удобней под них создать отдельный компонент и передать в него стикер свойством (props).
import React from 'react';
import { useState } from 'react';
export function Sticker(props: any) {
const [current, setCurrent] = useState(0);
return (
<img
width="50"
alt=""
src={props.item.get_url}
/>
)
}
** Sticker(props: any)** - тут мы говорим о том что в компонент зайдут данные.
Отработаем клик на стикере и установим текущий стикер в виде переменной-состояния current.
import React from 'react';
import { useState } from 'react';
export function Sticker(props: any) {
const [current, setCurrent] = useState(0);
const select = (id: number) => {
setCurrent(id);
}
return (
<img
width="50"
className={` ${props.item.id===current ? 'sticker-active': ''} `}
alt=""
onClick={() => select(props.item.id)}
src={props.item.get_url}
/>
)
}
LoginForm запрос стикеров с сервера.
import React, { useState, useEffect } from 'react';
import { Request } from '../Request';
import {Sticker} from './Sticker';
export function LoginForm() {
const [stickers, setStickers] = useState([]);
useEffect(() => {
let req = new Request();
req.get('sticker/list').then((data) => {
setStickers(data);
})
},[]);
return (
<div className="login-form">
<p>Как вас зовут?</p>
<p><input type="text" className="round" /></p>
<div className="stickers" >
{ stickers.map((el,index) => <Sticker key={index} item={el} />) }
</div>
<button id="chat-start" className="btn bg-white mt-3">Начать!</button>
</div>
);
}
При использовании useEffect нужно передать пустой массив вторым параметром для избежания рекурсии т.к. иначе это будет срабатывать при каждых изменениях стейта.
useEffect(() => {},[]);
Добавим запрос на сервер для получения текущего вопроса в компоненте Question.tsx
import React from 'react';
import { Request } from '../Request';
import { useState, useEffect } from 'react';
export function Question() {
const req = new Request();
const [question, setQuestion] = useState({
question: '',
answers: ''
});
useEffect(() => {
req.get('get_current_question').then((data) => {
setQuestion(data);
})
},[]);
return (
<div className="question">
{question.question} ({question.answers})
</div>
);
}
TODO: Формат сообщения тут неплхо оформить в виде интерфейса.
useState({
question: '',
answers: ''
});
Далее нам нужно отработать клик по стикеру и передать его из компонента Sticker в компонент LoginForm.
Для этого определим обработчик клика в Sticker.ts
import React from 'react';
export function Sticker(props:any) {
var select = (id: number) => {
props.onSelectSticker(id);
}
return (
<img width="50"
onClick={() => select(props.item.id)}
src={props.item.get_url} />
);
}
И прокинем обработчик через props из родительского компонента в Sticker.
...
export function LoginForm() {
...
const [sticker, setSticker] = useState(0);
...
var handleSelectSticker = (id: number) => {
setSticker(id);
}
return (
...
<div className="stickers">
{
stickers.map((el, key) =>
<Sticker
onSelectSticker={handleSelectSticker}
item={el}
key={key}
/>)
}
...
Добавим state для имени пользователя и привяжем его к изменению input-а при помощи обработчика handleChangeUsername.
export function LoginForm() {
...
const [username, setUsername] = useState('');
var handleChangeUsername = (evt:any) => {
setUsername(evt.target.value);
}
...
<input onChange={handleChangeUsername} type="text" className="round" />
Отправляем сообщение на сервер.
Создадим компонент формы отправки MessageForm.ts.
import React, {useState} from 'react';
import { Request } from '../Request';
export function MessageForm() {
const req = new Request();
const [message, setMessage] = useState('');
var handleChangeMessage = (evt:any) => {
setMessage(evt.target.value);
}
var submit = () => {
let data = {
message: message,
playername: localStorage.getItem('username')
}
req.post('save_message', data).then((data) => {
setMessage('');
})
}
return (
<>
<input
type="text"
className="form-control mr-3"
placeholder="Введите ответ на вопрос"
defaultValue={message}
onChange={handleChangeMessage}
/>
<button onClick={submit} type="submit" className="btn btn-primary d-flex align-items-center p-2"><i className="fa fa-paper-plane-o" aria-hidden="true"></i><span className="d-none d-lg-block ml-1">Send</span></button>
);
}
Сокет соединение.
Нам теперь необходимо установить сокет-соединение.
Для этого создадим класс соединения SocketConnection.ts и определим обработчики основных событий в его конструкторе.
export class SocketConnection {
timer:any = null;
websocket:any = null;
constructor() {
this.wsConnect();
}
wsConnect() {
clearInterval(this.timer);
this.websocket = new WebSocket('ws://quizapi.webmonstr.com:7777/quiz/');
this.websocket.onerror = (evt: any) => {
this.timer = setTimeout(() => this.wsConnect(),2000);
}
this.websocket.onmessage = (message: any) => {
var message = JSON.parse(message.data)
console.log(message);
}
this.websocket.onclose = (event: any) => {
console.log('Close connection');
this.timer = setTimeout(() => this.wsConnect(),2000);
};
}
}
События.
Нам необходимо отслеживать приход сообщений по сокетам в разных компонентах.
Для этого удобней всего генерировать события и подписываться на них в компонентах.
В этом класе мы будем использовать библиотеку RxJS и ее изструмен Subject.
Установим
npm install rxjs @types/rx --save
Применим и запустим событие при приходе сообщения.
...
import { Subject } from 'rxjs';
...
export class SocketConnection {
...
newMessage$ = new Subject();
...
this.websocket.onmessage = (message: any) => {
var message = JSON.parse(message.data)
this.newMessage$.next(message);
}
Однкако теперь нужно решить проблебу множественных экземпляров этого класса.
Применим паттерн singletone.
export class SocketConnection {
private static instance: SocketConnection;
...
public static getInstance(): SocketConnection {
if (!SocketConnection.instance) {
SocketConnection.instance = new SocketConnection();
}
return SocketConnection.instance;
}
Теперь будем создавать этот объект через метод getInstance и он будет всегда в единственном экземпляре.
import { SocketConnection } from '../SocketConnection';
const socket = SocketConnection.getInstance();
Подпишемся на это событие в компоненте Question.
import { SocketConnection } from '../SocketConnection';
export function Question() {
const req = new Request();
const socket = SocketConnection.getInstance();
socket.newMessage$.subscribe((data: any) => {
console.log(data);
});
Однако при этом мы наблюдаем 4 реакции на одно событие.
Проблема в том, что функция реакт компонента запускается по многу раз.
Чтобы что то выстрелило один раз, нужно завернуть это в хук useEffect.
Сделаем это и запросим новый вопрос с сервера.
...
export function Question(props: any) {
useEffect(() => {
socket.sendMessage$.subscribe((data: any) => {
if(data.is_right) {
let req = new Request();
req.get('get_current_question').then((data) => {
setQuestion(data);
console.log(data);
});
}
});
},[])
...
Создадим компонент сообщения Message.tsx.
import React from 'react';
export function Message(props: any) {
return (
<div className={`chat ${props.message.is_right ? "chat-left" : ""}`}>
<div className="chat-user">
<a className="avatar m-0">
<img src="images/user/1.jpg" alt="avatar" className="avatar-35 " />
</a>
<span className="chat-time mt-1">{props.message.playername}</span>
</div>
<div className="chat-detail">
<div className="chat-message">
<p>{ props.message.text } {props.message.is_right}</p>
</div>
</div>
</div>
);
}
Создадим компонент списка сообщений MessageBox.tsx.
Отработаем сообщение прихода нового ответа.
import React from 'react';
import { Request } from '../Request';
import { useState, useEffect } from 'react';
import { SocketConnection } from '../SocketConnection';
import { Message } from './Message';
const socket = SocketConnection.getInstance();
export function MessageBox(props: any) {
useEffect(() => {
socket.newMessage$.subscribe((payload: any) => {
// const msg = [...messages] as any;
// msg.push(payload);
// setMessages(msg);
const req = new Request();
req.get('message/list').then((data) => {
setMessages(data);
})
});
},[])
const [messages, setMessages] = useState([]);
useEffect(() => {
const req = new Request();
req.get('message/list').then((data) => {
setMessages(data);
console.log(data);
})
},[]);
return (
<div className="messages">
{ messages.map((el, key) => <Message key={key} message={el} />) }
</div>
);
}
Компонент текущего пользователя CurrentPlayer.tsx.
import React from 'react';
import { useState, useEffect } from 'react';
export function CurrentPlayer(props: any) {
const [image, setImage] = useState('');
useEffect(() => {
setImage(localStorage.getItem('image') as string);
},[]);
return (
<>
<div className="avatar chat-user-profile m-0 mr-3">
<img
src={image}
alt="avatar"
className="avatar-50 " />
</div>
<h5 className="mb-0">{localStorage.getItem('username')}</h5>
<h5 className="player-account"> Правильных ответов: {localStorage.getItem('account')}</h5>
);
}
Компонент списка участников PlayerList.tsx.
Фронтенд разработка на языке JavaScript. -> Формулировка задачи.
Формулировка задачи.
Взять готовый шаблон фронтенд приложения.
ссылка на репозиторий с шаблоном
Комманда для клонирования.
git clone git@github.com:zdimon/marafon-js-quiz-template.git
Используя REST API бекенд, расположенный по адресу http://quizapi.webmonstr.com/
Админка http://quiz.webmonstr.com/admin
Логин - admin
Пароль - admin
Создать приложение, используя библиотку jQuery в виде плагина.
Подключение плагина на странице.
<script src="app.js"></script>
<script>
var app = $('#rootBlock').quizPlugin();
app.start();
</script>
Суть работы приложения:
В начале проверяется переменная username в localStorage браузера.
Если этой переменной нет, то пользователю выводится форма авторизации со списком стикеров и полем, где он указываем свое имя.
Стикеры запрашиваются по адресу http://quiz.webmonstr.com/v1/quiz/sticker/list
После ввода имени и выбора стикера послать на адрес http://quiz.webmonstr.com/v1/quiz/player/join POST запрос с данными json в формате:
{
"name": "string",
"sticker_id": 0
}
Проанализировать ответ от сервера.
В случае успешного ответа (отсутсвие в нем поля error):
-
Cохранить имя пользователя в localStorage.
-
Убрать форму авторизации.
-
Вывести чат-комнату.
В случае ошибки вывести ее на странице над кнопкой формы красным цветом.
Вывод чат-комнаты.
- Запросить текущих игроков и вывести в блоке #playerListBlock согласно дизайну.
Выводим стикер и имя пользователя.
Вместо слова Admin выводит текущий счет пользователя.
Запросить список игроков можно по адресу:
http://quiz.webmonstr.com/v1/quiz/player/list
- Запросить текущий вопрос викторины и вывести в блоке #questionBlock
Адрес запроса:
http://quiz.webmonstr.com/v1/quiz/get_current_question
Формат ответа.
{
"lang": "ru",
"level": 3,
"tp": "questionend",
"mode": "fullmatch",
"theme": 4,
"question": "Какое слово образовано от начальных слов песни «Прелестная Катарина»?",
"answers": "ШАРМАНКА",
"is_published": true,
"order": 1
}
- Запросить список ответов и вывести в блоке chatMessageBlock согласно дизайну.
Адрес запроса.
Формат ответа.
Ответы, помеченные как неправильные, выводить на розовом фоне, правильные - на салатовом.
- При вводе ответа и нажатии на кнопку Send или клавиши Enter отправлять POST сообщение на сервер.
Ответ отправлять только в случае не пустого сообщения.
Адрес.
http://localhost:7777/v1/quiz/save_message
Формат отправляемых данных:
{
"message": "string",
"playername": "string"
}
Ответ игнорировать.
- Создать веб-сокет соединение с сервером.
Адрес веб-сокет соединения.
ws://localhost:7777/quiz/
Обеспечить реакцию приложения на следующие сообщения.
Сообщение о новом сообщении.
Формат сообщения:
{
'type': 'quiz_message',
'message': {...}
}
При приходе сообщения:
-
Вывести его в списке сообщений согласно дизайну и логике описанной выше.
-
В случае если сообщение помечено как правильное,
2.1 Обновить список игроков (либо использовать сообщение об обновлении аккаунта см. ниже) и проиграть произвольный звук.
2.2 Запросить следующий вопрос и обновить блок #questionBlock
Сообщение о удалении сообщения.
Формат.
{
'type': 'quiz_delete_message',
'message': {...}
}
При поступлении сообщения удалять его из списка по ID в сообщении.
Сообщение об обновлении аккаунта.
{
'type': 'quiz_update_account',
'message': {...}
}
При поступлении сообщения обновить аккаунт пользователя по его ID.