
React 应用中使用 Socket.IO 时,用户在房间内被重复添加(同名不同 socketId),根本原因常是 React 18 严格模式下 useEffect 的开发期双调用机制导致 socket 连接与 JOIN 事件被触发两次。
react 应用中使用 socket.io 时,用户在房间内被重复添加(同名不同 socketid),根本原因常是 react 18 严格模式下 `useeffect` 的开发期双调用机制导致 socket 连接与 join 事件被触发两次。
在基于 React Hooks + Socket.IO 的实时协作应用(如在线代码编辑器)中,你可能会观察到:服务端 userSocketMap 正常映射,但客户端 clients 数组却出现同一用户名、两个不同 socketId 的条目。例如控制台输出类似:
client [ { socketId: "abc123", username: "Alice" }, { socketId: "def456", username: "Alice" } ]
这并非服务端逻辑错误(如重复 socket.join() 或误发 JOINED),而是一个典型的客户端副作用管理陷阱。
? 根本原因:React 18 严格模式的开发期防护机制
自 React 18 起,<React.StrictMode> 在开发环境中会故意对 useEffect、useMemo、useState 初始化器等进行双次调用,用于提前暴露不纯副作用(如未清理的定时器、重复订阅、全局状态污染)。虽然这一机制对大多数场景透明,但它会直接影响 Socket.IO 的初始化流程:
- useEffect(() => { init(); }, []) 在开发模式下执行 两次;
- 每次执行都会:
- 创建一个新 socket 实例(initSocket());
- 绑定 ACTIONS.JOIN 事件;
- 发送一次 JOIN 请求;
- 导致服务端收到两条 JOIN 指令 → 为同一用户分配两个 socket ID → 客户端接收两次 JOINED 事件 → setclients(clients) 被覆盖为包含重复项的数组。
⚠️ 注意:此行为仅发生在开发环境(npm start),生产构建(npm run build && serve -s build)中不会出现。
✅ 正确解决方案:防抖式连接 + 单例 socket 管理
1. 禁用 StrictMode(临时验证,不推荐长期使用)
在 src/index.js 或 src/main.jsx 中移除 <StrictMode> 包裹,可立即验证是否为该问题:
// ❌ 不推荐长期使用:仅用于快速验证 root.render( // <React.StrictMode> <App /> // </React.StrictMode> );
若禁用后重复问题消失,则确认是 StrictMode 所致 —— 但应转向更健壮的修复方式。
2. 推荐方案:Socket 实例单例化 + useEffect 防重入
修改 initSocket 工具函数,确保全局唯一 socket 实例,并在组件内通过闭包或 ref 控制连接时机:
// src/socket.js import { io } from 'socket.io-client'; let socketInstance = null; export const initSocket = () => { if (socketInstance) return socketInstance; socketInstance = io('http://localhost:3001', { reconnectionAttempts: 3, timeout: 10000, }); return socketInstance; }; export const disconnectSocket = () => { if (socketInstance) { socketInstance.disconnect(); socketInstance = null; } };
然后在组件中使用 useRef 确保 useEffect 内部只建立一次连接:
useEffect(() => { // ✅ 使用 ref 防止 StrictMode 双初始化 const isMounted = { current: true }; const init = async () => { const socket = initSocket(); socketRef.current = socket; socket.on('connect_error', handleErrors); socket.on('connect_failed', handleErrors); // 关键:仅在首次挂载时发送 JOIN if (isMounted.current) { socket.emit(ACTIONS.JOIN, { roomId, username: location.state?.username, }); } socket.on(ACTIONS.JOINED, ({ clients, username, socketId }) => { if (username !== location.state?.username) { toast.success(`${username} joined the room`); } else { setUsernName(username); } setclients(clients); // 安全更新 }); socket.on(ACTIONS.DISCONNECTED, ({ socketId, username }) => { toast.success(`${username} left the room`); setclients((prev) => prev.filter((c) => c.socketId !== socketId)); }); }; init(); return () => { isMounted.current = false; // 清理监听器(非 disconnect!由 socket 管理层统一处理) socketRef.current?.off(ACTIONS.JOINED); socketRef.current?.off(ACTIONS.DISCONNECTED); // ❌ 不在此处 disconnect —— 避免多组件竞争 }; }, []);
? 提示:disconnectSocket() 应在路由跳转或登出时显式调用(如 useEffect 在 App 或 AuthProvider 中监听 auth 状态),而非每个页面组件卸载时调用,否则会导致其他页面 socket 失效。
3. 服务端增强:防御性校验(可选)
尽管客户端已解决,服务端增加轻量校验可提升鲁棒性:
// server.js socket.on(ACTIONS.JOIN, ({ roomId, username }) => { // 防止同一用户多次 JOIN 当前房间(开发期容错) const existingSocketId = Object.keys(userSocketMap).find( id => userSocketMap[id] === username && io.sockets.adapter.rooms.get(roomId)?.has(id) ); if (existingSocketId) { console.warn(`User ${username} already joined ${roomId} via ${existingSocketId}`); return; // 忽略重复 JOIN } userSocketMap[socket.id] = username; socket.join(roomId); // ... rest unchanged });
? 总结与最佳实践
- ✅ 永远将 Socket 实例抽象为单例,避免组件内多次 initSocket();
- ✅ 利用 useRef + isMounted 模式规避 StrictMode 副作用双触发;
- ✅ 不在 useEffect cleanup 中调用 socket.disconnect(),应由应用生命周期统一管理;
- ✅ 开发期遇到“奇怪的重复行为”,优先检查 StrictMode 影响;
- ✅ 生产环境无需担心此问题 —— React 仅在开发模式启用双渲染。
遵循以上模式,即可彻底解决 Socket.IO 用户重复加入问题,同时保持代码可维护性与跨环境一致性。