Доброго времени суток, друзья!
Хочу поделиться с вами опытом разработки простого чата на 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
- socket.io-client — клиентская часть Socket.IO
- react-router-dom — маршрутизация
- styled-components — стилизация (CSS-in-JS)
- 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
Часть из названных идей входит в мои планы по улучшению чата.
Благодарю за внимание и хорошего дня.