Socket.IO 用户重复加入问题的根源与解决方案

2次阅读

Socket.IO 用户重复加入问题的根源与解决方案

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 用户重复加入问题,同时保持代码可维护性与跨环境一致性。

text=ZqhQzanResources