Доброго времени суток, друзья!

Хочу поделиться с вами опытом разработки простого чата на React с помощью библиотеки «Socket.IO».

Предполагается, что вы знакомы с названной библиотекой. Если не знакомы, то вот соответствующее руководство с примерами создания «тудушки» и чата на ванильном JavaScript.

Также предполагается, что вы хотя бы поверхностно знакомы с Node.js.

В данной статье я сосредоточусь на практической составляющей совместного использования Socket.IO, React и Node.js.

Наш чат будет иметь следующие основные возможности:

  • Выбор комнаты
  • Отправка сообщений
  • Удаление сообщений отправителем
  • Хранение сообщений в локальной базе данных в формате JSON
  • Хранение имени и идентификатора пользователя в локальном хранилище браузера (local storage)
  • Отображение количества активных пользователей
  • Отображение списка пользователей с онлайн-индикатором

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

Если вам это интересно, то прошу следовать за мной.

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

Песочница:


Структура проекта и зависимости


Приступаем к созданию проекта:

mkdir react-chat
cd react-chat

Создаем клиента с помощью Create React App:

yarn create react-app client
# или
npm init react-app client
# или
npx create-react-app client

В дальнейшем для установки зависимостей я буду использовать yarn: yarn add = npm i, yarn start = npm start, yarn dev = npm run dev.

Переходим в директорию «client» и устанавливаем дополнительные зависимости:

cd client
yarn add socket.io-client react-router-dom styled-components bootstrap react-bootstrap react-icons emoji-mart react-timeago


Раздел «dependencies» файла «package.json»:

{
  "bootstrap": "^4.6.0",
  "emoji-mart": "^3.0.0",
  "react": "^17.0.1",
  "react-bootstrap": "^1.5.0",
  "react-dom": "^17.0.1",
  "react-icons": "^4.2.0",
  "react-router-dom": "^5.2.0",
  "react-scripts": "4.0.1",
  "react-timeago": "^5.2.0",
  "socket.io-client": "^3.1.0",
  "styled-components": "^5.2.1"
}

Возвращаемся в корневую директорию (react-chat), создаем директорию «server», переходим в нее, инициализируем проект и устанавливаем зависимости:

cd ..
mkdir server
cd server
yarn init -yp
yarn add socket.io lowdb supervisor

  • socket.io — серверная часть Socket.IO
  • lowdb — локальная БД в формате JSON
  • supervisor — сервер для разработки (альтернатива nodemon, который работает некорректно с последней стабильной версией Node.js; это как-то связано с неправильным запуском/остановкой дочерних процессов)

Добавляем команду «start» для запуска производственного сервера и команду «dev» для запуска сервера для разработки. package.json:

{
  "name": "server",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "dependencies": {
    "lowdb": "^1.0.0",
    "socket.io": "^3.1.0",
    "supervisor": "^0.12.0"
  },
  "scripts": {
    "start": "node index.js",
    "dev": "supervisor index.js"
  }
}

Снова возвращаемся в корневую директорию (react-chat), инициализируем проект и устанавливаем зависимости:

  cd ..
  yarn init -yp
  yarn add nanoid concurrently

  • nanoid — генерация идентификаторов (будет использоваться как на клиенте, так и на сервере)
  • concurrently — одновременное выполнение двух и более команд

react-chat/package.json (обратите внимание, команды для npm выглядят иначе; смотрите документацию concurrently):

{
  "name": "react-chat",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "dependencies": {
    "concurrently": "^6.0.0",
    "nanoid": "^3.1.20"
  },
  "scripts": {
    "server": "yarn --cwd server dev",
    "client": "yarn --cwd client start",
    "start": "concurrently \"yarn server\" \"yarn client\""
  }
}

Отлично, с формированием основной структуры проекта и установкой необходимых зависимостей мы закончили. Приступаем к реализации сервера.

Реализация сервера


Структура директории «server»:

|--server
  |--db - пустая директория для БД
  |--handlers
    |--messageHandlers.js
    |--userHandlers.js
  |--index.js
  ...

В файле «index.js» мы делаем следующее:

  • Создаем HTTP-сервер
  • Подключаем к нему Socket.IO
  • Запускаем сервер на порте 5000
  • Регистрируем обработчики событий при подключении сокета

index.js:

// создаем HTTP-сервер
const server = require('http').createServer()
// подключаем к серверу Socket.IO
const io = require('socket.io')(server, {
  cors: {
    origin: '*'
  }
})

const log = console.log

// получаем обработчики событий
const registerMessageHandlers = require('./handlers/messageHandlers')
const registerUserHandlers = require('./handlers/userHandlers')

// данная функция выполняется при подключении каждого сокета (обычно, один клиент = один сокет)
const onConnection = (socket) => {
  // выводим сообщение о подключении пользователя
  log('User connected')

  // получаем название комнаты из строки запроса "рукопожатия"
  const { roomId } = socket.handshake.query
  // сохраняем название комнаты в соответствующем свойстве сокета
  socket.roomId = roomId

  // присоединяемся к комнате (входим в нее)
  socket.join(roomId)

  // регистрируем обработчики
  // обратите внимание на передаваемые аргументы
  registerMessageHandlers(io, socket)
  registerUserHandlers(io, socket)

  // обрабатываем отключение сокета-пользователя
  socket.on('disconnect', () => {
    // выводим сообщение
    log('User disconnected')
    // покидаем комнату
    socket.leave(roomId)
  })
}

// обрабатываем подключение
io.on('connection', onConnection)

// запускаем сервер
const PORT = process.env.PORT || 5000
server.listen(PORT, () => {
  console.log(`Server ready. Port: ${PORT}`)
})

В файле «handlers/messageHandlers.js» мы делаем следующее:

  • Настраиваем локальную БД в формате JSON с помощью lowdb
  • Записываем в БД начальные данные
  • Создаем функции для получения, добавления и удаления сообщений
  • Регистрируем обработку соответствующих событий:
    • message:get — получение сообщений
    • message:add — добавление сообщения
    • message:remove — удаление сообщения


Сообщения представляют собой объекты с такими свойствами:

  • messageId (string) — индентификатор сообщения
  • userId (string) — индентификатор пользователя
  • senderName (string) — имя отправителя
  • messageText (string) — текст сообщения
  • createdAt (date) — дата создания

handlers/messageHandlers.js:

const { nanoid } = require('nanoid')
// настраиваем БД
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
// БД хранится в директории "db" под названием "messages.json"
const adapter = new FileSync('db/messages.json')
const db = low(adapter)

// записываем в БД начальные данные
db.defaults({
  messages: [
    {
      messageId: '1',
      userId: '1',
      senderName: 'Bob',
      messageText: 'What are you doing here?',
      createdAt: '2021-01-14'
    },
    {
      messageId: '2',
      userId: '2',
      senderName: 'Alice',
      messageText: 'Go back to work!',
      createdAt: '2021-02-15'
    }
  ]
}).write()

module.exports = (io, socket) => {
  // обрабатываем запрос на получение сообщений
  const getMessages = () => {
    // получаем сообщения из БД
    const messages = db.get('messages').value()
    // передаем сообщения пользователям, находящимся в комнате
    // синонимы - распространение, вещание, публикация
    io.in(socket.roomId).emit('messages', messages)
  }

  // обрабатываем добавление сообщения
  // функция принимает объект сообщения
  const addMessage = (message) => {
    db.get('messages')
      .push({
        // генерируем идентификатор с помощью nanoid, 8 - длина id
        messageId: nanoid(8),
        createdAt: new Date(),
        ...message
      })
      .write()

    // выполняем запрос на получение сообщений
    getMessages()
  }

  // обрабатываем удаление сообщение
  // функция принимает id сообщения
  const removeMessage = (messageId) => {
    db.get('messages').remove({ messageId }).write()

    getMessages()
  }

  // регистрируем обработчики
  socket.on('message:get', getMessages)
  socket.on('message:add', addMessage)
  socket.on('message:remove', removeMessage)
}

В файле «handlers/userHandlers.js» мы делаем следующее:

  • Создаем нормализованную структуру с пользователями
  • Создаем функции для получения, добавления и удаления пользователей
  • Регистрируем обработку соответствующих событий:
    • user:get — получение пользователей
    • user:add — добавление пользователя
    • user:leave — удаление пользователя


Для работы со списком пользователей мы также могли бы использовать lowdb. Если хотите, можете это сделать. Я же, с вашего позволения, ограничусь объектом.

Нормализованная структура (объект) пользователей имеет следующий формат:

{
  id (string) - идентификатор: {
    username (string) - имя пользователя,
    online (boolean) - индикатор нахождения пользователя в сети
  }
}

На самом деле, мы не удаляем пользователей, а переводим их статус в офлайн (присваиваем свойству «online» значение «false»).

handlers/userHandlers.js:

// нормализованная структура
// имитация БД
const users = {
  1: { username: 'Alice', online: false },
  2: { username: 'Bob', online: false }
}

module.exports = (io, socket) => {
  // обрабатываем запрос на получение пользователей
  // свойство "roomId" является распределенным,
  // поскольку используется как для работы с пользователями,
  // так и для работы с сообщениями
  const getUsers = () => {
    io.in(socket.roomId).emit('users', users)
  }

  // обрабатываем добавление пользователя
  // функция принимает объект с именем пользователя и его id
  const addUser = ({ username, userId }) => {
    // проверяем, имеется ли пользователь в БД
    if (!users[userId]) {
      // если не имеется, добавляем его в БД
      users[userId] = { username, online: true }
    } else {
      // если имеется, меняем его статус на онлайн
      users[userId].online = true
    }
    // выполняем запрос на получение пользователей
    getUsers()
  }

  // обрабатываем удаление пользователя
  const removeUser = (userId) => {
    // одно из преимуществ нормализованных структур состоит в том,
    // что мы может моментально (O(1)) получать данные по ключу
    // это актуально только для изменяемых (мутабельных) данных
    // в redux, например, без immer, нормализованные структуры привносят дополнительную сложность
    users[userId].online = false
    getUsers()
  }

  // регистрируем обработчики
  socket.on('user:get', getUsers)
  socket.on('user:add', addUser)
  socket.on('user:leave', removeUser)
}

Запускаем сервер для проверки его работоспособности:

yarn dev

Если видим в консоли сообщение «Server ready. Port: 5000», а в директории «db» появился файл «messages.json» с начальными данными, значит, сервер работает, как ожидается, и можно переходить к реализации клиентской части.

Реализация клиента


С клиентом все несколько сложнее. Структура директории «client»:

|--client
  |--public
    |--index.html
  |--src
    |--components
      |--ChatRoom
        |--MessageForm
          |--MessageForm.js
          |--package.json
        |--MessageList
          |--MessageList.js
          |--MessageListItem.js
          |--package.json
        |--UserList
          |--UserList.js
          |--package.json
        |--ChatRoom.js
        |--package.json
      |--Home
        |--Home.js
        |--package.json
      |--index.js
    |--hooks
      |--useBeforeUnload.js
      |--useChat.js
      |--useLocalStorage.js
    App.js
    index.js
  |--jsconfig.json (на уровне src)
  ...

Как следует из названий, в директории «components» находятся компоненты приложения (части пользовательского интерфейса, модули), а в директории «hooks» — пользовательские («кастомные») хуки, основным из которых является useChat().

Файлы «package.json» в директориях компонентов имеют единственное поле «main» со значением пути к JS-файлу, например:

{
  "main": "./Home"
}

Это позволяет импортировать компонент из директории без указания названия файла, например:

import { Home } from './Home'
// вместо
import { Home } from './Home/Home'

Файлы «components/index.js» и «hooks/index.js» используются для агрегации и повторного экспорта компонентов и хуков, соответственно.

components/index.js:

export { Home } from './Home'
export { ChatRoom } from './ChatRoom'

hooks/index.js:

export { useChat } from './useChat'
export { useLocalStorage } from './useLocalStorage'
export { useBeforeUnload } from './useBeforeUnload'

Это опять же позволяет импортировать компоненты и хуки по директории и одновременно. Агрегация и повторный экспорт обуславливают иcпользование именованного экспорта компонентов (документация React рекомендует использовать экспорт по умолчанию).

Файл «jsconfig.json» выглядит следующим образом:

{
  "compilerOptions": {
    "baseUrl": "src"
  }
}

Это «говорит» компилятору, что импорт модулей начинается с директории «src», поэтому компоненты, например, можно импортировать так:

// совместный результат агрегации и настроек компилятора
import { Home, ChatRoom } from 'components'
// вместо
import { Home, ChatRoom } from './components'

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

Вы можете использовать готовые решения. Например, вот хуки, предлагаемые библиотекой «react-use»:

# установка
yarn add react-use
# импорт
import { useLocalStorage } from 'react-use'
import { useBeforeUnload } from 'react-use'

Хук «useLocalStorage()» позволяет хранить (записывать и извлекать) значения в локальном хранилище браузера (local storage). Мы будем использовать его для сохранения имени и идентификатора пользователя между сессиями браузера. Мы не хотим заставлять пользователя каждый раз вводить свое имя, а идентификатор нужен для определения сообщений, принадлежащих данному пользователю. Хук принимает название ключа и, опционально, начальное значение.

hooks/useLocalstorage.js:

import { useState, useEffect } from 'react'

export const useLocalStorage = (key, initialValue) => {
  const [value, setValue] = useState(() => {
    const item = window.localStorage.getItem(key)
    return item ? JSON.parse(item) : initialValue
  })

  useEffect(() => {
    const item = JSON.stringify(value)
    window.localStorage.setItem(key, item)
    // отключаем линтер, чтобы не получать предупреждений об отсутствии зависимости key, от которой useEffect, на самом деле, не зависит
    // здесь мы немного обманываем useEffect
    // eslint-disable-next-line
  }, [value])

  return [value, setValue]
}

Хук «useBeforeUnload()» используется для вывода сообщения или выполнения функции в момент перезагрузки или закрытия страницы (вкладки браузера). Мы будем использовать его для отправки на сервер события «user:leave» для переключения статуса пользователя. Попытка реализовать отправку указанного события с помощью колбека, возвращаемого хуком «useEffect()», не увенчалась успехом. Хук принимает один параметр — примитив или функцию.

hooks/useBeforeUnload.js:

import { useEffect } from 'react'

export const useBeforeUnload = (value) => {
  const handleBeforeunload = (e) => {
    let returnValue
    if (typeof value === 'function') {
      returnValue = value(e)
    } else {
      returnValue = value
    }
    if (returnValue) {
      e.preventDefault()
      e.returnValue = returnValue
    }
    return returnValue
  }

  useEffect(() => {
    window.addEventListener('beforeunload', handleBeforeunload)
    return () => window.removeEventListener('beforeunload', handleBeforeunload)
    // eslint-disable-next-line
  }, [])
}

Хук «useChat()» — это главный хук нашего приложения. Будет проще, если я прокомментирую его построчно.

hooks/useChat.js:

import { useEffect, useRef, useState } from 'react'
// получаем класс IO
import io from 'socket.io-client'
import { nanoid } from 'nanoid'
// наши хуки
import { useLocalStorage, useBeforeUnload } from 'hooks'

// адрес сервера
// требуется перенаправление запросов - смотрите ниже
const SERVER_URL = 'http://localhost:5000'

// хук принимает название комнаты
export const useChat = (roomId) => {
  // локальное состояние для пользователей
  const [users, setUsers] = useState([])
  // локальное состояние для сообщений
  const [messages, setMessages] = useState([])

  // создаем и записываем в локальное хранинище идентификатор пользователя
  const [userId] = useLocalStorage('userId', nanoid(8))
  // получаем из локального хранилища имя пользователя
  const [username] = useLocalStorage('username')

  // useRef() используется не только для получения доступа к DOM-элементам,
  // но и для хранения любых мутирующих значений в течение всего жизненного цикла компонента
  const socketRef = useRef(null)

  useEffect(() => {
    // создаем экземпляр сокета, передаем ему адрес сервера
    // и записываем объект с названием комнаты в строку запроса "рукопожатия"
    // socket.handshake.query.roomId
    socketRef.current = io(SERVER_URL, {
      query: { roomId }
    })

    // отправляем событие добавления пользователя,
    // в качестве данных передаем объект с именем и id пользователя
    socketRef.current.emit('user:add', { username, userId })

    // обрабатываем получение списка пользователей
    socketRef.current.on('users', (users) => {
      // обновляем массив пользователей
      setUsers(users)
    })

    // отправляем запрос на получение сообщений
    socketRef.current.emit('message:get')

    // обрабатываем получение сообщений
    socketRef.current.on('messages', (messages) => {
      // определяем, какие сообщения были отправлены данным пользователем,
      // если значение свойства "userId" объекта сообщения совпадает с id пользователя,
      // то добавляем в объект сообщения свойство "currentUser" со значением "true",
      // иначе, просто возвращаем объект сообщения
      const newMessages = messages.map((msg) =>
        msg.userId === userId ? { ...msg, currentUser: true } : msg
      )
      // обновляем массив сообщений
      setMessages(newMessages)
    })

    return () => {
      // при размонтировании компонента выполняем отключение сокета
      socketRef.current.disconnect()
    }
  }, [roomId, userId, username])

  // функция отправки сообщения
  // принимает объект с текстом сообщения и именем отправителя
  const sendMessage = ({ messageText, senderName }) => {
    // добавляем в объект id пользователя при отправке на сервер
    socketRef.current.emit('message:add', {
      userId,
      messageText,
      senderName
    })
  }

  // функция удаления сообщения по id
  const removeMessage = (id) => {
    socketRef.current.emit('message:remove', id)
  }

  // отправляем на сервер событие "user:leave" перед перезагрузкой страницы
  useBeforeUnload(() => {
    socketRef.current.emit('user:leave', userId)
  })

  // хук возвращает пользователей, сообщения и функции для отправки удаления сообщений
  return { users, messages, sendMessage, removeMessage }
}

По умолчанию все запросы клиента отправляются к localhost:3000 (порт, на котором запущен сервер для разработки). Для перенаправления запросов к порту, на котором работает «серверный» сервер, необходимо выполнить проксирование. Для этого добавляем в файл «src/package.json» следующую строку:

"proxy": "http://localhost:5000"

Осталось реализовать компоненты приложения.

Компонент «Home» — это первое, что видит пользователь, когда запускает приложение. В нем имеется форма, в которой пользователю предлагается ввести свое имя и выбрать комнату. В действительности, в случае с комнатой, у пользователя нет выбора, доступен лишь один вариант (free). Второй (отключенный) вариант (job) — это возможность для масштабирования приложения. Отображение кнопки для начала чата зависит от поля с именем пользователя (когда данное поле является пустым, кнопка не отображается). Кнопка — это, на самом деле, ссылка на страницу с чатом.

components/Home.js:

import { useState, useRef } from 'react'
// для маршрутизации используется react-router-dom
import { Link } from 'react-router-dom'
// наш хук
import { useLocalStorage } from 'hooks'
// для стилизации используется react-bootstrap
import { Form, Button } from 'react-bootstrap'

export function Home() {
  // создаем и записываем в локальное хранилище имя пользователя
  // или извлекаем его из хранилища
  const [username, setUsername] = useLocalStorage('username', 'John')
  // локальное состояние для комнаты
  const [roomId, setRoomId] = useState('free')
  const linkRef = useRef(null)

  // обрабатываем изменение имени пользователя
  const handleChangeName = (e) => {
    setUsername(e.target.value)
  }

  // обрабатываем изменение комнаты
  const handleChangeRoom = (e) => {
    setRoomId(e.target.value)
  }

  // имитируем отправку формы
  const handleSubmit = (e) => {
    e.preventDefault()
    // выполняем нажатие кнопки
    linkRef.current.click()
  }

  const trimmed = username.trim()

  return (
    <Form
      className='mt-5'
      style={{ maxWidth: '320px', margin: '0 auto' }}
      onSubmit={handleSubmit}
    >
      <Form.Group>
        <Form.Label>Name:</Form.Label>
        <Form.Control value={username} onChange={handleChangeName} />
      </Form.Group>
      <Form.Group>
        <Form.Label>Room:</Form.Label>
        <Form.Control as='select' value={roomId} onChange={handleChangeRoom}>
          <option value='free'>Free</option>
          <option value='job' disabled>
            Job
          </option>
        </Form.Control>
      </Form.Group>
      {trimmed && (
        <Button variant='success' as={Link} to={`/${roomId}`} ref={linkRef}>
          Chat
        </Button>
      )}
    </Form>
  )
}

Компонент «UserList», как следует из названия, представляет собой список пользователей. В нем имеется аккордеон, сам список и индикаторы нахождения пользователей в сети.

components/UserList.js:

// стили
import { Accordion, Card, Button, Badge } from 'react-bootstrap'
// иконка - индикатор статуса пользователя
import { RiRadioButtonLine } from 'react-icons/ri'

// компонент принимает объект с пользователями - нормализованную структуру
export const UserList = ({ users }) => {
  // преобразуем структуру в массив
  const usersArr = Object.entries(users)
  // получаем массив вида (массив подмассивов)
  // [ ['1', { username: 'Alice', online: false }], ['2', {username: 'Bob', online: false}] ]

  // количество активных пользователей
  const activeUsers = Object.values(users)
    // получаем массив вида
    // [ {username: 'Alice', online: false}, {username: 'Bob', online: false} ]
    .filter((u) => u.online).length

  return (
    <Accordion className='mt-4'>
      <Card>
        <Card.Header bg='none'>
          <Accordion.Toggle
            as={Button}
            variant='info'
            eventKey='0'
            style={{ textDecoration: 'none' }}
          >
            Active users{' '}
            <Badge variant='light' className='ml-1'>
              {activeUsers}
            </Badge>
          </Accordion.Toggle>
        </Card.Header>
        {usersArr.map(([userId, obj]) => (
          <Accordion.Collapse eventKey='0' key={userId}>
            <Card.Body>
              <RiRadioButtonLine
                className={`mb-1 ${
                  obj.online ? 'text-success' : 'text-secondary'
                }`}
                size='0.8em'
              />{' '}
              {obj.username}
            </Card.Body>
          </Accordion.Collapse>
        ))}
      </Card>
    </Accordion>
  )
}

Компонент «MessageForm» — это стандартная форма для отправки сообщений. «Picker» — компонент для работы с эмодзи, предоставляемый библиотекой «emoji-mart». Данный компонент отображается/скрывается по нажатию кнопки.

components/MessageForm.js:

import { useState } from 'react'
// стили
import { Form, Button } from 'react-bootstrap'
// эмодзи
import { Picker } from 'emoji-mart'
// иконки
import { FiSend } from 'react-icons/fi'
import { GrEmoji } from 'react-icons/gr'

// функция принимает имя пользователя и функция отправки сообщений
export const MessageForm = ({ username, sendMessage }) => {
  // локальное состояние для текста сообщения
  const [text, setText] = useState('')
  // индикатор отображения эмодзи
  const [showEmoji, setShowEmoji] = useState(false)

  // обрабатываем изменение текста
  const handleChangeText = (e) => {
    setText(e.target.value)
  }

  // обрабатываем показ/скрытие эмодзи
  const handleEmojiShow = () => {
    setShowEmoji((v) => !v)
  }

  // обрабатываем выбор эмодзи
  // добавляем его к тексту, используя предыдущее значение состояния текста
  const handleEmojiSelect = (e) => {
    setText((text) => (text += e.native))
  }

  // обрабатываем отправку сообщения
  const handleSendMessage = (e) => {
    e.preventDefault()
    const trimmed = text.trim()
    if (trimmed) {
      sendMessage({ messageText: text, senderName: username })
      setText('')
    }
  }

  return (
    <>
      <Form onSubmit={handleSendMessage}>
        <Form.Group className='d-flex'>
          <Button variant='primary' type='button' onClick={handleEmojiShow}>
            <GrEmoji />
          </Button>
          <Form.Control
            value={text}
            onChange={handleChangeText}
            type='text'
            placeholder='Message...'
          />
          <Button variant='success' type='submit'>
            <FiSend />
          </Button>
        </Form.Group>
      </Form>
      {/* эмодзи */}
      {showEmoji && <Picker onSelect={handleEmojiSelect} emojiSize={20} />}
    </>
  )
}

Компонент «MessageListItem» — это элемент списка сообщений. «TimeAgo» — компонент для форматирования даты и времени. Он принимает дату и возвращает строку вида «1 month ago» (1 месяц назад). Эта строка обновляется в режиме реального времени. Удалять сообщения может только отправивший их пользователь.

components/MessageListItem.js:

// форматирование даты и времени
import TimeAgo from 'react-timeago'
// стили
import { ListGroup, Card, Button } from 'react-bootstrap'
// иконки
import { AiOutlineDelete } from 'react-icons/ai'

// функция принимает объект сообщения и функцию для удаления сообщений
export const MessageListItem = ({ msg, removeMessage }) => {
  // обрабатываем удаление сообщений
  const handleRemoveMessage = (id) => {
    removeMessage(id)
  }

  const { messageId, messageText, senderName, createdAt, currentUser } = msg
  return (
    <ListGroup.Item
      className={`d-flex ${currentUser ? 'justify-content-end' : ''}`}
    >
      <Card
        bg={`${currentUser ? 'primary' : 'secondary'}`}
        text='light'
        style={{ width: '55%' }}
      >
        <Card.Header className='d-flex justify-content-between align-items-center'>
          {/* передаем TimeAgo дату создания сообщения */}
          <Card.Text as={TimeAgo} date={createdAt} className='small' />
          <Card.Text>{senderName}</Card.Text>
        </Card.Header>
        <Card.Body className='d-flex justify-content-between align-items-center'>
          <Card.Text>{messageText}</Card.Text>
          {/* удалять сообщения может только отправивший их пользователь */}
          {currentUser && (
            <Button
              variant='none'
              className='text-warning'
              onClick={() => handleRemoveMessage(messageId)}
            >
              <AiOutlineDelete />
            </Button>
          )}
        </Card.Body>
      </Card>
    </ListGroup.Item>
  )
}

Компонент «MessageList» — это список сообщений. В нем используется компонент «MessageListItem».

components/MessageList.js:

import { useRef, useEffect } from 'react'
// стили
import { ListGroup } from 'react-bootstrap'
// компонент
import { MessageListItem } from './MessageListItem'

// пример встроенных стилей (inline styles)
const listStyles = {
  height: '80vh',
  border: '1px solid rgba(0,0,0,.4)',
  borderRadius: '4px',
  overflow: 'auto'
}

// функция принимает массив сообщений и функцию для удаления сообщений
// функция для удаления сообщений в виде пропа передается компоненту "MessageListItem"
export const MessageList = ({ messages, removeMessage }) => {
  // данный "якорь" нужен для выполнения прокрутки при добавлении в список нового сообщения
  const messagesEndRef = useRef(null)

  // плавная прокрутка, выполняемая при изменении массива сообщений
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({
      behavior: 'smooth'
    })
  }, [messages])

  return (
    <>
      <ListGroup variant='flush' style={listStyles}>
        {messages.map((msg) => (
          <MessageListItem
            key={msg.messageId}
            msg={msg}
            removeMessage={removeMessage}
          />
        ))}
        <span ref={messagesEndRef}></span>
      </ListGroup>
    </>
  )
}

Компонент «App» — главный компонент приложения. В нем определяются маршруты и производится сборка интерфейса.

src/App.js:

// средства маршрутизации
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'
// стили
import { Container } from 'react-bootstrap'
// компоненты
import { Home, ChatRoom } from 'components'

// маршруты
const routes = [
  { path: '/', name: 'Home', Component: Home },
  { path: '/:roomId', name: 'ChatRoom', Component: ChatRoom }
]

export const App = () => (
  <Router>
    <Container style={{ maxWidth: '512px' }}>
      <h1 className='mt-2 text-center'>React Chat App</h1>
      <Switch>
        {routes.map(({ path, Component }) => (
          <Route key={path} path={path} exact>
            <Component />
          </Route>
        ))}
      </Switch>
    </Container>
  </Router>
)

Наконец, файл «src/index.js» — это входная точка JavaScript для Webpack. В нем выполняется глобальная стилизация и рендеринг компонента «App».

src/index.js:

import React from 'react'
import { render } from 'react-dom'
import { createGlobalStyle } from 'styled-components'
// стили
import 'bootstrap/dist/css/bootstrap.min.css'
import 'emoji-mart/css/emoji-mart.css'
// компонент
import { App } from './App'
// небольшая корректировка "бутстраповских" стилей
const GlobalStyles = createGlobalStyle`
.card-header {
  padding: 0.25em 0.5em;
}
.card-body {
  padding: 0.25em 0.5em;
}
.card-text {
  margin: 0;
}
`

const root = document.getElementById('root')
render(
  <>
    <GlobalStyles />
    <App />
  </>,
  root
)

Что ж, мы закончили разработку нашего небольшого приложения.

Пришло время убедиться в его работоспособности. Для этого в корневой директории проекта (react-chat) выполняем команду «yarn start». После этого, в открывшейся вкладке браузера вы должны увидеть что-то вроде этого:







Вместо заключения


Если у вас возникнет желание доработать приложение, то вот вам парочка идей:

  • Добавить БД для пользователей (с помощью той же lowdb)
  • Добавить вторую комнату — для этого достаточно реализовать раздельную обработку списков сообщений на сервере
  • Добавить возможность переписки с конкретным пользователем (приватный месседжинг) — идентификатор сокета или пользователя может использоваться в качестве названия комнаты
  • Можно попробовать использовать настоящую БД — рекомендую взглянуть на MongoDB Cloud и Mongoose; сервер придется переписать на Express
  • Уровень эксперта: добавить возможность отправки файлов (изображений, аудио, видео и т.д.) — для отправки файлов можно использовать react-filepond, для их обработки на сервере — multer; обмен файлами и потоковую передачу аудио и видео данных можно реализовать с помощью WebRTC
  • Из более экзотического: добавить озвучивание текста и перевод голосовых сообщений в текст — для этого можно использовать react-speech-kit

Часть из названных идей входит в мои планы по улучшению чата.

Благодарю за внимание и хорошего дня.