You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React客户端重复连接Socket.io服务器问题及玩家分配咨询

问题描述

连接React客户端或刷新浏览器时,Socket服务器的io.on('connection')会触发两次。已尝试关闭Strict Mode、清理useEffect、重新编译,但问题仍未解决。请问是否可以通过这两次连接手动分配player1和player2?

server/index.ts

import express from 'express';
import { createServer } from "http";
import { Server } from 'socket.io';

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: {
    origin: "http://localhost:5173",
  }
});

type GameStateType = [string, string, string, string, string, string, string, string, string];

let isXTurn: Boolean;
let gameState:GameStateType = ['','','','','','','','',''];
const players = {};
const victoryConditions = [
  [0, 1, 2],
  [3, 4, 5],
  [6, 7, 8],
  [0, 3, 6],
  [1, 4, 7],
  [2, 5, 8],
  [0, 4, 8],
  [2, 4, 6]
];

io.on('connection', (socket) => {
  isXTurn = true;
  let gameOver = false;
  gameState = ['','','','','','','','',''];

  console.log(`用户已连接,ID: ${socket.id}`);

  console.log(socket.rooms); // Set { <socket.id> }
  socket.join("room1");
  console.log(socket.rooms);

  if (!players[socket.id]) {
    players[socket.id] = socket.id;
    console.log(players);
  }

  socket.on('tileClicked', (id) => {
    gameState[id] = isXTurn ? 'X' : 'O';
    for (let i = 0; i < victoryConditions.length; i++) {
      const condition = victoryConditions[i];
      if (gameState[condition[0]] === gameState[condition[1]] && gameState[condition[1]] === gameState[condition[2]] && gameState[condition[0]] !== '') {
        console.log('游戏结束');
        socket.broadcast.emit('gameOver', true);
        gameOver = true;
      }
    }
    console.log(id);
    console.log(isXTurn);
    console.log('游戏结束?'+gameOver);
    socket.emit('turnChange', isXTurn);
    socket.broadcast.emit('turnChange', isXTurn);
    if (gameOver) {
      isXTurn = undefined;
    } else {
      isXTurn = !isXTurn;
    }
  });
});

httpServer.listen(3000, () => {
  console.log('服务器运行在 http://localhost:3000');
});

client/TicTacToe.ts

import { useEffect, useState } from "react"
import Board from "./Board"
import GameOver from "./GameOver"
import Reset from "./Reset"
import { io } from "socket.io-client"

const socket = io("http://localhost:3000")


const TicTacToe = () => {
    const [gameOver, setGameOver] = useState(false);
    const [xTurn, setXTurn] = useState<boolean | undefined>();
    const [letterIcon, setLetterIcon] = useState('fa-solid fa-x fa-5x');
    const [isClicked, setIsClicked] = useState(false);
    const [winner, setWinner] = useState<string | null>(null);


    useEffect(() => {
        if (gameOver && winner === null) {
            setWinner(!xTurn ? 'X' : 'O');
        }
    }, [gameOver, xTurn, winner]);

    useEffect(() => {
        const handleGameOver = (arg: boolean) => {
            setGameOver(arg);
            console.log("游戏结束")
        }

        socket.on("gameOver", handleGameOver);
        return () => { socket.off("gameOver", handleGameOver) }  
    }, []);

    useEffect(() => {
        const handleTurnChange = (arg: boolean | undefined) => {
            console.log("切换回合")
            console.log(arg)
            setXTurn(arg);
            if (arg === null && gameOver) {
                setLetterIcon('hidden');
            }
            if (isClicked && arg !== undefined && !gameOver) {
                setLetterIcon(!arg ? 'fa-solid fa-x fa-5x' : 'fa-solid fa-o fa-5x');
            } else {
                setLetterIcon('hidden');
            }
        }
        socket.on("turnChange", handleTurnChange);
        return () => { socket.off("turnChange", handleTurnChange) }
    }, [gameOver, isClicked]);

    return (
    <div className="ticTacToe">
        <h1>井字棋</h1>
        <GameOver isGameOver={gameOver} gameWinner={winner}/>
        <Reset />
        <Board letterIcon={letterIcon} setIsClicked={setIsClicked} gameOver={gameOver}/>
    </div>
    )
}

export default TicTacToe

client/TicTacToeContext.tsx

import {createContext, useState} from 'react';
import { TicTacToeContextProps } from '../types/tictactoe';
import { io } from 'socket.io-client';


const socket = io("http://localhost:3000")

export const TicTacToeContext = createContext({
    isXTurn: true,
})


export const TicTacToeContextProvider: React.FC<TicTacToeContextProps> = (props) => {
    const [isXTurn, setIsXTurn] = useState(true)

    socket.on('changeTurn', (isPlayerTurn) => {
        setIsXTurn(isPlayerTurn);
    })

    return (
        <TicTacToeContext.Provider value={{ isXTurn }}>
            {props.children}
        </TicTacToeContext.Provider>
    );
}

client/Tile.tsx

import { TileProps } from "../types/tictactoe"

const Tile = ({ id, letterIcon, handleClick, children }: TileProps) => {
    
    return (
        <div id={String(id)} className={'tile'} onClick={handleClick}>
            <i className={ letterIcon }></i>
            {children}
        </div>
    )

}

export default Tile

client/Board.tsx

import { useState } from 'react';
import Tile from './Tile';
import { io } from 'socket.io-client';
import { BoardTypes } from '../types/tictactoe';
const socket = io('http://localhost:3000')


const Board:React.FC<BoardTypes> = ({letterIcon, setIsClicked, gameOver}) => {
    const [tileStates, setTileStates] = useState(Array(9).fill(null));
    
    const handleTileClick = (index: number) => {
        const newTileStates = [...tileStates];
        const allNull = tileStates.every(element => element === null);
        console.log('点击格子')
        console.log('已点击?'+isClicked)
        console.log('全为空?'+allNull)
        if (!newTileStates[index] || allNull) {
            !gameOver && setIsClicked(true);
            newTileStates[index] = letterIcon;
            setTileStates(newTileStates);
            socket.emit('tileClicked', index);
            console.log(index)
        }
    }

    return (
        <>
            <div className="board">
                {tileStates.map((tileLetterIcon, index) => (
                    <Tile letterIcon={tileLetterIcon} key={index} handleClick={() => handleTileClick(index)} />
                ))}
            </div>
        </>
    );
}
export default Board;
回答

首先,两次连接的核心原因是客户端重复创建Socket实例:你在TicTacToe.ts、TicTacToeContext.tsx、Board.tsx中分别创建了独立的socket实例,每个实例都会触发一次服务器的connection事件,导致多重复连接。先修复这个问题,再处理玩家分配。

第一步:修复重复连接

  1. 创建全局单例Socket:在客户端新建client/socket.ts文件,统一管理Socket实例:
import { io } from 'socket.io-client';
export const socket = io("http://localhost:3000");
  1. 在所有需要使用Socket的组件中导入这个单例,替换原有独立创建的代码:
// 比如TicTacToe.ts、Board.tsx中替换为
import { socket } from './socket';
  1. 移除无效监听:比如TicTacToeContext.tsx中的changeTurn事件,服务器并未发送该事件,属于无效逻辑。

第二步:手动分配Player1和Player2

修复重复连接后,服务器每次客户端连接只会触发一次connection,此时可通过房间内玩家数量分配角色:

// 修改server/index.ts的connection逻辑
io.on('connection', (socket) => {
  // 仅当房间为空时初始化游戏状态,避免新玩家重置现有游戏
  const roomPlayers = io.sockets.adapter.rooms.get("room1");
  if (!roomPlayers || roomPlayers.size === 0) {
    isXTurn = true;
    gameState = ['','','','','','','','',''];
  }

  console.log(`用户已连接,ID: ${socket.id}`);
  socket.join("room1");
  const currentPlayerCount = io.sockets.adapter.rooms.get("room1")?.size || 0;

  // 分配角色
  let playerRole = '';
  if (currentPlayerCount === 1) {
    playerRole = 'X'; // Player1
    players[socket.id] = { id: socket.id, role: 'X' };
  } else if (currentPlayerCount === 2) {
    playerRole = 'O'; // Player2
    players[socket.id] = { id: socket.id, role: 'O' };
  } else {
    playerRole = 'spectator'; // 超过2人设为观众
    players[socket.id] = { id: socket.id, role: 'spectator' };
  }

  console.log(`玩家 ${socket.id} 角色: ${playerRole}`);
  socket.emit('assignRole', playerRole); // 通知客户端角色

  // 原有tileClicked等逻辑保持不变...
});

客户端接收角色并处理:

// 在TicTacToe.ts中添加角色监听
useEffect(() => {
  const handleAssignRole = (role: string) => {
    if (role === 'X') {
      setLetterIcon('fa-solid fa-x fa-5x');
      setXTurn(true);
    } else if (role === 'O') {
      setLetterIcon('fa-solid fa-o fa-5x');
      setXTurn(false);
    }
  };
  socket.on('assignRole', handleAssignRole);
  return () => socket.off('assignRole', handleAssignRole);
}, []);

额外优化建议

  • 服务器的isXTurn、gameState是全局变量,多房间场景下会冲突,建议将游戏状态绑定到房间,比如用对象存储每个房间的状态:const roomGames = { room1: { isXTurn: true, gameState: [...], gameOver: false } }
  • 调整客户端useEffect依赖项,避免重复绑定监听,比如gameOver监听不需要依赖gameOver状态

内容的提问来源于stack exchange,提问作者gpk96gpk

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.06 21:45:55