У меня было приложение для чата на базе graphql с фронт-эндом react и бэкэндом express.
Оно прекрасно работало, но возникли проблемы, когда я попытался разместить backend на heroku, что-то в том, что они поддерживают только определенный тип websockets.
приложение для чата с подписками на graphql
клиентский код init commit
Один из перечисленных поддерживаемых типов был socketio, так что я попробовал его.
Для фронт-энда мы будем использовать react с vite, потому что create-react-app имеет некоторые устаревшие библиотеки, связанные с webpack, которые вызывают предупреждения об уязвимости.
Моей целью было сделать его как можно более минимальным, чтобы облегчить хостинг, поэтому база данных не будет использоваться.
живой просмотр приложения чата
для начала работы запустите,
npm init vite
и следуйте инструкциям
зависимости
npm install axios react-icons socket.io-client
Я использовал tailwindcss для этого, не стесняйтесь пропускать и внедрять свои собственные стили, добавляя tailwind в приложение react
в oredr для начала нам сначала нужно сделать макет сервера.
поэтому выйдите из папки react и запустите
npm init -y
затем
npm install -D typescript
Вы можете использовать свой собственный файл tsconfig или использовать этот, который работает для меня
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
"skipLibCheck": true,
"sourceMap": true,
"outDir": "./dist",
"moduleResolution": "node",
"removeComments": true,
"noImplicitAny": false,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"baseUrl": "."
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
затем добавьте скрипты в ваш package.json
"scripts": {
"watch": "tsc -w",
"start": "nodemon dist/index.js",
"test": "echo "Error: no test specified" && exit 1"
},
создайте каталог src и добавьте в него файл index.ts.
затем установите
npm install cors express body-parser nodemon socket.io
затем создайте простой сервер в файле index.ts
import express,{ Request,Response } from "express";
import { Server } from "socket.io";
import { createServer } from "http";
import { addUser, checkUserNameExists,removeUser} from './utils/usersutil';
import { makeTimeStamp } from './utils/utils';
// const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser')
const app = express();
const PORT = process.env.PORT||4000
const server = createServer(app);
// const httpServer = createServer(app);
var jsonParser = bodyParser.json()
// create application/x-www-form-urlencoded parser
// var urlencodedParser = bodyParser.urlencoded({ extended: false })
const io = new Server(server,{
cors: {
origin: "http://localhost:3000",
credentials: true,
allowedHeaders: ["my-custom-header"],
}
});
(async () => {
app.use(cors())
app.options('*', cors());
app.get('/', (req:Request, res:Response) => {
res.send({ message: "We did it!" })
});
app.get('/me', (req:Request, res:Response) => {
res.send({ message: "smoosh" })
});
app.post('/users',jsonParser,(req:Request, res:Response) => {
const user = req.body?.user.username
// //console.log("looking for ===== ",user)
const userExists = checkUserNameExists(user)
//console.log("looking for ===== ",user, userExists)
res.send({data:userExists})
});
io.on("connection", async(socket) => {
//console.log(`Client ${socket.id} connected`);
// Join a conversation
const { room,user } = socket.handshake.query;
const room_id = room as string
const user_count = addUser({id:socket.id,name:user,room})
// //console.log("room id / user==== ",room_id,user,user_count)
socket.join(room_id);
io.in(room_id).emit('room_data', {room,users:user_count});
const join_room_message={message:`${user} joined`, time:makeTimeStamp(), user:"server" }
io.in(room_id).emit('new_message_added', { user,newMessage:join_room_message});
socket.on('new_message', (newMessage) => {
//console.log("new message ",newMessage,room_id)
const user = newMessage.user
//@ts-ignore
io.in(room_id).emit('new_message_added', { user: user?.name,newMessage});
})
socket.on("disconnect", () => {
//console.log("User Disconnected new user count ====", socket.id,user_count);
removeUser(socket.id)
io.in(room_id).emit('room_data', {room: room_id,users:user_count - 1 });
const join_room_message={message:`${user} left`, time:makeTimeStamp(), user:"server" }
io.in(room_id).emit('new_message_added', { user,newMessage:join_room_message});
});
});
server.listen(PORT, () => {
console.log(`listening on http://localhost:${PORT}`)
});
})().catch(e=> console.log('error on server ====== ',e)
)
есть также файл вспомогательных функций пользователя
interface User{
id:string
name:string
room:string
}
const users:User[] = [];
const userExists=(users:User[],name:string)=>{
let status = false
for(let i = 0;i<users.length;i++){
if(users[i].name===name){
status = true;
break
}
}
return status;
}
//console.log("all users in list=== ",users)
export const addUser = ({id, name, room}) => {
name = name?.trim().toLowerCase();
room = room?.trim().toLowerCase();
//console.log("user to add ==== ",name)
const existingUser = userExists(users,name)
//console.log("existing user????====",existingUser)
if(existingUser) {
//console.log("existing user")
return users.length;
}else{
const user = {id,name,room};
//console.log("adding user === ",user)
users.push(user);
//console.log("all users === ",users)
const count = getUsersInRoom(room).length
return count
}
}
const userExistsIndex=(users:User[],id:string)=>{
let status = -1
for(let i = 0;i<users.length;i++){
if(users[i].id === id){
status = i;
break
}
}
return status;
}
export const checkUserNameExists=(name:string)=>{
let status = false
for(let i = 0;i<users.length;i++){
if(users[i].name === name){
status = true
break
}
}
return status;
}
export const removeUser = (id:string) => {
// const index = users.findIndex((user) => {
// user.id === id
// });
const index = userExistsIndex(users,id)
//console.log(index)
if(index !== -1) {
//console.log("user ",users[index].name ,"disconected , removing them")
return users.splice(index,1)[0];
}
}
export const getUser = (id:string) => users .find((user) => user.id === id);
export const getUsersInRoom = (room:string) => users.filter((user) => user.room === room);
export const userCount =()=>users.length
export const makeTimeStamp=()=>{
const hour = new Date(Date.now()).getHours()
const min = new Date(Date.now()).getMinutes()
const sec = new Date(Date.now()).getSeconds()
let mins= min+''
let secs=':'+sec
if(min<10){
mins = '0'+ min
}
if(sec<10){
secs = '0' + sec
}
return hour+':'+ mins + secs
}
Это обычный экспресс-сервер node js, который подключается и слушает сокетное соединение, затем он добавляет пользователя во временный список и возвращает имя комнаты и количество пользователей в ней,
Есть конечная точка post /users rest, которая обычно используется для аутентификации и добавления пользователя в базу данных, но в данном случае она просто проверяет, не используется ли уже указанное имя пользователя, что вызывает странные проблемы, когда два человека используют одинаковые имена в одной комнате.
Экземпляр socket io также будет слушать новые сообщения, отправляемые клиентами, и транслировать их всем в комнате.
Наконец, он прослушивает разъединения, в этом случае он удаляет имя пользователя из временного списка и сообщает об этом всем в комнате.
Чтобы следить за изменениями в папке index.ts, скомпилировать его в dist/index.js и запустить сервер .
используйте две команды ниже, каждую в своем экземпляре терминала
npm run watch
npm start
и вернемся к клиенту,
Помните, что мы пытаемся сделать это минимальным, поэтому
большая часть логики, связанной с состоянием, будет находиться во фронт-энде.
Для начала нам нужно убедиться, что у пользователя есть имя пользователя и комната, прежде чем он войдет в компоненты чата, и здесь мы используем reactcontext и localStorage.
Моя окончательная структура папок выглядит следующим образом
Общие типы будут выглядеть примерно так
export type Chat ={ newMessage:{message: string; time: string,user:string }};
export interface User{username:string ; room:string}
export interface Room{users:number ; room:string}
export interface Message{
message: string;
time: string;
user: string;
}
Внутри context.ts мы создаем UserContext
mport React from 'react';
import { User } from './../App';
const user_data = { user:{username:"",room:"general"}, updateUser:(user:User)=>{}}
const UserContext = React.createContext(user_data);
export default UserContext;
затем мы сделаем пользовательский хук для обработки клиента socketio
import { Room, User } from "./types"
import { useRef,useState,useEffect } from 'react';
import socketIOClient,{ Socket } from 'socket.io-client';
const NEW_MESSAGE_ADDAED = "new_message_added";
const ROOM_DATA = "room_data";
const devUrl="http://localhost:4000"
const useChats=(user:User)=>{
const socketRef = useRef<Socket>();
const [messages, setMessages] = useState<any>([]);
const [room, setRoom] = useState<Room>({users:0,room:""});
useEffect(() => {
socketRef.current = socketIOClient(devUrl, {
query: { room:user.room,user:user.username },
transports: ["websocket"],
withCredentials: true,
extraHeaders:{"my-custom-header": "abcd"}
})
socketRef.current?.on(NEW_MESSAGE_ADDAED, (msg:any) => {
// //console.log("new message added==== ",msg)
setMessages((prev: any) => [msg,...prev]);
});
socketRef.current?.on(ROOM_DATA, (msg:any) => {
//console.log("room data ==== ",msg)
setRoom(msg)});
return () => {socketRef.current?.disconnect()};
}, [])
const sendMessage = (message:any) => {
//console.log("sending message ..... === ",message)
socketRef.current?.emit("new_message", message)
};
return {room,messages,sendMessage}
}
export default useChats
Мы храним экземпляр сокета в useRef, потому что мы не хотим, чтобы он инициализировался заново при каждом повторном рендере, затем мы помещаем его в useEffect, чтобы инициализировать только при монтировании компонента, мы возвращаем новые сообщения, данные комнаты и функцию sendmessage.
Для того, чтобы сделать это минимальным, я избежал маршрутизации и просто сделал опциональный рендеринг, потому что у нас будет только два основных компонента, мы первоначально проверяем, есть ли пользователь в локальном хранилище и показываем компонент JoinRoom.tsx, если нет.
import { JoinRoom } from './components/JoinRoom';
import React from 'react'
import UserContext from "./utils/context";
import { Chats } from './components/Chats';
export interface User{username:string ; room:string}
let the_user:User
const user_room= localStorage.getItem("user-room");
if(user_room)
the_user = JSON.parse(user_room);
function App() {
const [user, setUser] = React.useState<User>(the_user);
const updateUser = (new_user:User) => {setUser(new_user)};
const user_exists = user && user.username !==""
return (
<div className="scroll-bar flex flex-col justify-between h-screen w-screen ">
<UserContext.Provider value ={{user,updateUser}}>
{user_exists?<Chats />:<JoinRoom/>}
</UserContext.Provider>
</div>
);
}
export default App;
для пользовательских классов tailwind добавьте это в index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
.flex-center{
@apply flex justify-center items-center
}
.flex-center-col{
@apply flex flex-col justify-center items-center
}
.scroll-bar{
@apply scrollbar-thin scrollbar-thumb-purple-900 scrollbar-track-gray-400
}
класс полосы прокрутки — это расширение tailwind, которое можно установить и добавить в конфигурацию tailwind
npm install -D tailwind-scrollbar
затем добавьте его в файл tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('tailwind-scrollbar'),
],
}
компонент загрузки
import React from 'react'
interface LoadingProps {
}
export const Loading: React.FC<LoadingProps> = ({}) => {
return (
<div className='h-full w-full flex-center bg-slate-300 text-lg'>
loading ...
</div>
);
}
JoinRoom.tsx, который будет показан, если пользователь не найден в локальном хранилище,
import React, {useContext, useState} from "react";
import UserContext from './../utils/context';
import axios from 'axios';
interface JoinRoomProps {
}
export const JoinRoom: React.FC<JoinRoomProps> = () => {
const [input, setInput] = useState({ username: "", room: "general" });
const [error, setError] = useState({ name:"", message:"" });
const devUrl="http://localhost:4000"
const client = axios.create({ baseURL:devUrl});
const user = useContext(UserContext);
//console.log("JoinRoom.tsx user ==== ",user.user)
const handleChange = async (e: any) => {
const { value } = e.target;
setInput({
...input,
[e.target.id]: value,
});
};
const handleSubmit = async (e: any) => {
//console.log("inputon submit ==== ",input)
e.preventDefault();
if(input.username !== ""){
const roomname = input.room.toLowerCase()
const username = input.username.toLowerCase()
const room_data = {username,room:roomname}
// localStorage.setItem("user-room",JSON.stringify(room_data));
// user.updateUser(room_data)
client.post('/users', {user:room_data})
.then( (response)=> {
const user_exist =response.data.data
//console.log("user exists? === ",user_exist)
if(!user_exist){
localStorage.setItem("user-room",JSON.stringify(room_data));
user.updateUser(room_data)
}
else{
setError({name:"username",message:"username exists"})
}
})
.catch(function (error) {
//console.log("error logging in === ",error)
setError({name:"username",message:"connection error"})
});
}
else{
setError({name:"username",message:"nick name needed"})
}
};
const isError=()=>{
if(error.name === "") return false
return true}
return (
<div className="h-full w-full flex-center-col bg-gradient-to-l from-cyan-900 to-purple-900">
<form className="w-[95%] md:w-[50%] p-3 bg-slate-700 rounded-lg text-white shadow-lg
shadow-purple-500 ">
<div className="flex-center-col">
<label className="text-lg font-bold">Join</label>
<input
style={{borderColor:isError()?"red":""}}
className="w-[80%] md:w-[80%] p-2 m-1 border-black border rounded-sm bg-black"
id="username"
placeholder="nick name"
onChange={handleChange}
value={input.username}
/>
<input
className="w-[80%] md:w-[80%] p-2 m-1 border-black border rounded-sm bg-black"
id="room"
placeholder="room name"
onChange={handleChange}
value={input.room}
/>
{isError()?<div className="text-md p-1m-1 text-red-300">{error.message}</div>:null}
<button
onClick={handleSubmit}
className="p-2 m-1 w-[30%] bg-purple-800 shadow-md
hover:shadow-purple-400 rounded-md">Join</button>
</div>
</form>
</div>
);
}
при отправке будет сделан post запрос, который проверит, используется ли имя пользователя в настоящее время, и добавит пользователя в локальное хранилище и контекст пользователя, если нет,
и наконец, для компонента чатов
сначала компонент панели инструментов
import React from 'react'
import {BiExit} from 'react-icons/bi'
import { User } from './../App';
interface ToolbarProps {
room:any
updateUser: (user: User) => void
}
export const Toolbar: React.FC<ToolbarProps> = ({room,updateUser}) => {
return (
<div className='bg-slate-600 text-white p-1 w-full flex justify-between items-center h-12'>
<div className='p-2 m-1 text-xl font-bold flex'>
<div className='p-2 text-xl font-bold '> {room?.room}</div>
{room.room?<div className='p-1 m-1 text-base font-normal shadow shadow-white hover:shadow-red-400
flex-center cursor-pointer'
onClick={()=>{
localStorage.removeItem('user-room') ;
updateUser({username:"",room:""})
}}><BiExit/></div>:null}
</div>
<div className='p-2 m-1 font-bold'>{room?.users}{room?.users?" online ":""}</div>
</div>
);
}
отвечает за отображение названия комнаты, количества пользователей в комнате,
в нем также есть кнопка покинуть комнату, которая устанавливает пользователя в ноль, вызывая переключение на компонент joinroom.
Chats.tsx
import React, { useState, useRef,useEffect,useContext } from "react";
import {AiOutlineSend } from 'react-icons/ai';
import { IconContext } from "react-icons";
import { makeTimeStamp } from './../utils/utils';
import { Toolbar } from './Toolbar';
import { Chat, Message } from './../utils/types';
import UserContext from "../utils/context";
import useChats from "../utils/useChats";
import { Loading } from './Loading';
interface ChatsProps {
}
export const Chats: React.FC<ChatsProps> = ({}) => {
const user = useContext(UserContext);
//console.log("Text.tsx user ==== ",user.user)
const {room,messages,sendMessage} = useChats(user.user)
const room_loaded = room.users>0 && user.user.username !== ""
console.log("Text.tsx room ==== ",room)
const [input, setInput] = useState({ message: "", time:makeTimeStamp() });
const [error, setError] = useState({ name:"", message:"" });
const [size, setSize] = useState({x: window.innerWidth,y: window.innerHeight});
const updateSize = () =>setSize({x: window.innerWidth,y: window.innerHeight });
useEffect(() => {
window.onresize = updateSize
})
const handleChange = async (e: any) => {
const { value } = e.target;
setInput({
...input,
[e.target.id]: value,
});
};
const handleSubmit = async (e: any) => {
e.preventDefault();
// //console.log("submit in form ====",input.message)
if (input.message !== "" && user.user.username !=="") {
const message = { message: input.message, time:input.time,user:user.user.username };
// //console.log("message ====",message)
sendMessage(message)
setError({name:"",message:""})
}else{
//console.log("error ====",input,user)
setError({name:"username",message:"type something"})
}
};
const isError=()=>{
if(error.name === "") return false
return true}
if(!room_loaded){
return <Loading/>
}
return (
<div
style={{maxHeight:size.y}}
className="h-full overflow-x-hidden overflow-y-hiddenflex flex-col justify-between ">
<div className="fixed top-[0px] w-[100%] z-60">
<Toolbar room={room} updateUser={user.updateUser}/>
</div>
{/* <div className="fixed top-[10%] right-[40%] p-1 z-60 text-3xl font-bold">{size.y}</div> */}
<div
className="mt-10 w-full h-[55vh] md:h-[80vh] flex flex-col-reverse items-center overflow-y-scroll p-2 scroll-bar"
>
{messages &&
messages.map((chat: Chat, index: number) => {
return <Chatcard key={index} chat={chat} user={user.user}/>;
})}
</div>
<form
onSubmit={handleSubmit}
className="w-full p-1 fixed bottom-1 ">
<div className="flex-center">
<input
style={{borderColor:isError()?"red":""}}
className="w-[80%] md:w-[50%] p-2 m-1 border-black border-2 rounded-sm "
id="message"
placeholder="type.."
onChange={handleChange}
value={input.message}
autoComplete={"off"}
/>
<button type="submit">
<IconContext.Provider value={{
size: "30px",
className: "mx-1",
}}>
<AiOutlineSend /></IconContext.Provider>
</button>
</div>
</form>
</div>
);
};
interface ChatCardProps {
chat: Chat;
user: { username: string; room: string;}
}
export const Chatcard: React.FC<ChatCardProps> = ({ chat,user }) => {
const isMe = chat.newMessage.user === user.username
// //console.log("chat in chat card ==== ",chat)
return (
<div className="flex-center w-full m-2">
<div
style={{backgroundColor:isMe?"purple":"white",color:isMe?"white":""}}
className="capitalize p-5 h-6 w-6 text-xl font-bold mr-1 border-2 border-slate-400
rounded-[50%] flex-center"> {chat?.newMessage.user[0]}</div>
<div className="w-[80%] h-full border border-slate-800 rounded-md
m-1 p-2 flex justify-between items-center">
<div className="max-w-[80%] h-fit break-words whitespace-normal text-mdfont-normal">
{chat?.newMessage.message}
</div>
<div className="w-fit font-medium h-full flex flex-col justify-end items-stretch text-sm ">
<div className="w-full ">{chat?.newMessage.user}</div>
<div className="w-full ">{chat?.newMessage.time}</div>
</div>
</div>
</div>
);
};
и это все.
На мой взгляд, подписка на graphql выглядит более аккуратно и дает больше контроля над подключением клиентов, так что если кто-то знает, как заставить это работать на heroku, я буду рад услышать от вас.
Во время написания этой статьи я уже заметил возможную ошибку, которая может привести к столкновению имен пользователей.
Если пользователь успешно присоединился к серверу с именем пользователя «john», а затем вышел и закрыл браузер, отключившись от сервера, если он вернется и другой пользователь присоединился к серверу с именем пользователя john, клиент получит значение локального хранилища, которое является john, и присоединится к комнате с другим пользователем в комнате с тем же именем пользователя.
Я позволю вам решить эту проблему, пожалуйста, отправьте мне запрос на исправление, если вы это сделаете или найдете какую-либо другую ошибку.
окончательный код клиента
финальный код сервера
код react native