Обычно, используя HTTP-запросы, сервер не передает данные клиенту в режиме реального времени. Но используя Socket.io, сервер сможет передавать клиенту информацию о некоторых событиях, произошедших на сервере, в режиме реального времени.

Наше приложение будет состоять из двух страниц.

Страница входа в чат:

How our app home page will look: a form with username input, select room dropdown and Join Room button
Как будет выглядеть главная страница нашего приложения: форма с вводом имени пользователя, выпадающий список выбора комнаты и кнопка Join Room

И страница самого чата:

The finished chat page
Готовая страница чата

Вот что мы будем использовать для создания этого приложения:

  • FrontendReact (фронтенд-фреймворк JavaScript для создания интерактивных приложений)

  • БэкэндNode и Express (Express — очень популярный фреймворк NodeJS, который помогает легко создавать API и бэкенды)

  • База данных: HarperDB (платформа для работы с данными и приложениями, которая позволяет запрашивать данные с помощью SQL или NoSQL. HarperDB также имеет встроенный API, что избавляет нас от необходимости писать много кода для бэкенда)

  • Коммуникация в реальном времениSocket.io (см. ниже).

Вот исходный код (не забудьте поставить ему звезду).

Оглавление

  1. Что такое Socket.io?

  2. Настройка проекта

  3. Как создать страницу "Join a room"

  4. Как настроить сервер

  5. Как создать первый слушатель событий Socket.io на сервере

  6. Как работают комнаты в Socket.io

  7. Как создать страницу чата

  8. Как создать компонент сообщений (B)

  9. Как создать схему и таблицу в HarperDB

  10. Как создать компонент Send Message (C)

  11. Как настроить переменные окружения HarperDB

  12. Как разрешить пользователям отправлять сообщения друг другу с помощью Socket.io

  13. Как получить сообщения из HarperDB

  14. Как отобразить последние 100 сообщений на клиенте

  15. Как отобразить комнату и пользователей (A)

  16. Как удалить пользователя из комнаты Socket.io

  17. Как добавить слушателя события disconnect


Что такое Socket.IO?

Socket.IO позволяет серверу передавать информацию клиенту в режиме реального времени, когда на сервере происходят события.

Например, если вы играете в многопользовательскую игру, событием может стать то, что ваш «друг» забьет вам эффектный гол.

С Socket.IO вы бы почти мгновенно узнали о пропущенном голе.

Без Socket.IO клиенту, чтобы убедиться, что событие произошло на сервере, пришлось бы делать несколько AJAX запросов. Например, клиент мог бы использовать JavaScript, чтобы проверять наличие события на сервере каждые 5 секунд.

С Socket.IO клиенту не нужно делать несколько AJAX запросов, чтобы проверить, произошло ли какое-то событие на сервере. Вместо этого сервер отправляет информацию клиенту, как только получает ее. Это намного лучше. 

Итак, Socket.IO позволяет легко создавать приложения реального времени, такие как чат-приложения и многопользовательские игры.

Настройка проекта

1. Как настроить папки

Начните новый проект в вашем текстовом редакторе (у меня это VS Code) и создайте в корне две папки с названиями client и server.

Realtime chat app folder structure
Структура папок приложения для чата в реальном времени

Фронтенд React-приложения мы создадим в папке client, а бэкенд Node/Express — в папке server.

2. Как установить зависимости для клиента

Откройте терминал в корне проекта (в VS Code это можно сделать, нажав Ctrl+' или перейдя в терминал -> новый терминал).

Далее установим React в клиентскую директорию:

$ npx create-react-app client

После установки React перейдите в папку client и установите следующие зависимости:

$ cd client
$ npm i react-router-dom socket.io-client

React-router-dom позволит настраивать маршруты к различным компонентам React — по сути, создавать различные страницы.

Socket.io-client — это клиентская версия socket.io, которая позволяет «выпускать» события на сервер. Получив их на сервере, мы можем использовать серверную версию socket.io для таких действий, как отправка сообщений пользователям, находящимся в той же комнате, что и отправитель, или присоединение пользователя к socket room.

Вы сможете лучше понять это позже, когда мы начнем реализовывать эти идеи с помощью кода.

3. Как загрузить приложение React

Давайте проверим, все ли работает, путем выполнения следующей команды из каталога клиента:

$ npm start

Webpack соберет приложение React и отправит его на http://localhost:3000:

Create react app up and running on localhost
Создаем приложение react и запускаем его на localhost

Теперь давайте настроим базу данных HarperDB — ее мы будем использовать для постоянного сохранения сообщений, отправленных пользователями.

Как настроить HarperDB

Сначала создайте учетную запись в HarperDB.

Затем создайте новый облачный экземпляр HarperDB:

create HarperDB instance
создайте экземпляр HarperDB

Чтобы упростить работу, выберите экземпляр облака:

select HarperDB instance type
выберите тип экземпляра HarperDB

Выберите облачного провайдера (я выбрал AWS):

select HarperDB cloud provider
Выберите облачного провайдера HarperDB

Задайте имя облачному экземпляру и создайте учетные данные экземпляра:

select HarperDB instance credentials
Задаем учетные данные экземпляра HarperDB

У HarperDB щедрый бесплатный уровень, который мы можем использовать для этого проекта, поэтому выбираем его:

select HarperDB instance specs
Выбираем характеристики экземпляра HarperDB

Проверяем правильность данных и создаем экземпляр.

Создание экземпляра займет несколько минут, так что давайте приступим к созданию нашего первого компонента React.

HarperDB instance loading
HarperDB instance loading

Как создать страницу “Join a Room”

Главная страница будет выглядеть следующим образом:

How our app home page will look: a form with username input, select room dropdown and Join Room button
Как будет выглядеть главная страница нашего приложения: форма с вводом имени пользователя, выпадающий список выбора комнаты и кнопка Join Room

Пользователь вводит имя пользователя, выбирает комнату чата из выпадающего списка и нажимает кнопку "Join Room". После этого пользователь попадает на страницу чата.

Итак, давайте сделаем эту домашнюю страницу.

1. Как создать HTML-форму и добавить стили

Создайте новый файл по адресу src/pages/home/index.js.

Мы добавим базовые стили в приложение с помощью модулей CSS, поэтому создайте новый файл: src/pages/home/styles.module.css.

Теперь структура папок должна выглядеть следующим образом:

pages folder with home page component
папка pages с компонентом домашней страницы

Создадим базовую форму HTML:

// client/src/pages/home/index.js

import styles from './styles.module.css';

const Home = () => {
  return (
    <div className={styles.container}>
      <div className={styles.formContainer}>
        <h1>{`<>DevRooms</>`}</h1>
        <input className={styles.input} placeholder='Username...' />

        <select className={styles.input}>
          <option>-- Select Room --</option>
          <option value='javascript'>JavaScript</option>
          <option value='node'>Node</option>
          <option value='express'>Express</option>
          <option value='react'>React</option>
        </select>

        <button className='btn btn-secondary'>Join Room</button>
      </div>
    </div>
  );
};

export default Home;

Выше у нас есть простой текстовый ввод для ввода имени пользователя и выпадающий список с некоторыми стандартными опциями для выбора чата, к которому пользователь может присоединиться.

Теперь импортируем этот компонент в App.js и настроим для него маршрут с помощью пакета react-router-dom. Это будет наша домашняя страница, поэтому путь будет просто "/":

// client/src/App.js

import './App.css';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './pages/home';

function App() {
  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route path='/' element={<Home />} />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

Теперь давайте добавим несколько базовых стилей, чтобы приложение выглядело более презентабельно:

/* client/src/App.css */

html * {
  font-family: Arial;
  box-sizing: border-box;
}
body {
  margin: 0;
  padding: 0;
  overflow: hidden;
  background: rgb(63, 73, 204);
}
::-webkit-scrollbar {
  width: 20px;
}
::-webkit-scrollbar-track {
  background-color: transparent;
}
::-webkit-scrollbar-thumb {
  background-color: #d6dee1;
  border-radius: 20px;
  border: 6px solid transparent;
  background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
  background-color: #a8bbbf;
}
.btn {
  padding: 14px 14px;
  border-radius: 6px;
  font-weight: bold;
  font-size: 1.1rem;
  cursor: pointer;
  border: none;
}
.btn-outline {
  color: rgb(153, 217, 234);
  border: 1px solid rgb(153, 217, 234);
  background: rgb(63, 73, 204);
}
.btn-primary {
  background: rgb(153, 217, 234);
  color: rgb(0, 24, 111);
}
.btn-secondary {
  background: rgb(0, 24, 111);
  color: #fff;
}

Также добавим стили, характерные для компонента главной страницы:

/* client/src/pages/home/styles.module.css */

.container {
  height: 100vh;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  background: rgb(63, 73, 204);
}
.formContainer {
  width: 400px;
  margin: 0 auto 0 auto;
  padding: 32px;
  background: lightblue;
  border-radius: 6px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 28px;
}
.input {
  width: 100%;
  padding: 12px;
  border-radius: 6px;
  border: 1px solid rgb(63, 73, 204);
  font-size: 0.9rem;
}
.input option {
  margin-top: 20px;
}

Давайте также сделаем кнопку "Join Room" полноразмерной, добавив атрибут стиля:

// client/src/pages/home/index.js

<button className='btn btn-secondary' style={{ width: '100%' }}>Join Room</button>

Теперь главная страница выглядит хорошо:

Fully-styled home page
Полностью стилизованная домашняя страница

2. Как добавить функциональность в форму Join Room

У нас есть базовая форма и стилизация, теперь пришло время добавить немного функциональности.

Вот что мы хотим, чтобы происходило, когда пользователь нажимает кнопку "Join Room":

  1. Проверяем, заполнены ли поля с именем пользователя и комнатой.

  2. Если да, то отправляем событие сокета на сервер.

  3. Перенаправляем пользователя на страницу чата (которую мы создадим позже).

Нам понадобится создать состояние для хранения значений username и room. Также нам нужно создать экземпляр сокета.

Можно создать эти состояния непосредственно в компоненте home, но странице Chat также понадобится доступ к usernameroom и socket. Поэтому мы поднимем состояние в App.js, где затем сможем передать эти переменные компонентам домашней страницы и страницы чата.

Итак, давайте создадим состояние, настроим сокет в App.js и передадим эти переменные в качестве реквизитов компоненту <Home />. Мы также передадим функции set state, чтобы можно было изменять состояние из <Home />:

// client/src/App.js

import './App.css';
import { useState } from 'react'; // Add this
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import io from 'socket.io-client'; // Add this
import Home from './pages/home';

const socket = io.connect('http://localhost:4000'); // Add this -- our server will run on port 4000, so we connect to it from here

function App() {
  const [username, setUsername] = useState(''); // Add this
  const [room, setRoom] = useState(''); // Add this

  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route
            path='/'
            element={
              <Home
                username={username} // Add this
                setUsername={setUsername} // Add this
                room={room} // Add this
                setRoom={setRoom} // Add this
                socket={socket} // Add this
              />
            }
          />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

Теперь получим доступ к этим реквизитам в компоненте Home. Для этого используем деструктуризацию:

// client/src/pages/home/index.js

import styles from './style.module.css';

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  return (
    // ...
  );
};

export default Home;

Когда пользователь набирает свое имя пользователя или выбирает комнату, нужно обновить переменные состояния username и room:

// client/src/pages/home/index.js

// ...

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  return (
    <div className={styles.container}>
      // ...
        <input
          className={styles.input}
          placeholder='Username...'
          onChange={(e) => setUsername(e.target.value)} // Add this
        />

        <select
          className={styles.input}
          onChange={(e) => setRoom(e.target.value)} // Add this
        >
         // ...
        </select>

        // ...
    </div>
  );
};

export default Home;

Теперь, когда мы получаем данные, введенные пользователем, мы можем создать функцию обратного вызова joinRoom() для случая, когда пользователь нажимает кнопку Join Room:

// client/src/pages/home/index.js

// ...

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  
  // Add this
  const joinRoom = () => {
    if (room !== '' && username !== '') {
      socket.emit('join_room', { username, room });
    }
  };

  return (
    <div className={styles.container}>
      // ...
      
        <button
          className='btn btn-secondary'
          style={{ width: '100%' }}
          onClick={joinRoom} // Add this
        >
          Join Room
        </button>
      // ...
    </div>
  );
};

export default Home;

Выше, когда пользователь нажимает на кнопку, возникает сокет событие join_room, а также объект, содержащий имя пользователя и выбранную комнату. Это событие будет получено сервером чуть позже, где мы совершим несколько волшебных действий.

Чтобы завершить работу над компонентом домашней страницы, нам нужно добавить редирект в нижней части функции joinRoom(), чтобы перенести пользователя на страницу /chat:

// client/src/pages/home/index.js

// ...
import { useNavigate } from 'react-router-dom'; // Add this

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  const navigate = useNavigate(); // Add this

  const joinRoom = () => {
    if (room !== '' && username !== '') {
      socket.emit('join_room', { username, room });
    }

    // Redirect to /chat
    navigate('/chat', { replace: true }); // Add this
  };

 // ...

Проверьте: введите имя пользователя и выберите комнату, затем нажмите Join Room. Вы должны попасть на маршрут http://localhost:3000/chat — пока это пустая страница.

Но прежде чем создать фронтенд Chat Page, давайте запустим кое-что на сервере.

Как настроить сервер

На сервере мы собираемся прослушивать события сокетов, исходящие от фронтенда. В настоящее время от React исходит только событие join_room, поэтому сначала мы добавим этот слушатель события.

Но перед этим нужно установить серверные зависимости и запустить сервер.

1. Как установить серверные зависимости

Откройте новый терминал (в коде VS: Terminal->New Terminal), перейдите в папку с сервером, инициализируйте файл package.json и установите следующие зависимости:

$ cd server
$ npm init -y
$ npm i axios cors express socket.io dotenv
  • Axios — это широко используемый пакет для удобного выполнения запросов к API.

  • Cors позволяет клиенту делать запросы к другим источникам — это необходимо для правильной работы socket.io. Если вы еще не слышали о CORS, смотрите раздел «Что такое CORS»

  • Express — NodeJS фреймворк, который позволяет писать бэкенд проще и с меньшим количеством кода.

  • Socket.io — библиотека, которая позволяет клиенту и серверу общаться в реальном времени, что невозможно при стандартных HTTP-запросах.

  • Dotenv — модуль, который позволяет безопасно хранить закрытые ключи и пароли и загружать их в код, когда это необходимо.

Мы также установим nodemon в качестве dev-зависимости, чтобы не перезапускать сервер каждый раз при внесении изменения в код — это сэкономит нам время и энергию:

$ npm i -D nodemon

2. Как запустить сервер

Создайте папку index.js в корне каталога сервера и добавьте в нее следующий код, чтобы запустить сервер:

// server/index.js

const express = require('express');
const app = express();
const http = require('http');
const cors = require('cors');

app.use(cors()); // Add cors middleware

const server = http.createServer(app);

server.listen(4000, () => 'Server is running on port 4000');

Откройте файл package.json на сервере и добавьте скрипт, который позволит использовать nodemon в разработке:

{
  ...
  "scripts": {
    "dev": "nodemon index.js"
  },
  ...
}

Теперь давайте выполним загрузку сервера, выполнив следующую команду:

$ npm run dev

Мы можем быстро проверить, что сервер работает правильно, добавив обработчик запроса get:

// server/index.js

const express = require('express');
const app = express();
http = require('http');
const cors = require('cors');

app.use(cors()); // Add cors middleware

const server = http.createServer(app);

// Add this
app.get('/', (req, res) => {
  res.send('Hello world');
});

server.listen(4000, () => 'Server is running on port 3000');

Теперь перейдите на сайт http://localhost:4000/:

localhost4000
localhost4000

Наш сервер запущен и работает. Настало время сделать кое-что для Socket.io на стороне сервера!

Как создать первый слушатель событий Socket.io на сервере

Помните, как мы отправляли событие join_room с клиента? Так вот, скоро мы будем слушать это событие на сервере и добавлять пользователя в сокет-комнату.

Но сначала нам нужно прослушать, когда клиент подключается к серверу через socket.io-client.

Теперь, когда клиент подключается с фронтенда, бэкенд перехватывает событие подключения и записывает его в журнал.

Пользователь подключился с помощью уникального идентификатора сокета для данного клиента.

// server/index.js

const express = require('express');
const app = express();
http = require('http');
const cors = require('cors');
const { Server } = require('socket.io'); // Add this

app.use(cors()); // Add cors middleware

const server = http.createServer(app); // Add this

// Add this
// Create an io server and allow for CORS from http://localhost:3000 with GET and POST methods
const io = new Server(server, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
  },
});

// Add this
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // We can write our socket event listeners in here...
});

server.listen(4000, () => 'Server is running on port 3000');

Давайте проверим, перехватывает ли теперь сервер событие подключения от клиента. Перейдите в приложение React по адресу http://localhost:3000/ и обновите страницу.

В консоли терминала сервера вы должны увидеть следующую запись:

user-connected
user-connected

Отлично, наш клиент подключился к серверу через socket.io. Теперь клиент и сервер могут общаться в режиме реального времени!

Как работают комнаты в Socket.io

Из документации Socket.io:

room — это произвольный канал, к которому сокеты могут присоединяться (join) и покидать (leave) его. Он может быть использован для трансляции событий подмножеству клиентов.

Итак, мы можем добавить пользователя в комнату, а затем сервер может отправлять сообщения всем пользователям в этой комнате — это позволит пользователям отправлять сообщения друг другу в режиме реального времени. Круто!

Как добавить пользователя в комнату Socket.io

После того как пользователь подключился через Socket.io, мы можем добавить слушатели событий сокета на сервер, чтобы слушать события, исходящие от клиента. Также можно отправлять события на сервер и слушать их на клиенте.

Давайте теперь прослушаем событие join_room, перехватим данные (username и room) и добавим пользователя в socket room:

// server/index.js

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Add this
  // Add a user to a room
  socket.on('join_room', (data) => {
    const { username, room } = data; // Data sent from client when join_room event emitted
    socket.join(room); // Join the user to a socket room
  });
});

Как отправить сообщение пользователям в комнате

Теперь давайте отправим сообщение всем пользователям в комнате, кроме только что присоединившегося пользователя, чтобы уведомить их о том, что к ним присоединился новый пользователь:

// server/index.js

const CHAT_BOT = 'ChatBot'; // Add this
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Add a user to a room
  socket.on('join_room', (data) => {
    const { username, room } = data; // Data sent from client when join_room event emitted
    socket.join(room); // Join the user to a socket room

    // Add this
    let __createdtime__ = Date.now(); // Current timestamp
    // Send message to all users currently in the room, apart from the user that just joined
    socket.to(room).emit('receive_message', {
      message: `${username} has joined the chat room`,
      username: CHAT_BOT,
      __createdtime__,
    });
  });
});

Выше мы отправляем событие receive_message всем клиентам в комнате, к которой только что присоединился текущий пользователь, вместе с некоторыми данными: сообщением, именем пользователя, отправившего сообщение, и временем отправки сообщения.

Чуть позже мы добавим слушатель событий в наше React-приложение, чтобы перехватить это событие и вывести сообщение на экран.

Давайте также отправим приветственное сообщение только что присоединившемуся пользователю:

// server/index.js

io.on('connection', (socket) => {
  // ...

    // Add this
    // Send welcome msg to user that just joined chat only
    socket.emit('receive_message', {
      message: `Welcome ${username}`,
      username: CHAT_BOT,
      __createdtime__,
    });
  });
});

Когда мы добавляем пользователя в комнату Socket.io, Socket.io хранит только идентификаторы сокетов для каждого пользователя. Но нам понадобятся имена пользователей всех, кто находится в комнате, а также название комнаты. Поэтому давайте сохраним эти данные в переменных на сервере:

// server/index.js

// ...

const CHAT_BOT = 'ChatBot';
// Add this
let chatRoom = ''; // E.g. javascript, node,...
let allUsers = []; // All users in current chat room

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
    // ...
    
    // Add this
    // Save the new user to the room
    chatRoom = room;
    allUsers.push({ id: socket.id, username, room });
    chatRoomUsers = allUsers.filter((user) => user.room === room);
    socket.to(room).emit('chatroom_users', chatRoomUsers);
    socket.emit('chatroom_users', chatRoomUsers);
  });
});

Выше мы также отправляем массив всех пользователей chatRoomUsers обратно клиенту через событие chatroom_users, чтобы можно было перечислить все имена пользователей в комнате на фронтенде.

Прежде чем добавить код на сервер, давайте вернемся на фронтенд и создадим страницу чата — чтобы проверить, получаем ли мы события receive_message.

Как создать страницу чата

В папке клиента создайте два новых файла:

  1. src/pages/chat/index.js

  2. src/pages/chat/styles.module.css

Давайте добавим несколько стилей, которые мы будем использовать на странице чата и в компонентах:

/* client/src/pages/chat/styles.module.css */

.chatContainer {
  max-width: 1100px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1fr 4fr;
  gap: 20px;
}

/* Room and users component */
.roomAndUsersColumn {
  border-right: 1px solid #dfdfdf;
}
.roomTitle {
  margin-bottom: 60px;
  text-transform: uppercase;
  font-size: 2rem;
  color: #fff;
}
.usersTitle {
  font-size: 1.2rem;
  color: #fff;
}
.usersList {
  list-style-type: none;
  padding-left: 0;
  margin-bottom: 60px;
  color: rgb(153, 217, 234);
}
.usersList li {
  margin-bottom: 12px;
}

/* Messages */
.messagesColumn {
  height: 85vh;
  overflow: auto;
  padding: 10px 10px 10px 40px;
}
.message {
  background: rgb(0, 24, 111);
  border-radius: 6px;
  margin-bottom: 24px;
  max-width: 600px;
  padding: 12px;
}
.msgMeta {
  color: rgb(153, 217, 234);
  font-size: 0.75rem;
}
.msgText {
  color: #fff;
}

/* Message input and button */
.sendMessageContainer {
  padding: 16px 20px 20px 16px;
}
.messageInput {
  padding: 14px;
  margin-right: 16px;
  width: 60%;
  border-radius: 6px;
  border: 1px solid rgb(153, 217, 234);
  font-size: 0.9rem;
}

Теперь посмотрим, как будет выглядеть страница чата:

The finished chat page
Готовая страница чата

Если собрать весь код и логику этой страницы в одном файле, это может запутать и усложнить управление, поэтому воспользуемся тем, что мы используем React, и разделим страницу на компоненты:

The chat page split into three components
Страница чата состоит из трех компонентов

Компоненты страницы чата:

A: Содержит название комнаты, список пользователей в этой комнате и кнопку "Leave", которая удаляет пользователя из комнаты.

B: Отправленные сообщения. При первом отображении из базы данных будут извлечены и показаны пользователю последние 100 сообщений, отправленных в этой комнате.

C: Ввод и кнопка для ввода и отправки сообщения.

Сначала создадим компонент B, чтобы отобразить сообщения пользователю.

Как создать компонент сообщений (B)

Создайте новый файл по адресу src/pages/chat/messages.js и добавьте в него код:

// client/src/pages/chat/messages.js

import styles from './styles.module.css';
import { useState, useEffect } from 'react';

const Messages = ({ socket }) => {
  const [messagesRecieved, setMessagesReceived] = useState([]);

  // Runs whenever a socket event is recieved from the server
  useEffect(() => {
    socket.on('receive_message', (data) => {
      console.log(data);
      setMessagesReceived((state) => [
        ...state,
        {
          message: data.message,
          username: data.username,
          __createdtime__: data.__createdtime__,
        },
      ]);
    });

	// Remove event listener on component unmount
    return () => socket.off('receive_message');
  }, [socket]);

  // dd/mm/yyyy, hh:mm:ss
  function formatDateFromTimestamp(timestamp) {
    const date = new Date(timestamp);
    return date.toLocaleString();
  }

  return (
    <div className={styles.messagesColumn}>
      {messagesRecieved.map((msg, i) => (
        <div className={styles.message} key={i}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <span className={styles.msgMeta}>{msg.username}</span>
            <span className={styles.msgMeta}>
              {formatDateFromTimestamp(msg.__createdtime__)}
            </span>
          </div>
          <p className={styles.msgText}>{msg.message}</p>
          <br />
        </div>
      ))}
    </div>
  );
};

export default Messages;

Выше у нас есть хук useEffect, который запускается при получении события от сокета. Затем мы получаем данные сообщения, переданные в слушатель события receive_message. Оттуда устанавливаем состояние messagesReceived, которое представляет собой массив объектов сообщений — они содержат сообщение, имя пользователя отправителя и дату отправки сообщения.

Импортируем новый компонент messages на страницу Chat, а затем создадим маршрут для страницы Chat в App.js:

// client/src/pages/chat/index.js

import styles from './styles.module.css';
import MessagesReceived from './messages';

const Chat = ({ socket }) => {
  return (
    <div className={styles.chatContainer}>
      <div>
        <MessagesReceived socket={socket} />
      </div>
    </div>
  );
};

export default Chat;
// client/src/App.js

import './App.css';
import { useState } from 'react';
import Home from './pages/home';
import Chat from './pages/chat';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import io from 'socket.io-client';

const socket = io.connect('http://localhost:4000');

function App() {
  const [username, setUsername] = useState('');
  const [room, setRoom] = useState('');

  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route
            path='/'
            element={
              <Home
                username={username}
                setUsername={setUsername}
                room={room}
                setRoom={setRoom}
                socket={socket}
              />
            }
          />
          {/* Add this */}
          <Route
            path='/chat'
            element={<Chat username={username} room={room} socket={socket} />}
          />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

Давайте проверим. Зайдите на главную страницу и войдите в комнату:

Joining a room as Dan
Вступление в комнату в качестве Дэна

Мы должны попасть на страницу чата и получить приветственное сообщение от ChatBot:

Welcome message received from ChatBot
Приветственное сообщение от ChatBot

Теперь пользователи могут видеть полученные сообщения. Супер!

Далее: настройка базы данных для непрерывного сохранения сообщений.

Как создать схему и таблицу в HarperDB

Вернитесь на панель HarperDB и нажмите на "browse". Затем создайте новую схему под названием "realtime_chat_app". Схема — это просто группа таблиц.

В рамках этой схемы создайте таблицу под названием "messages" с хэш-атрибутом "id".

Creating our schema and table in HarperDB
Создание схемы и таблицы в HarperDB

Теперь у нас есть место для хранения сообщений, поэтому давайте создадим компонент SendMessage.

Как создать компонент Send Message (C)

Создайте файл src/pages/chat/send-message.js и добавьте в него следующий код:

// client/src/pages/chat/send-message.js

import styles from './styles.module.css';
import React, { useState } from 'react';

const SendMessage = ({ socket, username, room }) => {
  const [message, setMessage] = useState('');

  const sendMessage = () => {
    if (message !== '') {
      const __createdtime__ = Date.now();
      // Send message to server. We can't specify who we send the message to from the frontend. We can only send to server. Server can then send message to rest of users in room
      socket.emit('send_message', { username, room, message, __createdtime__ });
      setMessage('');
    }
  };

  return (
    <div className={styles.sendMessageContainer}>
      <input
        className={styles.messageInput}
        placeholder='Message...'
        onChange={(e) => setMessage(e.target.value)}
        value={message}
      />
      <button className='btn btn-primary' onClick={sendMessage}>
        Send Message
      </button>
    </div>
  );
};

export default SendMessage;

Выше, когда пользователь нажимает кнопку "Send Message", на сервер передается событие сокета send_message вместе с объектом сообщения. Мы обработаем это событие на сервере в ближайшее время.

Импортируйте SendMessage на страницу чата:

// src/pages/chat/index.js

import styles from './styles.module.css';
import MessagesReceived from './messages';
import SendMessage from './send-message';

const Chat = ({ username, room, socket }) => {
  return (
    <div className={styles.chatContainer}>
      <div>
        <MessagesReceived socket={socket} />
        <SendMessage socket={socket} username={username} room={room} />
      </div>
    </div>
  );
};

export default Chat;

Теперь страница чата выглядит так:

Chat page now has a message input where a message can be typed and sent
На странице чата теперь есть возможность ввода сообщения, которое можно набрать и отправить

Далее нужно настроить переменные окружения HarperDB, чтобы можно было начать взаимодействовать с базой данных.

Как настроить переменные окружения HarperDB

Для того чтобы можно было сохранять сообщения в HarperDB, вам понадобится URL-адрес экземпляра HarperDB и пароль API.

На панели управления HarperDB нажмите на свой экземпляр, затем перейдите в раздел "config". Вы найдете URL-адрес вашего экземпляра и заголовок API Auth — то есть ваш пароль "super_user", который позволит делать любые запросы к базе данных, которые будете видеть только вы.

HarperDB instance URL and API auth header
URL-адрес экземпляра HarperDB и заголовок API auth

Хранить эти переменные будем в файле .env. Внимание: не добавляйте файл .env на GitHub! Этот файл не должен быть виден публично. Переменные загружаются через сервер за кулисами.

Создайте следующие файлы и добавьте в них URL-адрес и пароль HarperDB:

// server/.env
HARPERDB_URL="<your url goes here>"
HARPERDB_PW="Basic <your password here>"

Мы также создадим файл .gitignore, чтобы предотвратить публикацию .env на GitHub, а также папку node_modules:

// server/.gitignore
.env
node_modules

Примечание: умение работать с Git и GitHub — это 100% обязательное условие для всех разработчиков. Ознакомьтесь с моей статьей о рабочих процессах Git, если вам нужно улучшить свои навыки работы с Git. А если вам постоянно приходится искать одни и те же команды Git, и вам нужен быстрый способ поиска, копирования/вставки команды — посмотрите мою популярную шпаргалку по командам Git в PDF и плакат со шпаргалкой по Git.

Наконец, давайте загрузим переменные окружения на сервер, добавив этот код в верхнюю часть главного файла сервера:

// server/index.js

require('dotenv').config();
console.log(process.env.HARPERDB_URL); // remove this after you've confirmed it working
const express = require('express');
// ...

Как разрешить пользователям отправлять друг другу сообщения с помощью Socket.io

На сервере мы будем слушать событие send_message, а затем отправим сообщение всем пользователям в комнате:

// server/index.js

const express = require('express');
// ...
const harperSaveMessage = require('./services/harper-save-message'); // Add this

// ...

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
    
  // ...

  // Add this
  socket.on('send_message', (data) => {
    const { message, username, room, __createdtime__ } = data;
    io.in(room).emit('receive_message', data); // Send to all users in room, including sender
    harperSaveMessage(message, username, room, __createdtime__) // Save message in db
      .then((response) => console.log(response))
      .catch((err) => console.log(err));
  });
});

server.listen(4000, () => 'Server is running on port 3000');

Теперь нужно создать функцию harperSaveMessage. Создайте новый файл по адресу server/services/harper-save-message.js и добавьте в него следующий код:

// server/services/harper-save-message.js

var axios = require('axios');

function harperSaveMessage(message, username, room) {
  const dbUrl = process.env.HARPERDB_URL;
  const dbPw = process.env.HARPERDB_PW;
  if (!dbUrl || !dbPw) return null;

  var data = JSON.stringify({
    operation: 'insert',
    schema: 'realtime_chat_app',
    table: 'messages',
    records: [
      {
        message,
        username,
        room,
      },
    ],
  });

  var config = {
    method: 'post',
    url: dbUrl,
    headers: {
      'Content-Type': 'application/json',
      Authorization: dbPw,
    },
    data: data,
  };

  return new Promise((resolve, reject) => {
    axios(config)
      .then(function (response) {
        resolve(JSON.stringify(response.data));
      })
      .catch(function (error) {
        reject(error);
      });
  });
}

module.exports = harperSaveMessage;

Сохранение данных может занять какое-то время, поэтому мы возвращаем обещание, которое будет выполнено, если данные сохранятся успешно, или отклонено, если нет.

Если вам интересно, откуда я взял приведенный выше код, HarperDB предоставляет раздел «примеры кода» на панели, который значительно облегчает жизнь:

HarperDB code examples
примеры кода HarperDB 

Время тестировать! Войдите в комнату в качестве пользователя и отправьте сообщение. Затем перейдите в HarperDB и нажмите на "browse", затем щелкните по таблице "messages". Вы должны увидеть свое сообщение в базе данных:

Our first messages in the database
Наши первые сообщения в базе данных

Круто. И что дальше? Было бы здорово, если бы последние 100 сообщений, отправленных в комнате, загружались, когда пользователь присоединяется к комнате.

Как получить сообщения из HarperDB

На сервере создадим функцию, которая будет получать последние 100 сообщений, отправленных в определенной комнате (обратите внимание, что HarperDB также позволяет использовать SQL-запросы):

// server/services/harper-get-messages.js

let axios = require('axios');

function harperGetMessages(room) {
  const dbUrl = process.env.HARPERDB_URL;
  const dbPw = process.env.HARPERDB_PW;
  if (!dbUrl || !dbPw) return null;

  let data = JSON.stringify({
    operation: 'sql',
    sql: `SELECT * FROM realtime_chat_app.messages WHERE room = '${room}' LIMIT 100`,
  });

  let config = {
    method: 'post',
    url: dbUrl,
    headers: {
      'Content-Type': 'application/json',
      Authorization: dbPw,
    },
    data: data,
  };

  return new Promise((resolve, reject) => {
    axios(config)
      .then(function (response) {
        resolve(JSON.stringify(response.data));
      })
      .catch(function (error) {
        reject(error);
      });
  });
}

module.exports = harperGetMessages;

Мы будем вызывать эту функцию каждый раз, когда пользователь присоединяется к комнате:

// server/index.js

// ...
const harperSaveMessage = require('./services/harper-save-message');
const harperGetMessages = require('./services/harper-get-messages'); // Add this

// ...

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Add a user to a room
  socket.on('join_room', (data) => {
      
    // ...

    // Add this
    // Get last 100 messages sent in the chat room
    harperGetMessages(room)
      .then((last100Messages) => {
        // console.log('latest messages', last100Messages);
        socket.emit('last_100_messages', last100Messages);
      })
      .catch((err) => console.log(err));
  });

 // ...

Выше, если сообщения успешно получены, мы выпускаем событие Socket.io под названием last_100_messages. Теперь мы будем слушать это событие на фронтенде.

Как отобразить последние 100 сообщений на клиенте

Ниже мы добавляем хук useEffect, который содержит слушатель события Socket.io для события last_100_messages. После этого сообщения сортируются в порядке дат, самые последние внизу, и состояние messagesReceived обновляется.

Когда состояние messagesReceived обновляется, запускается useEffect для прокрутки элемент messageColumnдо самого последнего сообщения. Это улучшает UX приложения.

// client/src/pages/chat/messages.js

import styles from './styles.module.css';
import { useState, useEffect, useRef } from 'react';

const Messages = ({ socket }) => {
  const [messagesRecieved, setMessagesReceived] = useState([]);

  const messagesColumnRef = useRef(null); // Add this

  // Runs whenever a socket event is recieved from the server
  useEffect(() => {
    socket.on('receive_message', (data) => {
      console.log(data);
      setMessagesReceived((state) => [
        ...state,
        {
          message: data.message,
          username: data.username,
          __createdtime__: data.__createdtime__,
        },
      ]);
    });

    // Remove event listener on component unmount
    return () => socket.off('receive_message');
  }, [socket]);

  // Add this
  useEffect(() => {
    // Last 100 messages sent in the chat room (fetched from the db in backend)
    socket.on('last_100_messages', (last100Messages) => {
      console.log('Last 100 messages:', JSON.parse(last100Messages));
      last100Messages = JSON.parse(last100Messages);
      // Sort these messages by __createdtime__
      last100Messages = sortMessagesByDate(last100Messages);
      setMessagesReceived((state) => [...last100Messages, ...state]);
    });

    return () => socket.off('last_100_messages');
  }, [socket]);

  // Add this
  // Scroll to the most recent message
  useEffect(() => {
    messagesColumnRef.current.scrollTop =
      messagesColumnRef.current.scrollHeight;
  }, [messagesRecieved]);

  // Add this
  function sortMessagesByDate(messages) {
    return messages.sort(
      (a, b) => parseInt(a.__createdtime__) - parseInt(b.__createdtime__)
    );
  }

  // dd/mm/yyyy, hh:mm:ss
  function formatDateFromTimestamp(timestamp) {
    const date = new Date(timestamp);
    return date.toLocaleString();
  }

  return (
    // Add ref to this div
    <div className={styles.messagesColumn} ref={messagesColumnRef}>
      {messagesRecieved.map((msg, i) => (
        <div className={styles.message} key={i}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <span className={styles.msgMeta}>{msg.username}</span>
            <span className={styles.msgMeta}>
              {formatDateFromTimestamp(msg.__createdtime__)}
            </span>
          </div>
          <p className={styles.msgText}>{msg.message}</p>
          <br />
        </div>
      ))}
    </div>
  );
};

export default Messages;

Как отобразить комнату и пользователей (A)

Мы создали компоненты B и C, поэтому давайте завершим работу созданием A.

The chat page split into three components
Страница чата состоит из трех компонентов

Когда пользователь присоединяется к комнате, мы генерируем на сервере событие chatroom_users, которое отправляет всех пользователей в комнате всем клиентам в этой комнате. Давайте прослушаем это событие в компоненте под названием RoomAndUsers.

Ниже также есть кнопка "Leave", при нажатии на которую на сервер отправляется событие leave_room. Затем она перенаправляет пользователя обратно на главную страницу.

// client/src/pages/chat/room-and-users.js

import styles from './styles.module.css';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

const RoomAndUsers = ({ socket, username, room }) => {
  const [roomUsers, setRoomUsers] = useState([]);

  const navigate = useNavigate();

  useEffect(() => {
    socket.on('chatroom_users', (data) => {
      console.log(data);
      setRoomUsers(data);
    });

    return () => socket.off('chatroom_users');
  }, [socket]);

  const leaveRoom = () => {
    const __createdtime__ = Date.now();
    socket.emit('leave_room', { username, room, __createdtime__ });
    // Redirect to home page
    navigate('/', { replace: true });
  };

  return (
    <div className={styles.roomAndUsersColumn}>
      <h2 className={styles.roomTitle}>{room}</h2>

      <div>
        {roomUsers.length > 0 && <h5 className={styles.usersTitle}>Users:</h5>}
        <ul className={styles.usersList}>
          {roomUsers.map((user) => (
            <li
              style={{
                fontWeight: `${user.username === username ? 'bold' : 'normal'}`,
              }}
              key={user.id}
            >
              {user.username}
            </li>
          ))}
        </ul>
      </div>

      <button className='btn btn-outline' onClick={leaveRoom}>
        Leave
      </button>
    </div>
  );
};

export default RoomAndUsers;

Давайте импортируем этот компонент на страницу чата:

// client/src/pages/chat/index.js

import styles from './styles.module.css';
import RoomAndUsersColumn from './room-and-users'; // Add this
import SendMessage from './send-message';
import MessagesReceived from './messages';

const Chat = ({ username, room, socket }) => {
  return (
    <div className={styles.chatContainer}>
      {/* Add this */}
      <RoomAndUsersColumn socket={socket} username={username} room={room} />

      <div>
        <MessagesReceived socket={socket} />
        <SendMessage socket={socket} username={username} room={room} />
      </div>
    </div>
  );
};

export default Chat;

Как удалить пользователя из комнаты Socket.io

Socket.io предоставляет метод leave(), который можно использовать для удаления пользователя из комнаты Socket.io. Мы также храним данные о пользователях в массиве в памяти сервера, поэтому удалим пользователя и из этого массива:

// server/index.js

const leaveRoom = require('./utils/leave-room'); // Add this

// ...

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
    
  // ...

  // Add this
  socket.on('leave_room', (data) => {
    const { username, room } = data;
    socket.leave(room);
    const __createdtime__ = Date.now();
    // Remove user from memory
    allUsers = leaveRoom(socket.id, allUsers);
    socket.to(room).emit('chatroom_users', allUsers);
    socket.to(room).emit('receive_message', {
      username: CHAT_BOT,
      message: `${username} has left the chat`,
      __createdtime__,
    });
    console.log(`${username} has left the chat`);
  });
});

server.listen(4000, () => 'Server is running on port 3000');

Теперь нужно создать функцию leaveRoom():

// server/utils/leave-room.js

function leaveRoom(userID, chatRoomUsers) {
  return chatRoomUsers.filter((user) => user.id != userID);
}

module.exports = leaveRoom;

Зачем помещать эту короткую функцию в отдельную папку utils, спросите вы? Потому что мы будем использовать ее позже, а повторяться мы не хотим (следуя принципу DRY).

Давайте проверим: откройте два окна рядом друг с другом и присоединитесь к чату в обоих:

Two windows chatting in realtime.
Два окна с чатом в реальном времени

Затем нажмите кнопку "Leave" в окне 2:

The user is removed from the chat when they click the Leave button
Пользователь удаляется из чата, когда нажимает кнопку "Покинуть".

Пользователь покидает чат, а остальным пользователям отправляется сообщение о его уходе. Отлично!

Как добавить слушателя события disconnect

Что делать, если пользователь почему-то потерял связь с сервером, например, у него пропал интернет? Socket.io предоставляет встроенный слушатель события отключения (disconnect event listener) для этого. Давайте добавим его на сервер, чтобы удалять пользователя из памяти при потери связи:

// server/index.js

// ...

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
    
  // ...
    
  // Add this
  socket.on('disconnect', () => {
    console.log('User disconnected from the chat');
    const user = allUsers.find((user) => user.id == socket.id);
    if (user?.username) {
      allUsers = leaveRoom(socket.id, allUsers);
      socket.to(chatRoom).emit('chatroom_users', allUsers);
      socket.to(chatRoom).emit('receive_message', {
        message: `${user.username} has disconnected from the chat.`,
      });
    }
  });
});

server.listen(4000, () => 'Server is running on port 3000');

Вуаля, вы только что создали полнофункциональное приложение для чата в реальном времени с фронтендом на React, серверной частью на Node/Express и базой данных HarperDB. Хорошая работа!

В следующий раз я планирую рассмотреть пользовательские функции HarperDB, которые помогают пользователям определять свои собственные конечные точки API в HarperDB. Это означает, что мы можем создать все наше приложение в одном месте! Пример того, как HarperDB сокращает стек, можно посмотреть в этой статье.

Небольшое задание на прощание

Если обновить страницу чата, имя пользователя и комната будут потеряны. Сможете ли вы предотвратить потерю этой информации? Подсказка: оказаться полезным может локальное хранилище. Предлагайте варианты решения в комментариях к статье.


Всех начинающих веб-разработчиков приглашаем на открытые занятия, на которых:

  • создадим html сайт за час в аналоге блокнота (6 декабря, -> регистрация)

  • доработаем его до полноценного чата, чтобы пользователи с разных компьютеров могли общаться друг с другом (21 декабря, -> регистрация).

Комментарии (0)