React/Redux事件处理函数访问过期Redux状态的问题(SignalR批量BattleEvents场景)
我之前也碰到过几乎一模一样的批量事件处理问题,咱们先把核心原因说透,再给几个靠谱的解决方案。
问题根源分析
你遇到的情况本质是Redux状态更新的异步性 + 批量事件的同步处理冲突导致的:
- Redux的
dispatch是同步触发,但组件的useSelector更新、roomRef.current的赋值(第二个useEffect),要等当前事件循环里的所有代码执行完才会触发——也就是你循环处理3个takeDamage事件的整个过程中,roomRef.current一直是事件开始前的初始值,根本没机会更新。 - 另外你的第一个
useEffect依赖项有个隐形bug:把roomRef.current放进依赖数组完全没必要,而且会导致每次roomState变化时都重复订阅/取消订阅BattleEvents,完全是冗余操作,甚至可能引发订阅泄漏。
下面给你四个针对性的解决方案,从简单到符合Redux设计理念的最优方案都有:
方案一:维护本地临时状态副本(适合需要计算「旧值」的场景)
既然批量事件是按顺序执行的,我们可以在handleBattleEvents里维护一个内存中的room状态快照,每次处理事件时更新这个快照,不用等Redux的状态回传。这样每次处理takeDamage时,都能拿到上一个事件处理后的最新值。
async function handleBattleEvents (evtId: string, roomId: string, events: BattleEvent[]) { console.debug('<< BattleEvents', { evtId, roomId, events }); // 深拷贝当前room的快照作为临时状态(用structuredClone处理复杂对象) let tempRoom = structuredClone(roomRef.current); for (let evt of events) { try { if (evt.type === 'takeDamage') { // 把临时状态传入处理函数,同时接收更新后的状态片段 tempRoom = await handleTakeDamage(evtId, roomId, evt, tempRoom); } // ...其他事件类型的处理 } catch (err) { // 错误处理 } } } function handleTakeDamage ( evtId: string, roomId: string, evt: TakeDamageEvent, tempRoom: Room ) { const fighter = tempRoom.battle.players[evt.playerId].fighters[evt.fighterId]; const oldHpPerc = fighter.currentHpPerc; // 这里拿到的就是当前临时状态的旧值 console.log(oldHpPerc); // 第一次是1,第二次是0.73,第三次是0.46...完全正确 // 1. 先更新临时状态 fighter.currentHpPerc = evt.currentHpPerc; // 2. 再dispatch更新Redux状态 dispatch(roomsActions.setFighterHpPerc({ roomId, playerId: evt.playerId, fighterId: evt.fighterId, hpPerc: evt.currentHpPerc, })); // 返回更新后的临时状态给上层循环 return tempRoom; }
同时记得修正第一个useEffect的依赖项,去掉多余的roomRef.current:
useEffect(() => { if (gameHub.hub === null) return; console.info("Connecting room to gameHub."); gameHub.hub.on('BattleEvents', handleBattleEvents); return () => { gameHub.hub?.off('BattleEvents', handleBattleEvents); } }, [gameHub.hub]); // dispatch是Redux稳定导出的,不用放进依赖;handleBattleEvents是组件内函数,这里因为在useEffect外部,其实可以用useCallback包裹,但如果你的gameHub.hub变化不频繁,当前写法也能凑活
方案二:直接用Redux Store的getState()获取最新状态
如果你的项目是直接导出Redux Store实例(比如用Redux Toolkit的configureStore后导出store),可以在事件处理函数里直接调用store.getState()拿到实时的Redux状态,完全不用依赖roomRef:
// 假设你全局导出了store实例 import store from '../store'; function handleTakeDamage ( evtId: string, roomId: string, evt: TakeDamageEvent ) { // 每次调用都同步获取最新的Redux状态 const state = store.getState(); const room = state.rooms.byId[roomId]; const fighter = room.battle.players[evt.playerId].fighters[evt.fighterId]; const oldHpPerc = fighter.currentHpPerc; // 这里拿到的是最新值 dispatch(roomsActions.setFighterHpPerc({ roomId, playerId: evt.playerId, fighterId: evt.fighterId, hpPerc: evt.currentHpPerc, })); }
这个方案最简洁,但要注意:如果你的组件是通过Provider注入store的,而不是全局导出,这个方法就不适用了。
方案三:串行等待每个dispatch完成(适合强顺序依赖的事件)
如果你的批量事件必须严格按顺序执行(比如后一个伤害必须基于前一个伤害后的HP),可以把每个事件处理函数改成异步,等待dispatch完成后再处理下一个事件:
async function handleBattleEvents (evtId: string, roomId: string, events: BattleEvent[]) { console.debug('<< BattleEvents', { evtId, roomId, events }); // 用for...of循环+await,让事件串行处理 for (let evt of events) { try { if (evt.type === 'takeDamage') { // 等待当前事件的dispatch完成,再处理下一个 await handleTakeDamage(evtId, roomId, evt); } // ...其他事件类型 } catch (err) { // 错误处理 } } } // 把handleTakeDamage改成异步函数,返回dispatch的Promise async function handleTakeDamage ( evtId: string, roomId: string, evt: TakeDamageEvent ) { // 这里用getState或者roomRef都可以,因为await dispatch后Redux状态已经更新了 const state = store.getState(); const room = state.rooms.byId[roomId]; const fighter = room.battle.players[evt.playerId].fighters[evt.fighterId]; const oldHpPerc = fighter.currentHpPerc; // 等待dispatch完成(如果你的action是thunk或者用了Redux Toolkit,dispatch会返回Promise) await dispatch(roomsActions.setFighterHpPerc({ roomId, playerId: evt.playerId, fighterId: evt.fighterId, hpPerc: evt.currentHpPerc, })); }
这个方案的缺点是会把批量事件从并行变成串行,性能稍差,但适合事件之间有强依赖的场景。
方案四:批量提交到Redux Reducer统一处理(最优解)
最符合Redux设计理念的方案是:把整个BattleEvents数组作为一个action提交到Reducer,让Reducer内部循环处理所有事件,利用Reducer的纯函数特性保证状态更新的顺序性。这样只需要一次dispatch,性能最优,也避免了组件层的状态跟踪逻辑。
1. 新增批量处理的Action
// roomsSlice.ts(假设用Redux Toolkit) import { createSlice, PayloadAction } from '@reduxjs/toolkit'; // 定义批量事件的Payload类型 interface BatchBattleEventsPayload { evtId: string; roomId: string; events: BattleEvent[]; } const roomsSlice = createSlice({ name: 'rooms', initialState: initialState, reducers: { // ...其他已有的action batchBattleEvents: (state, action: PayloadAction<BatchBattleEventsPayload>) => { const { roomId, events } = action.payload; const room = state.byId[roomId]; if (!room) return; // Reducer内部循环处理每个事件,保证顺序更新 for (const evt of events) { switch (evt.type) { case 'takeDamage': { const { playerId, fighterId, currentHpPerc } = evt as TakeDamageEvent; const fighter = room.battle.players[playerId].fighters[fighterId]; // 直接在Reducer里更新状态,每次更新都是基于上一次的结果 fighter.currentHpPerc = currentHpPerc; break; } // ...处理其他事件类型 } } } } }); export const { batchBattleEvents } = roomsSlice.actions;
2. 组件层简化处理逻辑
async function handleBattleEvents (evtId: string, roomId: string, events: BattleEvent[]) { console.debug('<< BattleEvents', { evtId, roomId, events }); // 直接dispatch批量action,剩下的交给Reducer处理 dispatch(roomsActions.batchBattleEvents({ evtId, roomId, events })); }
这个方案把状态更新的逻辑收拢到Reducer里,符合Redux「单一数据源、状态更新纯函数」的设计原则,同时避免了组件层的状态跟踪冗余代码,是处理批量事件的最优解。
内容来源于stack exchange

