Socket.io房间内客户端事件单次触发防重复调用方案
问题背景
我是Socket.io新手,尚未完全掌握服务端与客户端的正确用法,目前在客户端侧编写了监听服务端消息、获取问题数组并在房间内展示的逻辑。
业务场景为搭建支持多客户端接入的问答房间:需要仅触发一次逻辑,就将展示内容同步推送给房间内所有用户。由于涉及近百行前端渲染逻辑,这部分展示代码必须放在客户端实现,同时需要服务端下发问题数组数据。
核心问题:触发逻辑写在客户端时,事件触发总次数等于房间内的客户端数量,需要实现该事件全局仅触发一次。
现有代码
客户端监听新问题事件
// 已做精简,保留核心逻辑 socket.on('getNewQuestion', (currentQuestion) => { init(); questionNumber.innerHTML = "Question " + countQuestion; questionParams.id = currentQuestion.questionId; questionParams.questionTxt = currentQuestion.question; questionParams.reponse = JSON.parse(currentQuestion.reponse); questionParams.lien = currentQuestion.lien; questionParams.type = currentQuestion.type; // 展示问题文本 questionTxt.innerHTML = currentQuestion.question; // 根据问题类型展示对应内容 switch (currentQuestion.type) { case "enigme": var getReponseLength = JSON.parse(currentQuestion.reponse)[0].length; questionType.innerHTML = "find the answer to this question with " + getReponseLength + " letters"; break; case "anagramme": questionType.innerHTML = "find a word usign all these letters :"; const html = []; // 字符串转字符数组 var questionParse = JSON.parse(currentQuestion.question); // 打乱字符顺序 for (let i = questionParse.length - 1; i >= 0; i--) { const randLetters = Math.floor(Math.random() * (i + 1)); [questionParse[i], questionParse[randLetters]] = [questionParse[randLetters], questionParse[i]]; html.push(`<div>${questionParse[i]}</div>`);//展示单个字符 } currentQuestion.question = questionParse; questionTxt.innerHTML = html.join(" "); break; } countQuestion++; });
服务端监听取题事件
socket.on('callGetNewQuestion', (roomCode) => { // 从对应房间的题库中随机取一道题 const questionList = questions.getQuestionsList(roomCode); const questionCalled = questionList[Math.floor(Math.random() * questionList.length)]; io.in(roomCode).emit('getNewQuestion', questionCalled); // 将随机题目推送给房间内所有客户端 questions.removeQuestion(questionCalled.questionId); // 移除已出题目避免重复出现 });
客户端游戏开始触发逻辑
socket.on('startGame', () => { socket.emit('callGetNewQuestion', userParams.roomCode); timerCountdownGame(); });
问题现象
由于触发逻辑在客户端执行,房间内有2个客户端时取题请求会触发2次,有3个客户端时会触发3次,导致游戏开始时直接跳过对应数量的问题,例如3个客户端时会直接从第1题跳到第3题。
问题根因
所有客户端收到startGame事件后,都会独立向服务端发送一次取题请求,服务端每收到一次请求就会抽取一道新题并从题库移除,最终导致重复抽题、跳题。
修复方案
优先选择第一种方案,符合多人实时应用的开发规范,从根源避免重复触发问题。
方案1:由服务端统一控制抽题触发时机,移除客户端主动取题逻辑
多人实时游戏的核心状态流转必须由服务端掌控,客户端只负责接收事件、渲染内容、上报用户操作,不要让客户端决定核心逻辑的触发时机。
调整逻辑:服务端判定游戏满足开始条件(比如房主点击开始、房间人数达标)时,自行执行抽题逻辑,抽题完成后直接给全房间推送题目,不需要等待客户端发送取题请求。
调整后的服务端游戏开始逻辑示例:// 服务端触发游戏开始的逻辑,对应房主点击开始、人数满足要求等场景 function handleGameStart(roomCode) { io.in(roomCode).emit('startGame'); // 通知所有客户端游戏开始 // 服务端直接完成抽题、推送,无需客户端请求 const questionList = questions.getQuestionsList(roomCode); const questionCalled = questionList[Math.floor(Math.random() * questionList.length)]; io.in(roomCode).emit('getNewQuestion', questionCalled); questions.removeQuestion(questionCalled.questionId); }客户端侧直接删除
startGame事件监听里的socket.emit('callGetNewQuestion', userParams.roomCode)代码,仅保留倒计时启动逻辑即可。方案2:若必须保留客户端触发逻辑,为取题事件增加幂等锁
在服务端为每个房间维护取题锁标记,收到第一个callGetNewQuestion请求时先加锁,短时间内同房间发来的重复请求直接忽略,等题目推送完成后再解锁,允许下一次取题。
实现示例:// 服务端维护每个房间的取题锁状态 const roomFetchQuestionLock = new Map(); socket.on('callGetNewQuestion', (roomCode) => { // 锁已存在说明正在处理取题请求,直接拦截重复请求 if (roomFetchQuestionLock.get(roomCode)) return; // 加锁 roomFetchQuestionLock.set(roomCode, true); const questionList = questions.getQuestionsList(roomCode); const questionCalled = questionList[Math.floor(Math.random() * questionList.length)]; io.in(roomCode).emit('getNewQuestion', questionCalled); questions.removeQuestion(questionCalled.questionId); // 推送完成后延迟1秒解锁,避免正常的下一题请求被拦截 setTimeout(() => { roomFetchQuestionLock.delete(roomCode); }, 1000); });
额外注意:现有代码中变位词(anagramme)的字母打乱逻辑在客户端执行,每个客户端的随机结果独立,会导致不同用户看到的乱序字母不一致,不符合同步展示要求。这部分打乱逻辑需要移到服务端抽题环节执行,把打乱完成的字符数组随问题一起下发给所有客户端,保证所有用户看到的内容完全一致。
内容的提问来源于stack exchange,提问作者chubby marschmallow

