Разрабатываем чат на 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

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

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

ссылка на оригинал статьи https://habr.com/ru/post/544046/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *