Есть много руководств о том, как сделать приложение для общения в реальном времени на React и Socket.io. Создание таких приложений полезно для обучения, но мне захотелось чего-то более творческого. Пришла идея сделать приложение, где можно делиться местоположением.
Итак, начнем.
Несколько скринов того, что получилось, и GitHub
Можно получать уведомления, когда кто-то присоединяется или уходит из канала, а также видеть текущее количество пользователей, находящихся в сети.
Как только оунер перестает делиться своим местоположением, канал удаляется, и другие пользователи не могут видеть локацию.
Тестировать приложение можно не выходя на улицу (изменение местоположения).
Настройка проекта
На фронтенде я настроил React-приложение с использованием Vite, TypeScript и Tailwind CSS. Для отображения координат на карте я взял известную библиотеку с открытым исходным кодом под названием Leaflet.
Я недавно начал использовать TypeScript и применил его в этом проекте. Однако важно отметить, что TypeScript не является обязательным инструментом.
Вот зависимости фронтенда:
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@types/leaflet": "^1.9.6",
"@types/react-leaflet": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.11.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.16.0",
"react-toastify": "^9.1.3",
"socket.io-client": "^4.7.2"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.15",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.29",
"tailwindcss": "^3.3.3",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}
Для бэкенда есть базовый сервер node-express со следующими зависимостями:
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"start": "node dist/index.js",
"dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\""
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"socket.io": "^4.7.2"
},
"devDependencies": {
"@types/express": "^4.17.17",
"concurrently": "^8.2.0",
"nodemon": "^3.0.1",
"typescript": "^5.2.2"
}
}
Структура папок
React:
? src
├── ? components
│ ├── ? Element
│ │ ├── ? Header
│ │ ├── ? Map
│ │ ├── ? Status
│ │ ├── ? StatusPanel
│ ├── ? Layout
├── ? context
│ ├── ? socket.tsx
├── ? pages
│ ├── ? Home.tsx
│ ├── ? Location.tsx
├── ? types
├── ? App.tsx
├── ? main.tsx
├── ? index.css
...rest
Node:
? src
├── ? index.ts
Создание Socket Context
Сохраняя сокет в контексте, его можно сделать доступным для всего приложения. Функция connectSocket обрабатывает подключение. Если сокет - null (нет существующего подключения), функция устанавливает новое соединение сокета. Если сокет - не null (отключен), функция подключается к сокету.
// SocketProvider.js
import {useState, createContext, useContext, JSX} from 'react'
import {io, Socket} from 'socket.io-client'
import { SOCKET_URL } from '../config'
type SocketContextType = {
socket: Socket | null;
connectSocket: () => void;
}
type SocketProviderProps = {
children: JSX.Element
}
export const SocketContext = createContext<SocketContextType | null>(null)
export const SocketProvider = ({children}: SocketProviderProps) => {
const [socket, setSocket] = useState<Socket | null>(null)
const connectSocket = () => {
if(!socket) {
const newSocket: Socket = io(SOCKET_URL)
setSocket(newSocket)
return
}
socket.connect()
}
return (
<SocketContext.Provider value={{socket, connectSocket}}>
{children}
</SocketContext.Provider>
)
}
export const useSocket = () => {
const context = useContext(SocketContext)
if(!context) {
throw new Error('Something went wrong!')
}
return context
}
Здесь были сделаны упрощения. Добавлен кастомный хук useSocket, который обрабатывает логику доступа к SocketContext вместе с SocketProvider. Такой подход упрощает код, делая его более читаемым и понятным при работе со значениями контекста в компонентах.
Оборачивание приложения контекстом сокета
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { SocketProvider } from './context/socket.tsx'
import {ToastContainer} from 'react-toastify'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<SocketProvider>
<>
<App />
<ToastContainer newestOnTop/>
</>
</SocketProvider>
</React.StrictMode>,
)
Роуты приложения
Здесь есть две страницы: home и location. В home можно создать канал и передать местоположение. Страница location отображается, когда URL соответствует шаблону /location/1242, указывая, что 1242 - это определенный идентификатор канала или параметр.
Эти страницы обернуты в Layout Component.
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Home from './pages/Home'
import Location from './pages/Location'
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="location/:roomId" element={<Location />}/>
</Route>
</Routes>
</BrowserRouter>
)
}
export default App
Layout Component
import { Outlet } from 'react-router-dom'
import Header from '../Elements/Header'
function index() {
return (
<div className='flex justify-center px-3 py-2'>
<div className='flex flex-col w-full md:min-w-full xl:min-w-[1100px] xl:max-w-[1200px] mb-4'>
<Header />
<main>
<Outlet />
</main>
</div>
</div>
)
}
export default index
Доступ к локации пользователя
Для получения текущего местоположения пользователя можно использовать интерфейс геолокации, предоставляемый браузером. Это вызовет всплывающее окно, запрашивающее разрешение на доступ к местоположению.
// получить текущую локацию
navigator.geolocation.getCurrentPosition(success, error, options)
success, error (опционально), options (опционально) — это передаваемые коллбэк-функции, и через них можно получить доступ к координатам или ошибке или передать дополнительные параметры. Дополнительную информацию можно найти в доке Mozilla.
Просто текущая позиция не нужна. Необходимо именно отслеживать перемещение пользователя из точки А в точку Б.
Мы могли бы использовать функцию setInterval, которая неоднократно бы вызывала getCurrentPosition(). Но она не требуется, поскольку интерфейс геолокации предоставляет другой метод, watchPosition(). И этот метод вызывается каждый раз, когда позиция меняется.
// узнать текущую локацию
let id = navigator.geolocation.watchPosition(success, error, options)
// очистка
navigator.geolocation.clearWatch(id)
Метод watchPosition похож на setTimeout или setInterval, который работает асинхронно в фоновом режиме и отслеживает текущую позицию пользователя. Сохраняя его в переменной, сохраняется и ссылка на него. Позже, когда больше не нужно будет отслеживать местоположение пользователя, мы можем очистить переменную или установить ее значение как null. Если этого не сделать, все продолжит работать в фоновом режиме, что может привести к проблемам с памятью и ненужному потреблению ресурсов.
type GeolocationPosition = {
lat: number
lng: number
}
type LocationStatus = 'accessed' | 'denied' | 'unknown' | 'error'
export default function Home() {
const [locationStatus, setLocationStatus] = useState<LocationStatus>('unknown')
const [position, setPosition] = useState<GeolocationPosition | null>(null)
useEffect(() => {
let watchId: number | null = null
// проверка воможности отслеживать геопозицию в браузере
if('geolocation' in navigator) {
watchId = navigator.geolocation.watchPosition((position) => {
setPosition({
lat: position.coords.latitude,
lng: position.coords.longitude
})
setLocationStatus('accessed')
}, (error) => {
switch (error.code) {
case error.PERMISSION_DENIED:
setLocationStatus('denied')
break
case error.POSITION_UNAVAILABLE:
setLocationStatus('unknown')
break
case error.TIMEOUT:
setLocationStatus('error')
break
default:
setLocationStatus('error')
break
}
})
return () => {
if(watchId) {
navigator.geolocation.clearWatch(watchId)
}
}
}
}, [])
...
...
При загрузке страницы, появляется запрос на доступ к местоположению и запрашивается разрешение на определение местоположения. На основании этого определяется, дал ли пользователь разрешение на определение местоположения. Как только геолоцирование разрешено, появляется доступ к объекту положения, который содержит координаты пользователя.
С кодом ошибки, предоставленным геолокацией, можно легко обработать ошибку и точно узнать, почему нет доступа к местоположению.
Определение координат на карте
Получив координаты пользователя можно передавать их в компонент Map, и местоположение будет показано на карте.
Нужно, чтобы маркер карты автоматически перемещался на переданные координатам. Для этого в компоненте Location Marker помещен метод map.flyTo, предоставленный инстансом leaflet, внутрь хука useEffect с позицией в качестве зависимости. При каждом изменении местоположения маркер будет перемещаться в нужное место.
import { useState, useEffect } from 'react'
import { MapContainer, TileLayer, useMapEvents, Marker, Popup } from 'react-leaflet'
import { GeolocationPosition } from '../../../types'
import 'leaflet/dist/leaflet.css'
function Map({ location }: { location: GeolocationPosition }) {
if (!location) return 'No location found'
return (
<div className='w-full bg-gray-100 h-[600px] md:h-[550px]'>
<MapContainer center={[location.lat, location.lng]} zoom={30} scrollWheelZoom={true} className='h-screen'>
<TileLayer
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' // URL слоя тайлов OpenStreetMap
/>
<LocationMarker location={location} />
</MapContainer>
</div>
)
}
function LocationMarker({ location }: { location: GeolocationPosition }) {
const map = useMapEvents({}) // Используйте события карты для доступа к инстансу карты Leaflet
const [position, setPosition] = useState({
lat: location.lat,
lng: location.lng
})
// Эффект обновления положения маркера и перелета в новое место при изменении данных о местоположении
useEffect(() => {
setPosition({
lat: location.lat,
lng: location.lng
})
map.flyTo([location.lat, location.lng]) // Изменение положения на карте
}, [location])
return position === null ? null : (
<Marker position={position}>
<Popup>User is here!</Popup>
</Marker>
)
}
export default Map
Теперь можно использовать этот компонент на домашней странице.
export default function Home() {
const [locationStatus, setLocationStatus] = useState<LocationStatus>('unknown')
const [position, setPosition] = useState<GeolocationPosition | null>(null)
// ... rest
return(
<>
{/** ...rest **/}
{
position && (<Map location={position}/>)
}
</>
)
Настройка сокет-сервера node-express
В этом коде настроен простой HTTP-сервер, на Node.js и Express. Затем создан инстанс сервера Socket.io (переменная io) и ему передан сервер Express. Несмотря на то, что HTTP и веб-сокеты представляют из себя разные протоколы связи, Socket io позволяет как HTTP-серверу, так и серверу WebSocket использовать один и тот же инстанс сервера, позволяя им взаимодействовать через один и тот же сетевой порт.
import express, { Express, Request, Response } from 'express'
import {Socket, Server} from 'socket.io'
import cors from 'cors'
import dotenv from 'dotenv'
dotenv.config()
const app: Express = express()
const port = process.env.PORT || 5000
app.use(cors())
app.use(express.json())
app.get('/', (req: Request, res: Response) => {
res.send('Welcome to LocShare!')
})
const server = app.listen(port, () => {
console.log(`Server is running`)
})
const io: Server = new Server(server, {
cors: {
origin: '*',
},
})
io.on('connection', (socket: Socket) => {
console.log(`User connected: ${socket.id}`)
})
Когда пользователь делится местоположением, по сути, уже создается канал.
Этот канал будет иметь уникальный идентификатор. Как только канал будет создан, идентификатор отправляется пользователю.
Вот флоу:
Пользователь делится местоположением: событие создания канала
Сервер получает событие
Создание идентификатора канала
Присоединение к каналу
Прикрепление идентификатора канала к текущему сокет-клиенту
Привязка события создания канала и идентификатора канала
Сохранение создателя канала
Здесь немного расширяется сокет. К нему присоединяется дополнительное свойство под названием roomId. Позже оно будет использовано при выходе пользователя из канала.
// Определение кастомного интерфейса, расширяющего интерфейс Socket
interface CustomSocket extends Socket {
roomId?: string
}
const roomCreator = new Map<string, string>() // roomid => socketid
io.on('connection', (socket: CustomSocket) => {
console.log(`User connected: ${socket.id}`)
socket.on('createRoom', (data) => {
const roomId = Math.random().toString(36).substring(2, 7)
socket.join(roomId) // присоединение к каналу в сокетах
socket.roomId = roomId // присваивание roomId сокету
const totalRoomUsers = io.sockets.adapter.rooms.get(roomId)
socket.emit('roomCreated', {
roomId,
position: data.position,
totalConnectedUsers: Array.from(totalRoomUsers || []),
})
roomCreator.set(roomId, socket.id) // маппинг roomid с сокетом
})
})
Присоединение к каналу
Чтобы присоединиться к каналу клиент генерирует событие joinRoom с идентификатором канала.
Пользователь генерирует «joinRoom» с идентификатором канала
Проверка существует ли канал:
существует
Присоединение к каналу
Присвоение roomid сокету
Уведомление создателю канала
Сообщение тому, кто присоединился (сокет)
!существует
Уведомление сокета
...
...
socket.on('joinRoom', (data: {roomId: string}) => {
// проверка существования канала
const roomExists = io.sockets.adapter.rooms.has(data.roomId)
if (roomExists) {
socket.join(data.roomId)
socket.roomId = data.roomId // Присвоение roomid сокету
// Уведомление создателю канала
const creatorSocketID = roomCreator.get(data.roomId)
if (creatorSocketID) {
const creatorSocket = io.sockets.sockets.get(creatorSocketID) // получение инстанса сокета создателя
if (creatorSocket) {
const totalRoomUsers = io.sockets.adapter.rooms.get(data.roomId)
creatorSocket.emit('userJoinedRoom', {
userId: socket.id,
totalConnectedUsers: Array.from(totalRoomUsers || [])
})
}
}
// сообщение присоединившемуся
io.to(`${socket.id}`).emit('roomJoined', {
status: 'OK',
})
} else {
io.to(`${socket.id}`).emit('roomJoined', {
status: 'ERROR'
})
}
})
Обновление локации
socket.on('updateLocation', (data) => {
io.emit('updateLocationResponse', data)
})
Домашняя страница: подключение сокетов и создание канала
Теперь с домашней страницы можно подключиться к сокет-серверу. Для начала работы пользователь должен сначала поделиться своим местоположением. После получения координат можно подключаться к серверу. После успешного соединения автоматически создается событие createRoom.
Этот подход был выбран для того, чтобы предотвратить автоматическое подключение к серверу при посещении страницы пользователем. В будущем в приложении могут появиться дополнительные страницы, такие как страница входа или регистрации. Чтобы избежать ненужных соединений, все было разработано таким образом, чтобы гарантировать, необходимость установки соединения.
type SocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
type RoomInfo = {
roomId: string
position: GeolocationPosition
totalConnectedUsers: string[]
}
export default function Home() {
// состояния, связанные с местоположением
const {socket, connectSocket} = useSocket()
const [socketStatus, setSocketStatus] = useState<SocketStatus>('disconnected')
const [roomLink, setRoomLink] = useState<string>('')
const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null)
function connectToSocketServer() {
connectSocket()
setSocketStatus('connecting')
}
useEffect(() => {
let watchId: number | null = null
// логика леолокации
}, [])
useEffect(() => {
if(socket) {
socket.on('connect', () => {
setSocketStatus('connected')
socket.emit('createRoom', {
position
})
})
socket.on('roomCreated', (data: RoomInfo) => {
toast.success('You are live!', {
autoClose: 2000,
})
setRoomInfo(data)
})
socket.on('userJoinedRoom', (data: {userId: string, totalConnectedUsers: string[]}) => {
setRoomInfo((prev) => {
if(prev) {
return {
...prev,
totalConnectedUsers: data.totalConnectedUsers
}
}
return null
})
toast.info(`${data.userId} joined the room`, {
autoClose: 2000,
})
position && socket.emit('updateLocation', {
position
})
})
socket.on('disconnect', () => {
setSocketStatus('disconnected')
})
}
}, [socket])
useEffect(() => {
if(socket) {
socket.emit('updateLocation', {
position
})
}
}, [position])
return (
<>
{/* ...rest */}
{
socketStatus === 'disconnected' && (
<div className='flex flex-col gap-6 items-start w-full'>
<button
className={`${locationStatus === 'accessed' ? 'bg-purple-800' : 'bg-gray-600 cursor-not-allowed'}`}
onClick={() => {
if(locationStatus === 'accessed') {
connectToSocketServer()
} else {
toast.error('Please allow location access', {
autoClose: 2000,
})
}
}}
disabled={locationStatus !== 'accessed'}
>Share Location</button>
{/* ...rest */}
</div>
)
Страница местоположения: доступ к идентификатору канала и присоединение к каналу
Когда страница загружается, извлекается идентификатор канала из URL. Впоследствии устанавливается соединение с сокетом. После успешного установления соединения запускается событие «join room», чтобы указать, что пользователь присоединился к указанной комнате.
import React, {useState, useEffect} from 'react'
import { useParams } from 'react-router-dom'
import {useSocket} from '../context/socket'
type RoomStatus = 'unknown' | 'joined' | 'not-exist'
function Location() {
const { roomId } = useParams()
const { socket, connectSocket } = useSocket()
const [socketStatus, setSocketStatus] = useState<SocketStatus>('disconnected')
const [roomStatus, setRoomStatus] = useState<RoomStatus>('unknown')
const [position, setPosition] = useState<GeolocationPosition | null>(null)
useEffect(() => {
connectSocket()
setSocketStatus('connecting')
return () => {
if(socket) {
socket.disconnect()
setSocketStatus('disconnected')
}
}
}, [])
useEffect(() => {
if(socket){
socket.on('connect', () => {
setSocketStatus('connected')
socket.emit('joinRoom', {
roomId
})
})
socket.on('roomJoined', ({status}: {status: string}) => {
if(status === 'OK') {
setRoomStatus('joined')
} else if (status === 'ERROR') {
setRoomStatus('not-exist')
} else {
setRoomStatus('unknown')
}
})
socket.on('updateLocationResponse', ({position}:{position: GeolocationPosition}) => {
if(position) {
setPosition(position)
}
})
socket.on('disconnect', () => {
setSocketStatus('disconnected')
})
}
}, [socket])
// ...rest
Сервер: выход из канала
Вот логика, когда пользователь покидает комнату:
Пользователь покидает канал:
Для создателя канала:
Если уходящий пользователь является создателем:
Закрытие канала
Уведомление других пользователей
Для гостя канала:
Если уходящий пользователь является гостем:
Уведомление создателю:
Нотификация создателю комнаты об уходе.
Выход из канала:
Проверка того, что ушедший пользователь удален из списка участников комнаты.
io.on('connection', (socket: CustomSocket) => {
console.log(`User connected: ${socket.id}`)
// ...rest code
socket.on('disconnect', () => {
console.log(`User disconnected: ${socket.id}`)
const roomId = socket.roomId
if(roomId){
// Удаление комнаты при выходе создателя
if(roomCreator.get(roomId) === socket.id){
// Уведомление гостям об удалении
const roomUsers = io.sockets.adapter.rooms.get(roomId)
if(roomUsers){
for (const socketId of roomUsers) {
io.to(`${socketId}`).emit('roomDestroyed', {
status: 'OK'
})
}
}
io.sockets.adapter.rooms.delete(roomId)
roomCreator.delete(roomId)
} else{
socket.leave(roomId)
// Уведомление создателю о выходе гостя
const creatorSocketId = roomCreator.get(roomId)
if(creatorSocketId){
const creatorSocket = io.sockets.sockets.get(creatorSocketId)
if(creatorSocket){
creatorSocket.emit('userLeftRoom', {
userId: socket.id,
totalConnectedUsers: Array.from(io.sockets.adapter.rooms.get(roomId) || [])
})
}
}
}
}
})
})
Обновление домашней страницы и страницы местоположения: выход из комнаты
home.tsx
export default function Home() {
// ...rest
useEffect(() => {
if(socket) {
socket.on('connect', () => {
setSocketStatus('connected')
socket.emit('createRoom', {
position
})
})
// ...rest
socket.on('userLeftRoom', (data: {userId: string, totalConnectedUsers: string[]}) => {
setRoomInfo((prev) => {
if(prev) {
return {
...prev,
totalConnectedUsers: data.totalConnectedUsers
}
}
return null
})
toast.info(`${data.userId} left the room`, {
autoClose: 2000,
})
})
socket.on('disconnect', () => {
setSocketStatus('disconnected')
})
}
}, [socket])
// ...rest
location.tsx
function Location() {
// ...rest
useEffect(() => {
if(socket){
socket.on('connect', () => {
setSocketStatus('connected')
socket.emit('joinRoom', {
roomId
})
})
// ...rest
socket.on('roomDestroyed', () => {
setRoomStatus('not-exist')
socket.disconnect()
})
socket.on('disconnect', () => {
setSocketStatus('disconnected')
})
}
}, [socket])
function stopSharingLocation() {
if(socket){
socket.disconnect()
setSocketStatus('disconnected')
setRoomInfo(null)
toast.success('You are no longer live!', {
autoClose: 2000,
})
}
}
// ...rest
Всё
Мы рассмотрели процесс и логику приложения для обмена геоданными, сосредоточив внимание на основных аспектах. Я не делился кодом реализации JSX и пользовательского интерфейса, вы можете найти полный исходный код в репозитории GitHub.
PRы приветствуются, если захочется поучаствовать!
Спасибо @r1ndaman за редактуру статьи.