React useEffect仅首次正常工作、SPA重定向后失效问题排查
React useEffect 路由重定向后仅触发一次异常问题
问题现象
- 首次加载
GameComponent进入游戏房间时,所有逻辑运行正常 - SPA路由重定向返回主页后,再次进入相同游戏流程,useEffect仅触发一次后不再生效
- 已验证服务端可正常发送对应数据,返回的
isGameReady为标准布尔值,可正常在true/false间切换 - 已尝试将
isReady、gameStep加入useEffect依赖数组,问题未解决
问题代码
完整组件代码
const GameComponent = function(props) { const [selectedHand, setSelectedHand] = useState('random') const [isGameReady, setIsGameReady] = useState(false) const [gameStep, setGameStep] = useState(0) let listenersStarted = useRef(false) function currentSelector(e){ setSelectedHand(e.currentTarget.getAttribute('value')); } function getSelectedHand(value) { if (value === selectedHand) { return "box3 selected"; } return "box3"; } useEffect(() => { const serverInteractions = { sendChoice: ()=>{ props.server.emit("choice", selectedHand) }, startListeners: ()=>{ props.server.emit("game status") if (!listenersStarted.current){ if (!props.server.hasListeners('game status')){ props.server.on('game status', async args=>{ await new Promise(r => setTimeout(r, 250)); document.getElementById('instruction').textContent = args['status'] setIsGameReady(args['isReady']) }) props.server.on('game result', async ()=>{ await new Promise(r => setTimeout(r, 250)); }) } listenersStarted.current = true } } } serverInteractions.startListeners() }, []); useEffect(() => { console.log(isGameReady, gameStep) if (isGameReady && gameStep===0){ setGameStep(1) }else if(!isGameReady && gameStep>0){ setGameStep(0) } }, [isGameReady]); useEffect( () => { const asyncFunction = async ()=>{ if(!isGameReady && gameStep >0){ await setGameStep(0) }else if (isGameReady){ console.log(gameStep, isGameReady) if (isGameReady && gameStep === 1){ document.getElementById('instruction').textContent = "" const oldGameStep = gameStep await new Promise(r => setTimeout(r, 5000)); if (isGameReady && oldGameStep == gameStep){ await setGameStep(2) } }else if(isGameReady && gameStep === 2){ console.log('mandando resultado') //await serverInteractions.sendChoice() document.getElementById('instruction').textContent = "" }else if(isGameReady && gameStep===3){ console.log(isGameReady) if (isGameReady){ console.log(isGameReady) document.getElementById('instruction').textContent = "" setSelectedHand('random'); await new Promise(r => setTimeout(r, 5000)); if (isGameReady){ await setGameStep(0) } } }else if(isGameReady && gameStep===0){ await setGameStep(1) } } } asyncFunction() }, [gameStep]);
核心异常useEffect片段
useEffect(() => { console.log(isGameReady, gameStep) if (isGameReady && gameStep===0){ setGameStep(1) }else if(!isGameReady && gameStep>0){ setGameStep(0) } }, [isGameReady]);
触发setState的事件监听片段
props.server.on('game status', async args=>{ await new Promise(r => setTimeout(r, 250)); document.getElementById('instruction').textContent = args['status'] setIsGameReady(args['isReady']) })
问题根因
核心问题出在Socket事件监听器没有在组件卸载时清理,同时listenersStarted ref的判断逻辑存在缺陷:
- 首次进入组件时,给全局单例的
props.server(socket实例)注册了game status、game result事件监听,这些监听回调捕获的是首次组件渲染周期的setIsGameReady方法 - 路由跳转离开页面时,GameComponent组件卸载,但没有调用
props.server.off()移除之前注册的事件监听器;同时listenersStarted是ref值,组件重新挂载时ref不会重置,仍为true - 第二次进入组件时,因为
listenersStarted.current === true,不会注册新的事件监听器;旧监听器持有的是上一个已卸载组件实例的setIsGameReady,调用时无法触发当前新挂载组件的state更新,表现为useEffect不触发 - 代码中
!props.server.hasListeners('game status')判断没有实际作用:旧监听器一直挂在socket实例上,这个判断永远为false,不会重复注册,但旧监听器又绑定的是已销毁组件的闭包,无法更新当前组件状态 - 额外代码错误:
setState是同步触发渲染的方法,不需要加await,所有await setGameStep()、await setSelectedHand()的写法均为无效写法。
修复方案
- 给注册socket监听的useEffect添加清理函数,组件卸载时主动移除所有事件监听
- 删除冗余的
listenersStartedref判断,完全依赖useEffect的生命周期管理监听器注册与销毁 - 不要直接通过
document.getElementById操作DOM,改用React受控ref绑定DOM元素,避免内存泄漏
修复后的监听相关useEffect代码参考:
// 提前给instruction元素绑定ref const instructionRef = useRef(null) // 事件回调用useCallback包裹,保证引用稳定方便移除 const handleGameStatus = useCallback(async (args) => { await new Promise(r => setTimeout(r, 250)); instructionRef.current.textContent = args['status'] setIsGameReady(args['isReady']) }, []) const handleGameResult = useCallback(async () => { await new Promise(r => setTimeout(r, 250)); }, []) useEffect(() => { props.server.emit("game status") props.server.on('game status', handleGameStatus) props.server.on('game result', handleGameResult) // 组件卸载时清理所有监听 return () => { props.server.off('game status', handleGameStatus) props.server.off('game result', handleGameResult) } }, [props.server, handleGameStatus, handleGameResult])
内容的提问来源于stack exchange,提问作者user19088941
相关产品推荐
相关产品推荐

