Есть много руководств о том, как сделать приложение для общения в реальном времени на 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 за редактуру статьи.
