Фронтенд. Програма “Вікторина” на 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.

start page

Помилка з’являється через те, що тепер в 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.

У компоненті можуть бути стани - це дані, які змінюються в міру подій додатка.

Для того, щоб ці дані визначити і міняти, є функція використаннядержави.

Ця функція повертає змінну стану та функцію для його зміни (бо стан ніколи не змінюється безпосередньо через змінну присвоєнням).

Тобто. Коли отримаємо дані з сервера, ми повинні викликати 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: ''
      });

start page

Далі нам потрібно відпрацювати клік по стікеру і передати його з компонента 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.

[посилання на документацію] (https://www.learnrxjs.io/learn-rxjs/subjects/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 реакції на одну подію.

start page

Проблема в тому, що функція реакт компонента запускається багато разів.

Щоб щось вистрілило один раз, потрібно загорнути це в хук, використовуючиефект.

Зробимо це та запитаємо нове питання із сервера.

...
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>
      &nbsp;<h5 className="player-account"> Правильных ответов: {localStorage.getItem('account')}</h5>
        
      );
}

Компонент у списку учасників PlayerList.tsx.