Привет, друзья!


В данной статье я хочу показать вам, как разработать простое приложение для обмена сообщениями в режиме реального времени с использованием Socket.io, Express и React с акцентом на работе с медиа.


Функционал нашего приложения будет следующим:


  • при первом запуске приложение предлагает пользователю ввести свое имя;
  • имя пользователя и его идентификатор записываются в локальное хранилище;
  • при повторном запуске приложения имя и идентификатор пользователя извлекаются из локального хранилища (имитация системы аутентификации/авторизации);
  • выполняется подключение к серверу через веб-сокеты и вход в комнату main_room (при желании можно легко реализовать возможность выбора или создания других комнат);
  • пользователи обмениваются сообщениями в реальном времени;
  • типом сообщения может быть текст, аудио, видео или изображение;
  • передаваемые файлы сохраняются на сервере;
  • путь к сохраненному на сервере файлу добавляется в сообщение;
  • сообщение записывается в базу данных;
  • пользователи могут записывать аудио и видеосообщения;
  • после прикрепления файла и записи аудио или видео сообщения, отображается превью созданного контента;
  • пользователи могут добавлять в текст сообщения эмодзи;
  • текстовые сообщения могут озвучиваться;
  • и т.д.

Репозиторий с исходным кодом проекта.


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


Справедливости ради следует отметить, что я уже писал о разработке чата на Хабре. Будем считать, что это новая (продвинутая) версия.


Подготовка и настройка проекта


Создаем директорию, переходим в нее и инициализируем Node.js-проект:


mkdir chat-app
cd chat-app

yarn init -yp
# or
npm init -y

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


mkdir server

yarn create react-app client
# or
npx create-react-app client

Нам потребуется одновременно запускать два сервера (для клиента и самого сервера), поэтому установим concurrently — утилиту для одновременного выполнения нескольких команд, определенных в файле package.json:


yarn add concurrently
# or
npm i concurrently

Определяем команды в package.json:


"scripts": {
  "dev:client": "yarn --cwd client start",
  "dev:server": "yarn --cwd server dev",
  "dev": "concurrently \"yarn dev:client\" \"yarn dev:server\""
}

Или, если вы используете npm:


"scripts": {
  "dev:client": "npm run start --prefix client",
  "dev:server": "npm run dev --prefix server",
  "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\""
}

В качестве БД мы будем использовать MongoDb Atlas Database.


Переходим по ссылке, создаем аккаунт, создаем проект и кластер и получаем строку для подключения вида mongodb+srv://<user>:<password>@cluster0.f7292.mongodb.net/<database>?retryWrites=true&w=majority*, где <user>, <password> и <database> — данные, которые вы указали при создании проекта и кластера.


  • Для получения адреса БД необходимо нажать Connect рядом с названием кластера (Cluster0) и затем Connect your application.
  • Если у вас, как и у меня, динамический IP, во вкладке Network Access раздела Security надо прописать 0.0.0.0/0







Можно приступать к разработке сервера.


Сервер


Переходим в директорию server и устанавливаем зависимости:


cd server

# производственные зависимости
yarn add express socket.io mongoose cors multer
# or
npm i ...

# зависимость для разработки
yarn add -D nodemon
# or
npm i -D nodemon

  • expressNode.js-фреймворк для разработки веб-серверов;
  • socket.io — библиотека, облегчающая работу с веб-сокетами;
  • mongooseORM для работы с MongoDB;
  • cors — утилита для работы с CORS;
  • multer — утилита для разбора (парсинга) данных в формате multipart/form-data (для сохранения файлов на сервере);
  • nodemon — утилита для запуска сервера для разработки.

Определяем тип кода сервера (модуль) и команду для запуска сервера для разработки в файле package.json:


"type": "module",
"scripts": {
  "dev": "nodemon"
}

Структура директории server будет следующей:


- files - директория для хранения файлов
- models
  - message.model.js - модель сообщения для `Mongoose`
- socket_io
  - handlers
    - message.handlers.js - обработчики для сообщений
    - user.handler.js - обработчики для пользователей
  - onConnection.js - обработка подключения
- utils
  - file.js - утилиты для работы с файлами
  - onError.js - обработчик ошибок
  - upload.js - утилита для сохранения файлов
- config.js - настройки (в репозитории имеется файл `config.example.js` с примером настроек)
- index.js - основной файл сервера

Определяем настройки в файле config.js (не забудьте добавить его в .gitignore):


// разрешенный источник
export const ALLOWED_ORIGIN = 'http://localhost:3000'
// адрес БД
export const MONGODB_URI =
  'mongodb+srv://<user>:<password>@cluster0.f7292.mongodb.net/<database>?retryWrites=true&w=majority'

Определяем модель в файле models/message.model.js:


import mongoose from 'mongoose'

const { Schema, model } = mongoose

const messageSchema = new Schema(
  {
    messageId: {
      type: String,
      required: true,
      unique: true
    },
    messageType: {
      type: String,
      required: true
    },
    textOrPathToFile: {
      type: String,
      required: true
    },
    roomId: {
      type: String,
      required: true
    },
    userId: {
      type: String,
      required: true
    },
    userName: {
      type: String,
      required: true
    }
  },
  {
    timestamps: true
  }
)

export default model('Message', messageSchema)

Каждое наше сообщение будет включать следующую информацию:


  • messageId — идентификатор сообщения;
  • messageType — тип сообщения;
  • textOrPathToFile — текст сообщения или путь к файлу;
  • roomId — идентификатор комнаты;
  • userId — идентификатор пользователя;
  • userName — имя пользователя;
  • createdAt, updatedAt — дата и время создания и обновления сообщения, соответственно (timestamps: true).

Кратко рассмотрим утилиты (директория utils).


Обработчик ошибок (onError.js):


export default function onError(err, req, res, next) {
  console.log(err)

  // если имеется объект ответа
  if (res) {
    // статус ошибки
    const status = err.status || err.statusCode || 500
    // сообщение об ошибке
    const message = err.message || 'Something went wrong. Try again later'
    res.status(status).json({ message })
  }
}

Утилита для работы с файлами (file.js):


import { unlink } from 'fs/promises'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import onError from './onError.js'

// путь к текущей директории
const _dirname = dirname(fileURLToPath(import.meta.url))

// путь к директории с файлами
const fileDir = join(_dirname, '../files')

// утилита для получения пути к файлу
export const getFilePath = (filePath) => join(fileDir, filePath)

// утилита для удаления файла
export const removeFile = async (filePath) => {
  try {
    await unlink(join(fileDir, filePath))
  } catch (e) {
    onError(e)
  }
}

Утилита для сохранения файлов (upload.js):


import { existsSync, mkdirSync } from 'fs'
import multer from 'multer'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'

// путь к текущей директории
const _dirname = dirname(fileURLToPath(import.meta.url))

const upload = multer({
  storage: multer.diskStorage({
    // директория для записи файлов
    destination: async (req, _, cb) => {
      // извлекаем идентификатор комнаты из HTTP-заголовка `X-Room-Id`
      const roomId = req.headers['x-room-id']
      // файлы хранятся по комнатам
      // название директории - идентификатор комнаты
      const dirPath = join(_dirname, '../files', roomId)

      // создаем директорию при отсутствии
      if (!existsSync(dirPath)) {
        mkdirSync(dirPath, { recursive: true })
      }

      cb(null, dirPath)
    },
    filename: (_, file, cb) => {
      // названия файлов могут быть одинаковыми
      // добавляем к названию время с начала эпохи и дефис
      const fileName = `${Date.now()}-${file.originalname}`

      cb(null, fileName)
    }
  })
})

export default upload

Рассмотрим основной файл сервера (index.js).


Импортируем все и вся:


import cors from 'cors'
import express from 'express'
import { createServer } from 'http'
import mongoose from 'mongoose'
import { Server } from 'socket.io'
import { ALLOWED_ORIGIN, MONGODB_URI } from './config.js'
import onConnection from './socket_io/onConnection.js'
import { getFilePath } from './utils/file.js'
import onError from './utils/onError.js'
import upload from './utils/upload.js'

Создаем экземпляр Express-приложения и подключаем посредников для работы с CORS и парсинга JSON:


const app = express()

app.use(
  cors({
    origin: ALLOWED_ORIGIN
  })
)
app.use(express.json())

Обрабатываем загрузку файлов:


app.use('/upload', upload.single('file'), (req, res) => {
  if (!req.file) return res.sendStatus(400)

  // формируем относительный путь к файлу
  const relativeFilePath = req.file.path
    .replace(/\\/g, '/')
    .split('server/files')[1]

  // и возвращаем его
  res.status(201).json(relativeFilePath)
})

Обрабатываем получение файлов:


app.use('/files', (req, res) => {
  // формируем абсолютный путь к файлу
  const filePath = getFilePath(req.url)

  // и возвращаем файл по этому пути
  res.status(200).sendFile(filePath)
})

Добавляем обработчик ошибок и подключаемся к БД:


app.use(onError)

try {
  await mongoose.connect(MONGODB_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true
  })
  console.log('???? Connected')
} catch (e) {
  onError(e)
}

Создаем экземпляры сервера и Socket.io и обрабатываем подключение:


const server = createServer(app)

const io = new Server(server, {
  cors: ALLOWED_ORIGIN,
  serveClient: false
})

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

Наконец, определяем порт и запускаем сервер:


const PORT = process.env.PORT || 4000
server.listen(PORT, () => {
  console.log(`???? Server started on port ${PORT}`)
})

Полный код сервера:
import cors from 'cors'
import express from 'express'
import { createServer } from 'http'
import mongoose from 'mongoose'
import { Server } from 'socket.io'
import { ALLOWED_ORIGIN, MONGODB_URI } from './config.js'
import onConnection from './socket_io/onConnection.js'
import { getFilePath } from './utils/file.js'
import onError from './utils/onError.js'
import upload from './utils/upload.js'

const app = express()

app.use(
  cors({
    origin: ALLOWED_ORIGIN
  })
)
app.use(express.json())

app.use('/upload', upload.single('file'), (req, res) => {
  if (!req.file) return res.sendStatus(400)

  const relativeFilePath = req.file.path
    .replace(/\\/g, '/')
    .split('server/files')[1]

  res.status(201).json(relativeFilePath)
})

app.use('/files', (req, res) => {
  const filePath = getFilePath(req.url)

  res.status(200).sendFile(filePath)
})

app.use(onError)

try {
  await mongoose.connect(MONGODB_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true
  })
  console.log('???? Connected')
} catch (e) {
  onError(e)
}

const server = createServer(app)

const io = new Server(server, {
  cors: ALLOWED_ORIGIN,
  serveClient: false
})

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

const PORT = process.env.PORT || 4000
server.listen(PORT, () => {
  console.log(`???? Server started on port ${PORT}`)
})

Рассмотрим работу с сокетами (директория socket_io).


Обработка подключения (onConnection.js):


import userHandlers from './handlers/user.handlers.js'
import messageHandlers from './handlers/message.handlers.js'

export default function onConnection(io, socket) {
  // извлекаем идентификатор комнаты и имя пользователя
  const { roomId, userName } = socket.handshake.query

  // записываем их в объект сокета
  socket.roomId = roomId
  socket.userName = userName

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

  // регистрируем обработчики для пользователей
  userHandlers(io, socket)

  // регистрируем обработчики для сообщений
  messageHandlers(io, socket)
}

Обработчики для пользователей (handlers/user.handlers.js):


// "хранилище" пользователей
const users = {}

export default function userHandlers(io, socket) {
  // извлекаем идентификатор комнаты и имя пользователя из объекта сокета
  const { roomId, userName } = socket

  // инициализируем хранилище пользователей
  if (!users[roomId]) {
    users[roomId] = []
  }

  // утилита для обновления списка пользователей
  const updateUserList = () => {
    // сообщение получают только пользователи, находящиеся в комнате
    io.to(roomId).emit('user_list:update', users[roomId])
  }

  // обрабатываем подключение нового пользователя
  socket.on('user:add', async (user) => {
    // сообщаем другим пользователям об этом
    socket.to(roomId).emit('log', `User ${userName} connected`)

    // записываем идентификатор сокета пользователя
    user.socketId = socket.id

    // записываем пользователя в хранилище
    users[roomId].push(user)

    // обновляем список пользователей
    updateUserList()
  })

  // обрабатываем отключения пользователя
  socket.on('disconnect', () => {
    if (!users[roomId]) return

    // сообщаем об этом другим пользователям
    socket.to(roomId).emit('log', `User ${userName} disconnected`)

    // удаляем пользователя из хранилища
    users[roomId] = users[roomId].filter((u) => u.socketId !== socket.id)

    // обновляем список пользователей
    updateUserList()
  })
}

Обработчики для сообщений (handlers/message.handlers.js):


import Message from '../../models/message.model.js'
import { removeFile } from '../../utils/file.js'
import onError from '../../utils/onError.js'

// "хранилище" для сообщений
const messages = {}

export default function messageHandlers(io, socket) {
  // извлекаем идентификатор комнаты
  const { roomId } = socket

  // утилита для обновления списка сообщений
  const updateMessageList = () => {
    io.to(roomId).emit('message_list:update', messages[roomId])
  }

  // обрабатываем получение сообщений
  socket.on('message:get', async () => {
    try {
      // получаем сообщения по `id` комнаты
      const _messages = await Message.find({
        roomId
      })
      // инициализируем хранилище сообщений
      messages[roomId] = _messages

      // обновляем список сообщений
      updateMessageList()
    } catch (e) {
      onError(e)
    }
  })

  // обрабатываем создание нового сообщения
  socket.on('message:add', (message) => {
    // пользователи не должны ждать записи сообщения в БД
    Message.create(message).catch(onError)

    // это нужно для клиента
    message.createdAt = Date.now()

    // создаем сообщение оптимистически,
    // т.е. предполагая, что запись сообщения в БД будет успешной
    messages[roomId].push(message)

    // обновляем список сообщений
    updateMessageList()
  })

  // обрабатываем удаление сообщения
  socket.on('message:remove', (message) => {
    const { messageId, messageType, textOrPathToFile } = message

    // пользователи не должны ждать удаления сообщения из БД
    // и файла на сервере (если сообщение является файлом)
    Message.deleteOne({ messageId })
      .then(() => {
        if (messageType !== 'text') {
          removeFile(textOrPathToFile)
        }
      })
      .catch(onError)

    // удаляем сообщение оптимистически
    messages[roomId] = messages[roomId].filter((m) => m.messageId !== messageId)

    // обновляем список сообщений
    updateMessageList()
  })
}

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


Это все, что требуется от нашего сервера.


Переходим к реализации клиента.


Клиент


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


cd client

# производственные зависимости
yarn add react-router-dom zustand react-icons emoji-mart react-speech-kit react-timeago socket.io-client nanoid
# or
npm i ...

# зависимость для разработки
yarn add -D sass
# or
npm i -D sass

  • react-router-dom — библиотека для маршрутизации на стороне клиента;
  • zustand — библиотека для управления состоянием приложения;
  • react-icons — большой набор иконок в виде компонентов;
  • emoji-mart — компонент с эмодзи;
  • react-speech-kit — обертка над Web Speech API для react;
  • react-timeago — компонент для отображения относительного времени;
  • socket.io-client — клиент socket.io;
  • nanoid — утилита для генерации идентификаторов;
  • sassпрепроцессор CSS.

Структура директории src будет следующей:


- api
  - file.api.js - интерфейс для загрузки файлов
- components
  - NameInput
    - NameInput.js - компонент для ввода имени пользователя
  - Room
    - MessageInput
      - EmojiMart
        - EmojiMart.js - компонент для эмодзи
      - FileInput
        - FileInput.js - компонент для выбора (прикрепления) файла для отправки
        - FilePreview.js - компонент для отображения превью файла
      - Recorder
        - Recorder.js - компонент для создания аудио или видеозаписи
        - RecordingModal.js - модальное окно для выбора типа и управления процессом записи
      - MessageInput.js - компонент для ввода сообщения пользователем, выбора эмодзи, прикрепления файла или создания аудио или видеозаписи
    - MessageList
      - MessageItem.js - компонент для одного сообщения
      - MessageList.js - компонент для списка сообщений
    - UserList
      - UserList - компонент для списка пользователей
    - Room.js - компонент для комнаты
  - index.js - повторный экспорт компонентов
- hooks
  - useChat.js - хук для работы с сокетами
  - useStore.js - хранилище состояния в форме хука
- pages
  - Home
    - Home.js - домашняя страница
  - index.js - повторный экспорт страниц
- routes
  - app.routes.js - роуты приложения
- styles - стили (я не буду на них останавливаться, просто скопируйте их из репозитория с исходным кодом проекта)
- utils
  - recording.js - утилиты для создания аудио или видеозаписи
  - storage.js - утилита для работы с локальным хранилищем
- App.js - основной компонент приложения
- App.scss - стили
- constants.js - константы
- index.js - основной файл клиента

Начнем с основного компонента приложения (App.js):


import { BrowserRouter } from 'react-router-dom'
import AppRoutes from 'routes/app.routes'
import './App.scss'

function App() {
  return (
    <BrowserRouter>
      <AppRoutes />
    </BrowserRouter>
  )
}

export default App

Подключаем роутер и рендерим роуты приложения.


Рассмотрим эти роуты (routes/app.routes.js):


import { Home } from 'pages'
import { Route, Routes } from 'react-router-dom'

const AppRoutes = () => (
  <Routes>
    <Route path='*' element={<Home />} />
  </Routes>
)

export default AppRoutes

Все дороги, т.е. пути ведут в Рим, т.е. на главную страницу.


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


Взглянем на домашнюю страницу (pages/Home/Home.js):


import { NameInput, Room } from 'components'
import { USER_KEY } from 'constants'
import storage from 'utils/storage'

export const Home = () => {
  const user = storage.get(USER_KEY)

  return user ? <Room /> : <NameInput />
}

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


Утилита для работы с локальным хранилищем (utils/storage.js):


const storage = {
  get: (key) =>
    window.localStorage.getItem(key)
      ? JSON.parse(window.localStorage.getItem(key))
      : null,
  set: (key, value) => window.localStorage.setItem(key, JSON.stringify(value))
}

export default storage

Константы (constants.js):


export const USER_KEY = 'chat_app_user'
export const SERVER_URI = 'http://localhost:4000'

Займемся реализацией компонентов (components).


Компонент для ввода имени пользователя (NameInput/NameInput.js):


// импорты
import { USER_KEY } from 'constants'
import { nanoid } from 'nanoid'
import { useEffect, useState } from 'react'
import storage from 'utils/storage'

export const NameInput = () => {
  // начальные данные
  const [formData, setFormData] = useState({
    userName: '',
    // фиксируем ("хардкодим") название (идентификатор) комнаты
    roomId: 'main_room'
  })
  // состояние блокировки кнопки
  const [submitDisabled, setSubmitDisabled] = useState(true)

  // все поля формы являются обязательными
  useEffect(() => {
    const isSomeFieldEmpty = Object.values(formData).some((v) => !v.trim())
    setSubmitDisabled(isSomeFieldEmpty)
  }, [formData])

  // функция для изменения данных
  const onChange = ({ target: { name, value } }) => {
    setFormData({ ...formData, [name]: value })
  }

  // функция для отправки формы
  const onSubmit = (e) => {
    e.preventDefault()
    if (submitDisabled) return

    // генерируем идентификатор пользователя
    const userId = nanoid()

    // записываем данные пользователя в локальное хранилище
    storage.set(USER_KEY, {
      userId,
      userName: formData.userName,
      roomId: formData.roomId
    })

    // перезагружаем приложение для того, чтобы "попасть" в комнату
    window.location.reload()
  }

  return (
    <div className='container name-input'>
      <h2>Welcome</h2>
      <form onSubmit={onSubmit} className='form name-room'>
        <div>
          <label htmlFor='userName'>Enter your name</label>
          <input
            type='text'
            id='userName'
            name='userName'
            minLength={2}
            required
            value={formData.userName}
            onChange={onChange}
          />
        </div>
        {/* скрываем поле для создания комнаты (возможность масштабирования) */}
        <div class='visually-hidden'>
          <label htmlFor='roomId'>Enter room ID</label>
          <input
            type='text'
            id='roomId'
            name='roomId'
            minLength={4}
            required
            value={formData.roomId}
            onChange={onChange}
          />
        </div>
        <button disabled={submitDisabled} className='btn chat'>
          Chat
        </button>
      </form>
    </div>
  )
}

Компонент комнаты (Room/Room.js):


import useChat from 'hooks/useChat'
import MessageInput from './MessageInput/MessageInput'
import MessageList from './MessageList/MessageList'
import UserList from './UserList/UserList'

export const Room = () => {
  // получаем список пользователей, список сообщений, системную информацию и методы для отправки и удаления сообщения
  const { users, messages, log, sendMessage, removeMessage } = useChat()
  // и передаем их соответствующим компонентам
  return (
    <div className='container chat'>
      <div className='container message'>
        <MessageList
          log={log}
          messages={messages}
          removeMessage={removeMessage}
        />
        <MessageInput sendMessage={sendMessage} />
      </div>
      <UserList users={users} />
    </div>
  )
}

Рассмотрим хук для работы с сокетами (hooks/useChat.js):


import { SERVER_URI, USER_KEY } from 'constants'
import { useEffect, useRef, useState } from 'react'
import { io } from 'socket.io-client'
import storage from 'utils/storage'

export default function useChat() {
  // извлекаем данные пользователя из локального хранилища
  const user = storage.get(USER_KEY)
  // локальное состояние для списка пользователей
  const [users, setUsers] = useState([])
  // локальное состояние для списка сообщений
  const [messages, setMessages] = useState([])
  // состояние для системного сообщения
  const [log, setLog] = useState(null)
  // иммутабельное состояние для сокета
  const { current: socket } = useRef(
    io(SERVER_URI, {
      query: {
        // отправляем идентификатор комнаты и имя пользователя на сервер
        roomId: user.roomId,
        userName: user.userName
      }
    })
  )

  // регистрируем обработчики
  useEffect(() => {
    // сообщаем о подключении нового пользователя
    socket.emit('user:add', user)

    // запрашиваем сообщения из БД
    socket.emit('message:get')

    // обрабатываем получение системного сообщения
    socket.on('log', (log) => {
      setLog(log)
    })

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

    // обрабатываем получение обновленного списка сообщений
    socket.on('message_list:update', (messages) => {
      setMessages(messages)
    })
  }, [])

  // метод для отправки сообщения
  const sendMessage = (message) => {
    socket.emit('message:add', message)
  }

  // метод для удаления сообщения
  const removeMessage = (message) => {
    socket.emit('message:remove', message)
  }

  return { users, messages, log, sendMessage, removeMessage }
}

Компонент для отображения списка пользователей (UserList/UserList.js):


import { AiOutlineUser } from 'react-icons/ai'

export default function UserList({ users }) {
  return (
    <div className='container user'>
      <h2>Users</h2>
      <ul className='list user'>
        {users.map(({ userId, userName }) => (
          <li key={userId} className='item user'>
            <AiOutlineUser className='icon user' />
            {userName}
          </li>
        ))}
      </ul>
    </div>
  )
}

Перебираем пользователей и рендерим список имен.


Компонент для отображения списка сообщений (MessageList/MessageList.js):


import { useEffect, useRef } from 'react'
import MessageItem from './MessageItem'

export default function MessageList({ log, messages, removeMessage }) {
  // иммутабельная ссылка на элемент для отображения системных сообщений
  const logRef = useRef()
  // иммутабельная ссылка на конец списка сообщений
  const bottomRef = useRef()

  // выполняем прокрутку к концу списка при добавлении нового сообщения
  // это может стать проблемой при большом количестве пользователей,
  // когда участники чата не будут успевать читать сообщения
  useEffect(() => {
    bottomRef.current.scrollIntoView({
      behavior: 'smooth'
    })
  }, [messages])

  // отображаем и скрываем системные сообщения
  useEffect(() => {
    if (log) {
      logRef.current.style.opacity = 0.8
      logRef.current.style.zIndex = 1

      const timerId = setTimeout(() => {
        logRef.current.style.opacity = 0
        logRef.current.style.zIndex = -1

        clearTimeout(timerId)
      }, 1500)
    }
  }, [log])

  return (
    <div className='container message'>
      <h2>Messages</h2>
      <ul className='list message'>
        {/* перебираем список и рендерим сообщения */}
        {messages.map((message) => (
          <MessageItem
            key={message.messageId}
            message={message}
            removeMessage={removeMessage}
          />
        ))}

        <p ref={bottomRef}></p>

        <p ref={logRef} className='log'>
          {log}
        </p>
      </ul>
    </div>
  )
}

Компонент сообщения (MessageList/MessageItem.js):


import { SERVER_URI, USER_KEY } from 'constants'
import { CgTrashEmpty } from 'react-icons/cg'
import { GiSpeaker } from 'react-icons/gi'
import { useSpeechSynthesis } from 'react-speech-kit'
import TimeAgo from 'react-timeago'
import storage from 'utils/storage'

export default function MessageItem({ message, removeMessage }) {
  // извлекаем данные пользователя из локального хранилища
  const user = storage.get(USER_KEY)
  // утилиты для перевода текста в речь
  const { speak, voices } = useSpeechSynthesis()
  // определяем язык приложения
  const lang = document.documentElement.lang || 'en'
  // мне нравится голос от гугла
  const voice = voices.find(
    (v) => v.lang.includes(lang) && v.name.includes('Google')
  )

  // элемент для рендеринга зависит от типа сообщения
  let element

  // извлекаем из сообщения тип и текст или путь к файлу
  const { messageType, textOrPathToFile } = message

  // формируем абсолютный путь к файлу
  const pathToFile = `${SERVER_URI}/files${textOrPathToFile}`

  // определяем элемент для рендеринга на основе типа сообщения
  switch (messageType) {
    case 'text':
      element = (
        <>
          <button
            className='btn'
            // озвучиваем текст при нажатии кнопки
            onClick={() => speak({ text: textOrPathToFile, voice })}
          >
            <GiSpeaker className='icon speak' />
          </button>
          <p>{textOrPathToFile}</p>
        </>
      )
      break
    case 'image':
      element = <img src={pathToFile} alt='' />
      break
    case 'audio':
      element = <audio src={pathToFile} controls></audio>
      break
    case 'video':
      element = <video src={pathToFile} controls></video>
      break
    default:
      return null
  }

  // определяем принадлежность сообщения текущему пользователю
  const isMyMessage = user.userId === message.userId

  return (
    <li className={`item message ${isMyMessage ? 'my' : ''}`}>
      <p className='username'>{isMyMessage ? 'Me' : message.userName}</p>

      <div className='inner'>
        {element}

        {isMyMessage && (
          {/* пользователь может удалять только свои сообщения */}
          <button className='btn' onClick={() => removeMessage(message)}>
            <CgTrashEmpty className='icon remove' />
          </button>
        )}
      </div>

      <p className='datetime'>
        <TimeAgo date={message.createdAt} />
      </p>
    </li>
  )
}

Рассмотрим хранилище в форме хука (hooks/useStore.js):


import create from 'zustand'

const useStore = create((set, get) => ({
  // файл
  file: null,
  // индикатор отображения превью файла
  showPreview: false,
  // индикатор отображения компонента с эмодзи
  showEmoji: false,
  // метод для обновления файла
  setFile: (file) => {
    // получаем предыдущий файл
    const prevFile = get().file
    if (prevFile) {
      // https://w3c.github.io/FileAPI/#creating-revoking
      // это позволяет избежать утечек памяти
      URL.revokeObjectURL(prevFile)
    }
    // обновляем файл
    set({ file })
  },
  // метод для обновления индикатора отображения превью
  setShowPreview: (showPreview) => set({ showPreview }),
  // метод для обновления индикатора отображения эмодзи
  setShowEmoji: (showEmoji) => set({ showEmoji })
}))

export default useStore

Компонент для ввода сообщения (MessageInput/MessageInput.js):


import fileApi from 'api/file.api'
import { USER_KEY } from 'constants'
import useStore from 'hooks/useStore'
import { nanoid } from 'nanoid'
import { useEffect, useRef, useState } from 'react'
import { FiSend } from 'react-icons/fi'
import storage from 'utils/storage'
import EmojiMart from './EmojiMart/EmojiMart'
import FileInput from './FileInput/FileInput'
import Recorder from './Recorder/Recorder'

export default function MessageInput({ sendMessage }) {
  // извлекаем данные пользователя из локального хранилища
  const user = storage.get(USER_KEY)
  // извлекаем состояние из хранилища
  const state = useStore((state) => state)
  const {
    file,
    setFile,
    showPreview,
    setShowPreview,
    showEmoji,
    setShowEmoji
  } = state
  // локальное состояние для текста сообщения
  const [text, setText] = useState('')
  // локальное состояние блокировки кнопки
  const [submitDisabled, setSubmitDisabled] = useState(true)
  // иммутабельная ссылка на инпут для ввода текста сообщения
  const inputRef = useRef()

  // для отправки сообщения требуется либо текст сообщения, либо файл
  useEffect(() => {
    setSubmitDisabled(!text.trim() && !file)
  }, [text, file])

  // отображаем превью при наличии файла
  useEffect(() => {
    setShowPreview(file)
  }, [file, setShowPreview])

  // функция для отправки сообщения
  const onSubmit = async (e) => {
    e.preventDefault()
    if (submitDisabled) return

    // извлекаем данные пользователя и формируем начальное сообщение
    const { userId, userName, roomId } = user
    let message = {
      messageId: nanoid(),
      userId,
      userName,
      roomId
    }

    if (!file) {
      // типом сообщения является текст
      message.messageType = 'text'
      message.textOrPathToFile = text
    } else {
      // типом сообщения является файл
      try {
        // загружаем файл на сервер и получаем относительный путь к нему
        const path = await fileApi.upload({ file, roomId })
        // получаем тип файла
        const type = file.type.split('/')[0]

        message.messageType = type
        message.textOrPathToFile = path
      } catch (e) {
        console.error(e)
      }
    }

    // скрываем компонент с эмодзи, если он открыт
    if (showEmoji) {
      setShowEmoji(false)
    }

    // отправляем сообщение
    sendMessage(message)

    // сбрасываем состояние
    setText('')
    setFile(null)
  }

  return (
    <form onSubmit={onSubmit} className='form message'>
      <EmojiMart setText={setText} messageInput={inputRef.current} />
      <FileInput />
      <Recorder />
      <input
        type='text'
        autoFocus
        placeholder='Message...'
        value={text}
        onChange={(e) => setText(e.target.value)}
        ref={inputRef}
        // при наличии файла вводить текст нельзя
        disabled={showPreview}
      />
      <button className='btn' type='submit' disabled={submitDisabled}>
        <FiSend className='icon' />
      </button>
    </form>
  )
}

Компонент для отображения эмодзи (MessageInput/EmojiMart/EmojiMart.js):


import { Picker } from 'emoji-mart'
import 'emoji-mart/css/emoji-mart.css'
import useStore from 'hooks/useStore'
import { useCallback, useEffect } from 'react'
import { BsEmojiSmile } from 'react-icons/bs'

export default function EmojiMart({ setText, messageInput }) {
  // извлекаем соответствующие методы из хранилища
  const { showEmoji, setShowEmoji, showPreview } = useStore(
    ({ showEmoji, setShowEmoji, showPreview }) => ({
      showEmoji,
      setShowEmoji,
      showPreview
    })
  )

  // обработчик нажатия клавиши `Esc`
  const onKeydown = useCallback(
    (e) => {
      if (e.key === 'Escape') {
        setShowEmoji(false)
      }
    },
    [setShowEmoji]
  )

  // регистрируем данный обработчик на объекте `window`
  useEffect(() => {
    window.addEventListener('keydown', onKeydown)

    return () => {
      window.removeEventListener('keydown', onKeydown)
    }
  }, [onKeydown])

  // метод для добавления эмодзи к тексту сообщения
  const onSelect = ({ native }) => {
    setText((text) => text + native)
    messageInput.focus()
  }

  return (
    <div className='container emoji'>
      <button
        className='btn'
        type='button'
        {/* отображаем/скрываем эмодзи при нажатии кнопки */}
        onClick={() => setShowEmoji(!showEmoji)}
        disabled={showPreview}
      >
        <BsEmojiSmile className='icon' />
      </button>
      {showEmoji && (
        <Picker
          onSelect={onSelect}
          emojiSize={20}
          showPreview={false}
          perLine={6}
        />
      )}
    </div>
  )
}

Компонент для прикрепления файла (MessageInput/FileInput/FileInput.js):


import useStore from 'hooks/useStore'
import { useEffect, useRef } from 'react'
import { MdAttachFile } from 'react-icons/md'
import FilePreview from '../FilePreview/FilePreview'

export default function FileInput() {
  // извлекаем файл и метод для его обновления из хранилища
  const { file, setFile } = useStore(({ file, setFile }) => ({ file, setFile }))
  // иммутабельная ссылка на инпут для добавления файла
  // мы скрываем инпут за кнопкой
  const inputRef = useRef()

  // сбрасываем значение инпута при отсутствии файла
  useEffect(() => {
    if (!file) {
      inputRef.current.value = ''
    }
  }, [file])

  return (
    <div className='container file'>
      <input
        type='file'
        accept='image/*, audio/*, video/*'
        onChange={(e) => setFile(e.target.files[0])}
        className='visually-hidden'
        ref={inputRef}
      />
      <button
        type='button'
        className='btn'
        // передаем клик инпуту
        onClick={() => inputRef.current.click()}
      >
        <MdAttachFile className='icon' />
      </button>

      {file && <FilePreview />}
    </div>
  )
}

Компонент для отображения превью файла (MessageInput/FileInput/FilePreview.js):


import useStore from 'hooks/useStore'
import { useEffect, useState } from 'react'
import { AiOutlineClose } from 'react-icons/ai'

export default function FilePreview() {
  // извлекаем файл и метод для его обновления из хранилища
  const { file, setFile } = useStore(({ file, setFile }) => ({ file, setFile }))
  // локальное состояние для источника файла
  const [src, setSrc] = useState()
  // локальное состояние для типа файла
  const [type, setType] = useState()

  // при наличии файла обновляем источник и тип файла
  useEffect(() => {
    if (file) {
      setSrc(URL.createObjectURL(file))
      setType(file.type.split('/')[0])
    }
  }, [file])

  // элемент для рендеринга зависит от типа файла
  let element

  switch (type) {
    case 'image':
      element = <img src={src} alt={file.name} />
      break
    case 'audio':
      element = <audio src={src} controls></audio>
      break
    case 'video':
      element = <video src={src} controls></video>
      break
    default:
      element = null
      break
  }

  return (
    <div className='container preview'>
      {element}

      <button
        type='button'
        className='btn close'
        // обнуляем файл при закрытии превью
        onClick={() => setFile(null)}
      >
        <AiOutlineClose className='icon close' />
      </button>
    </div>
  )
}

Нам осталось рассмотреть компонент для создания аудио или видеозаписи. Но сначала рассмотрим соответствующие утилиты (utils/recording.js):


// https://www.w3.org/TR/mediastream-recording/
// переменные для рекордера, частей данных и требований к потоку данных
let mediaRecorder = null
let mediaChunks = []
let mediaConstraints = null

// https://w3c.github.io/mediacapture-main/#constrainable-interface
// требования к аудиопотоку
export const audioConstraints = {
  audio: {
    echoCancellation: true,
    autoGainControl: true,
    noiseSuppression: true
  }
}

// требования к медиапотоку (аудио + видео)
export const videoConstraints = {
  ...audioConstraints,
  video: {
    width: 1920,
    height: 1080,
    frameRate: 60.0
  }
}

// индикатор начала записи
export const isRecordingStarted = () => !!mediaRecorder

// метод для приостановки записи
export const pauseRecording = () => {
  mediaRecorder.pause()
}

// метод для продолжения записи
export const resumeRecording = () => {
  mediaRecorder.resume()
}

// метод для начала записи
// принимает требования к потоку
export const startRecording = async (constraints) => {
  mediaConstraints = constraints

  try {
    // https://w3c.github.io/mediacapture-main/#dom-mediadevices-getusermedia
    // получаем поток с устройств пользователя
    const stream = await navigator.mediaDevices.getUserMedia(constraints)
    // определяем тип создаваемой записи
    const type = constraints.video ? 'video' : 'audio'

    // https://www.w3.org/TR/mediastream-recording/#mediarecorder-constructor
    // создаем экземпляр рекордера
    mediaRecorder = new MediaRecorder(stream, { mimeType: `${type}/webm` })

    // обрабатываем запись данных
    mediaRecorder.ondataavailable = ({ data }) => {
      mediaChunks.push(data)
    }

    // запускаем запись
    mediaRecorder.start(250)

    // возвращаем поток
    return stream
  } catch (e) {
    console.error(e)
  }
}

// метод для завершения записи
export const stopRecording = () => {
  // останавливаем рекордер
  mediaRecorder.stop()
  // останавливаем треки из потока
  mediaRecorder.stream.getTracks().forEach((t) => {
    t.stop()
  })

  // определяем тип записи
  const type = mediaConstraints.video ? 'video' : 'audio'
  // https://w3c.github.io/FileAPI/#file-constructor
  // создаем новый файл
  const file = new File(mediaChunks, 'my_record.webm', {
    type: `${type}/webm`
  })

  // без этого запись можно будет создать только один раз
  mediaRecorder.ondataavailable = null
  // обнуляем рекордер
  mediaRecorder = null
  // очищаем массив с данными
  mediaChunks = []

  // возвращаем файл
  return file
}

Компонент для создания записи (MessageInput/Recorder/Recorder.js):


import useStore from 'hooks/useStore'
import { useState } from 'react'
import { RiRecordCircleLine } from 'react-icons/ri'
import RecordingModal from './RecordingModal'

export default function Recorder() {
  // извлекаем индикатор отображения превью файла из хранилища
  const showPreview = useStore(({ showPreview }) => showPreview)
  // локальное состояние для индикатора отображения модального окна
  const [showModal, setShowModal] = useState(false)

  return (
    <div className='container recorder'>
      <button
        type='button'
        className='btn'
        // показываем модальное окно при нажатии кнопки
        onClick={() => setShowModal(true)}
        // блокируем кнопку при отображении превью файла
        disabled={showPreview}
      >
        <RiRecordCircleLine className='icon' />
      </button>
      {showModal && <RecordingModal setShowModal={setShowModal} />}
    </div>
  )
}

Одна из самых интересных частей приложения — модальное окно для выбора типа и создания записи (MessageInput/Recorder/RecordingModal.js):


import useStore from 'hooks/useStore'
import { useRef, useState } from 'react'
import { BsFillPauseFill, BsFillPlayFill, BsFillStopFill } from 'react-icons/bs'
import {
  audioConstraints,
  isRecordingStarted,
  pauseRecording,
  resumeRecording,
  startRecording,
  stopRecording,
  videoConstraints
} from 'utils/recording'

export default function RecordingModal({ setShowModal }) {
  // извлекаем метод для обновления файла из хранилища
  const setFile = useStore(({ setFile }) => setFile)
  // локальное состояние для требований к потоку данных
  // по умолчанию создается аудиозапись
  const [constraints, setConstraints] = useState(audioConstraints)
  // локальный индикатор начала записи
  const [recording, setRecording] = useState(false)
  // иммутабельная ссылка на элемент для выбора типа записи
  const selectBlockRef = useRef()
  // иммутабельная ссылка на элемент `video`
  const videoRef = useRef()

  // функция для обновления требований к потоку на основе типа записи
  const onChange = ({ target: { value } }) =>
    value === 'audio'
      ? setConstraints(audioConstraints)
      : setConstraints(videoConstraints)

  // функция для приостановки/продолжения записи
  const pauseResume = () => {
    if (recording) {
      pauseRecording()
    } else {
      resumeRecording()
    }
    setRecording(!recording)
  }

  // функция для начала записи
  const start = async () => {
    if (isRecordingStarted()) {
      return pauseResume()
    }

    // получаем поток
    const stream = await startRecording(constraints)

    // обновляем локальный индикатор начала записи
    setRecording(true)

    // скрываем элемент для выбора типа записи
    selectBlockRef.current.style.display = 'none'

    // если создается видеозапись
    if (constraints.video && stream) {
      videoRef.current.style.display = 'block'
      // направляем поток в элемент `video`
      videoRef.current.srcObject = stream
    }
  }

  // функция для завершения записи
  const stop = () => {
    // получаем файл
    const file = stopRecording()

    // обновляем локальный индикатор начала записи
    setRecording(false)

    // обновляем файл
    setFile(file)

    // скрываем модалку
    setShowModal(false)
  }

  return (
    <div
      className='overlay'
      onClick={(e) => {
        // скрываем окно при клике за его пределами
        if (e.target.className !== 'overlay') return
        setShowModal(false)
      }}
    >
      <div className='modal'>
        <div ref={selectBlockRef}>
          <h2>Select type</h2>
          <select onChange={onChange}>
            <option value='audio'>Audio</option>
            <option value='video'>Video</option>
          </select>
        </div>

        {/* вот для чего нам нужны 2 индикатора начала записи */}
        {isRecordingStarted() && <p>{recording ? 'Recording...' : 'Paused'}</p>}

        <video ref={videoRef} autoPlay muted />

        <div className='controls'>
          <button className='btn play' onClick={start}>
            {recording ? (
              <BsFillPauseFill className='icon' />
            ) : (
              <BsFillPlayFill className='icon' />
            )}
          </button>
          {isRecordingStarted() && (
            <button className='btn stop' onClick={stop}>
              <BsFillStopFill className='icon' />
            </button>
          )}
        </div>
      </div>
    </div>
  )
}

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


Проверка работоспособности приложения


Находясь в корневой директории проекта, выполняем команду yarn dev и открываем 2 вкладки браузера по адресу http://localhost:3000 (одну из вкладок открываем в режиме инкогнито).


Вводим имена пользователей и входим в комнату:





Обмениваемся сообщениями:





Обмениваемся эмодзи:





Обмениваемся файлами:





Обмениваемся аудио/видео записями:








Удаляем парочку сообщений:





Приложение работает, как ожидается.


Поскольку, у наших пользователей имеются относительно стабильные и известные клиенту id, ничто не мешает нам масштабировать приложение, добавив в него возможность совершения аудио и видеозвонков посредством WebRTC, о чем я рассказывал в этой статье. Будем считать это вашим домашним заданием.


Пожалуй, это все, чем я хотел поделиться с вами в данной статье.


Благодарю за внимание и happy coding!




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


  1. polearnik
    11.03.2022 12:46
    +4

    socket.io и express отвратительный выбор. Столкнулся с тем что мощный сервер с околонулевой нагрузкой на память проц и сеть не может обработать больше пары тысяч пользоателей. . После многочисленных дебагов заменил на uWebsockets.js и пробелма пропала. Ну а про express не высказался только ленивый. используйте fastify.


    1. aio350 Автор
      13.03.2022 12:09

      Я бы сказал, что Express и Socket.io не отвратительный, а, скорее, классический, хотя, возможно, и не лучший выбор. Про Fastify и uWebsockets слышал, но ни то, ни другое пока не пробовал. Сейчас знакомлюсь с Nest.js, в котором в качестве нижележащей платформы (underlying platform) по умолчанию используется Express, хотя имеется возможность переключиться на Fastify. Вероятно, Express все-таки не так страшен, как его малюют. Впрочем, возможность переключения на Fastify тоже кое о чем говорит.


  1. jsmarketing
    12.03.2022 19:29
    +1

    Спасибо за материал, обязательно изучу! И не только вам а всем авторам. Мой стереотип про авторитетность Хабра как источника информации нерушим, и поддерживается вашими публикациями! К сожалению, читать все и сразу не получается чисто физически. Скорочтение освоить что ли..еще раз спасибо!