Фронтенд разработка на языке JavaScript. / Марафон. Фронтенд. Создание приложения "Викторина" на ReactJs. / Создаем онлайн викторину на 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.