[Разработка веб-сайтов, JavaScript, Программирование, ReactJS] Разрабатываем чат на React с использованием Socket.IO
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Доброго времени суток, друзья!
Хочу поделиться с вами опытом разработки простого чата на 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
Часть из названных идей входит в мои планы по улучшению чата.
Благодарю за внимание и хорошего дня.
===========
Источник:
habr.com
===========
Похожие новости:
- [JavaScript, API] ExtendScript Работа с композициями
- [Python, Программирование] Сохранение сюжетов matplotlib в pdf файл
- [Программирование, Управление персоналом, Карьера в IT-индустрии, Читальный зал] Компульсивная жизнь разработчиков ПО или Почему весь кодинг немного навязчивый? (перевод)
- [Информационная безопасность, Системное администрирование, Программирование, API] Подтверждение номеров телефона без SMS
- [CMS, Разработка веб-сайтов, Антивирусная защита, Программирование, 1С] Сказ о том, как я с гидрой боролся
- [Python, Программирование, Машинное обучение] Расширяющийся нейронный газ
- [Программирование, История IT] 50 лет Паскаля (перевод)
- [Программирование, Проектирование и рефакторинг, Распределённые системы] Интеграция: синхронное, асинхронное и реактивное взаимодействие, консистентность и транзакции
- [Мессенджеры, Разработка под Android, Социальные сети и сообщества, IT-компании] Мэрия Москвы разработала TDM Messenger — «полноценную замену» Telegram и Skype
- [DIY или Сделай сам, Электроника для начинающих] Универсальные платы для умного дома на базе микроконтроллера ATmega128 (ATmega2561)
Теги для поиска: #_razrabotka_vebsajtov (Разработка веб-сайтов), #_javascript, #_programmirovanie (Программирование), #_reactjs, #_javascript, #_react, #_reactjs, #_react.js, #_socket.io, #_socketio, #_socket, #_chat, #_messenger, #_websockets, #_longpolling, #_chat (чат), #_messendzher (мессенджер), #_vebsokety (веб-сокеты), #_razrabotka_vebsajtov (
Разработка веб-сайтов
), #_javascript, #_programmirovanie (
Программирование
), #_reactjs
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 14:01
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Доброго времени суток, друзья! Хочу поделиться с вами опытом разработки простого чата на React с помощью библиотеки «Socket.IO». Предполагается, что вы знакомы с названной библиотекой. Если не знакомы, то вот соответствующее руководство с примерами создания «тудушки» и чата на ванильном JavaScript. Также предполагается, что вы хотя бы поверхностно знакомы с Node.js. В данной статье я сосредоточусь на практической составляющей совместного использования Socket.IO, React и Node.js. Наш чат будет иметь следующие основные возможности:
Также мы реализуем возможность отправки эмодзи. Если вам это интересно, то прошу следовать за мной. Для тех, кого интересует только код: вот ссылка на репозиторий. Песочница: Извините, данный ресурс не поддреживается. :( Структура проекта и зависимости Приступаем к созданию проекта: 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
Добавляем команду «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
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» мы делаем следующее:
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» мы делаем следующее:
Сообщения представляют собой объекты с такими свойствами:
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» мы делаем следующее:
Для работы со списком пользователей мы также могли бы использовать 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». После этого, в открывшейся вкладке браузера вы должны увидеть что-то вроде этого: Вместо заключения Если у вас возникнет желание доработать приложение, то вот вам парочка идей:
Часть из названных идей входит в мои планы по улучшению чата. Благодарю за внимание и хорошего дня. =========== Источник: habr.com =========== Похожие новости:
Разработка веб-сайтов ), #_javascript, #_programmirovanie ( Программирование ), #_reactjs |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 14:01
Часовой пояс: UTC + 5