Разрабатываем чат на React с использованием Socket.IO



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

    Хочу поделиться с вами опытом разработки простого чата на 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

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

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

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

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое