Создание приложения для real-time обмена геоданными с React, Socket.io и Leaflet

от автора

Есть много руководств о том, как сделать приложение для общения в реальном времени на React и Socket.io. Создание таких приложений полезно для обучения, но мне захотелось чего-то более творческого. Пришла идея сделать приложение, где можно делиться местоположением.

Итак, начнем.

Несколько скринов того, что получилось, и GitHub

Можно получать уведомления, когда кто-то присоединяется или уходит из канала, а также видеть текущее количество пользователей, находящихся в сети.

Как только оунер перестает делиться своим местоположением, канал удаляется, и другие пользователи не могут видеть локацию.

Тестировать приложение можно не выходя на улицу (изменение местоположения).

GitHub: https://github.com/manish-mehra/locshare

Настройка проекта

На фронтенде я настроил 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}`) })

Когда пользователь делится местоположением, по сути, уже создается канал.

Этот канал будет иметь уникальный идентификатор. Как только канал будет создан, идентификатор отправляется пользователю.

Вот флоу:

  1. Пользователь делится местоположением: событие создания канала

  2. Сервер получает событие

    1. Создание идентификатора канала

    2. Присоединение к каналу

    3. Прикрепление идентификатора канала к текущему сокет-клиенту

    4. Привязка события создания канала и идентификатора канала

    5. Сохранение создателя канала

Здесь немного расширяется сокет. К нему присоединяется дополнительное свойство под названием 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 с идентификатором канала.

  1. Пользователь генерирует «joinRoom» с идентификатором канала

  2. Проверка существует ли канал:

    1. существует

      1. Присоединение к каналу

      2. Присвоение roomid сокету

      3. Уведомление создателю канала

      4. Сообщение тому, кто присоединился (сокет)

    2. !существует

      1. Уведомление сокета

... ... 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

Сервер: выход из канала

Вот логика, когда пользователь покидает комнату:

Пользователь покидает канал:

  1. Для создателя канала:

    1. Если уходящий пользователь является создателем:

      1. Закрытие канала

      2. Уведомление других пользователей

  2. Для гостя канала:

    1. Если уходящий пользователь является гостем:

      1. Уведомление создателю:

        1. Нотификация создателю комнаты об уходе.

      2. Выход из канала:

        1. Проверка того, что ушедший пользователь удален из списка участников комнаты.

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 за редактуру статьи.


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


Комментарии

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

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