Socket.IO新用户加入房间时为单用户生成2个socket ID问题排查
React协同应用Socket.IO单用户生成2个Socket ID问题排查
问题现象
开发基于React的实时协同应用时出现异常:新用户加入房间时,Socket.IO会为单个用户生成2个独立的socket ID,代码参照官方文档编写,暂未定位问题根因,相关代码片段如下。
服务端代码(server.js)
const cors = require('cors'); const axios = require('axios'); const {Server} = require('socket.io'); const http = require('http'); // *注意:贴出的原始代码漏了express引入,运行会直接报错,记得补上 const express = require('express'); const ACTIONS = require('../src/Actions'); const app = express(); // Create an instance of express const server = http.createServer(app) // Create an instance of http server const io = new Server(server); // Create an instance of socket.io server // Storing a client list const clients = new Map(); // Switching on the server socket to listen for connections io.on('connection', (socket) => { const clientSocketId = socket.id; console.log(clientSocketId+' connected'); socket.on(ACTIONS.JOIN,({roomId,username})=>{ console.log(roomId,username) clients.set(socket.id,{ roomId, username, socketId: socket.id, }) socket.join(roomId); const clientlist = Array.from(clients.values()) clientlist.forEach(client=>{ io.to(client.socketId).emit(ACTIONS.JOINED,{ clientlist, username, socketId: socket.id, }) }) }) // The server is listening to two events Code Change and Code Sync // Code Change is emitted when the user changes the code // Code Sync is called when the user joins the room to sync the previously typed code socket.on(ACTIONS.CODE_CHANGE, ({ roomId, code }) => { socket.in(roomId).emit(ACTIONS.CODE_CHANGE, { code }); }); socket.on(ACTIONS.SYNC_CODE, ({ socketId, code }) => { io.to(socketId).emit(ACTIONS.CODE_CHANGE, { code }); }); // Disconnecting the current socket socket.on('disconnecting',()=>{ console.log(clientSocketId+' disconnected') // Getting the list of all the present rooms const rooms = Object.keys(socket.rooms); rooms.forEach(roomId=>{ socket.in(roomId).emit(ACTIONS.DISCONNECTED,{ socketId: socket.id, username: clients.get(socket.id).username, }) }) clients.delete(socket.id); socket.leave(); }) }) const PORT = process.env.SERVER_PORT || 5000; server.listen(PORT,()=>{console.log('Listening on '+PORT)});
客户端Socket初始化代码
export const initSocket = async () => { const options = { transports: ['websocket'], reconnection: true, reconnectionAttempts: 'Infinity', forceNew: true, reconnectionDelay: 1000, reconnectionDelayMax: 5000, timeout: 10000, autoConnect: true, secure: true, } const socket = io(process.env.REACT_APP_SERVER_URL,options) return socket }
Dashboard.js中useEffect初始化逻辑
React.useEffect(()=>{ // As the user joins the room we initialize the client socket which connects to the server const init = async () => { socketRef.current = await initSocket(); // Handling connection errors socketRef.current.on('connect_error',(err)=>handleError(err)) socketRef.current.on('connect_failed',(err)=>handleError(err)) const handleError = (err)=>{ console.log(err) toast({ title: 'Error connecting to the server', status: 'error', duration: 9000, isClosable: true, }) reactNavigater('/') } socketRef.current.emit(ACTIONS.JOIN,{ roomId: roomId, username: location.state?.username, }); // Listening for joined event when a even user joins socketRef.current.on(ACTIONS.JOINED,({clientlist,username,socketId})=>{ if(username !== location.state?.username){ toast({ title: `${username} has joined the room`, status: 'success', duration: 9000, isClosable: true, }) } setClientlist(clientlist) socketRef.current.emit(ACTIONS.SYNC_CODE, { socketId: socketRef.current.id, code: codeRef.current, }); }) // Listening for disconnected event when a even user disconnects socketRef.current.on(ACTIONS.DISCONNECTED,({socketId,username})=>{ toast({ title: `${username} has disconnected`, status: 'warning', duration: 9000, isClosable: true, }) // Filter the clientlist to remove the disconnected client setClientlist(Clientlist.filter(client=>client.socketId !== socketId)) } ) } init() // Here we have multiple listeners, so we have to remove them when the component unmounts return ()=>{ if(socketRef.current){ socketRef.current.disconnect() socketRef.current.off(ACTIONS.JOINED) socketRef.current.off(ACTIONS.DISCONNECTED) } } },[])
排查思路与修复方案
按优先级从高到低排查:
- 优先排查React 18严格模式导致的重复连接
这是90%同类问题的根因。React 18+开发环境下,严格模式会对组件执行「挂载→卸载→重新挂载」的重复渲染流程,虽然写了effect清理逻辑,但第一次初始化的socket断开请求存在网络延迟,服务端还没收到断开事件,第二次effect已经创建了新的socket连接,就会出现单用户两个socket ID的情况。
验证方式:临时移除src/index.js中根组件的<React.StrictMode>包裹,重启开发服务测试,如果单用户只生成1个socket ID即可确认。
修复不需要关闭严格模式,给socket初始化加单例缓存即可,保证全局只存在一个socket实例:let socketInstance = null; export const initSocket = async () => { if(socketInstance?.connected) return socketInstance; const options = { transports: ['websocket'], reconnection: true, reconnectionAttempts: 'Infinity', forceNew: false, // 关闭强制新建连接,配合单例使用 reconnectionDelay: 1000, reconnectionDelayMax: 5000, timeout: 10000, autoConnect: true, secure: true, } socketInstance = io(process.env.REACT_APP_SERVER_URL,options) return socketInstance } - 修复服务端断开逻辑的空指针问题
现有disconnecting事件中直接取clients.get(socket.id).username,如果socket建立连接后还没触发JOIN事件就断开,这里会拿到undefined,直接访问属性会抛错,导致后续clients.delete(socket.id)不执行,残留无效socket记录。修复后代码:socket.on('disconnecting',()=>{ console.log(clientSocketId+' disconnected') const rooms = Object.keys(socket.rooms); const clientInfo = clients.get(socket.id) rooms.forEach(roomId=>{ if(clientInfo){ socket.in(roomId).emit(ACTIONS.DISCONNECTED,{ socketId: socket.id, username: clientInfo.username, }) } }) clients.delete(socket.id); socket.leave(); }) - 调整客户端事件绑定与JOIN触发时机
现有代码中handleError定义在事件绑定语句之后,虽然JS函数声明存在提升,但容易出现引用失效导致连接错误后无限重试、生成新连接。另外不要在socket初始化完成后立刻发JOIN事件,要等连接真正建立后再发,避免连接未就绪触发底层重连:- 把
handleError定义移到事件绑定之前 - 把JOIN事件的触发放到socket的
connect事件回调中:
socketRef.current.on('connect', () => { socketRef.current.emit(ACTIONS.JOIN,{ roomId: roomId, username: location.state?.username, }); }) - 把
- 补全CORS配置
现有服务端只引入了cors中间件但没有挂载,也没有给Socket.IO配置跨域允许规则,会导致部分浏览器环境下连接被拦截,触发自动重连生成新ID。补上对应配置:// 挂载express cors中间件 app.use(cors()); // Socket.IO 配置跨域 const io = new Server(server, { cors: { origin: process.env.CLIENT_URL || "http://localhost:3000", credentials: true } });
内容的提问来源于stack exchange,提问作者archie09
相关产品推荐
相关产品推荐

