React中stomp.js订阅回调内调用setState无法更新状态
问题根因
这是React函数组件结合长连接场景非常典型的闭包过期问题,结合你的代码逻辑,具体触发原因如下:
- 初始化socket的
useEffect设置了空依赖数组,仅在组件首次挂载时执行,subscribe方法绑定的回调函数是首次渲染周期生成的闭包版本 - React 18开发环境默认开启严格模式,会自动执行「挂载→模拟卸载→重新挂载」的流程:第一次挂载时执行
initSocket创建stomp实例,还没等连接完成就提前把实例存入了appContext;随后组件被模拟卸载,第一次绑定的回调对应的组件实例已经被销毁 - 组件第二次挂载时,判断
!appContext.socket为false(第一次创建的实例已经存在context里),不会重新执行初始化和订阅绑定。后续长连接收到消息时,触发的是已经销毁的旧组件实例绑定的回调,回调里的setIsIncomingCall调用会被React直接拦截,不会触发当前组件的状态更新——这也是为什么回调里的日志、普通计算逻辑能正常执行,但页面状态永远不变的核心原因 - 就算关闭严格模式,只要组件发生卸载重挂载(比如路由跳转后返回、父组件重渲染触发子组件重建),因为socket实例存在全局context中不会重建,绑定的永远是旧实例的回调,一样会复现这个问题。你之前尝试的useCallback、箭头函数包裹等方案没有生效,本质都是没有解决「回调引用永远指向旧组件实例」的核心问题。
修复方案
按以下三点调整代码即可解决:
- 用
useRef存储最新的消息处理回调,保证长连接消息触发时永远能拿到当前组件实例的最新逻辑,不需要关心闭包依赖问题 - 调整stompClient存入context的时机,等连接成功后再存入全局context,避免未完成初始化的实例被异常复用
- 增加组件卸载时的清理逻辑,断开旧连接、清空context中存储的实例,避免旧订阅残留
调整后的核心代码如下:
export default function HomePage() { // ...其他状态定义 const [isIncomingCall, setIsIncomingCall] = useState(false) // 新增ref存储最新的来电处理回调 const incomingCallHandlerRef = useRef(null) // 每次组件渲染都更新ref中存储的最新回调 useEffect(() => { incomingCallHandlerRef.current = (data) => { const state = JSON.parse(data.body.toLowerCase()); setIsIncomingCall(state) } }) function initSocket() { const socket = new SockJS(`${process.env.API_URL}/ws`); const stompClient = over(socket); stompClient.connect( {}, () => onSocketConnected(stompClient), onSocketError ) } function onSocketConnected(stompClient) { // 连接成功后再将实例存入全局context appContext.setSocket(stompClient) const options = { accessToken: cookies['logged-in'], }; stompClient.send("/app/connect", {}, JSON.stringify(options)); stompClient.subscribe(`/user/search/complete`, handleSearchComplete) stompClient.subscribe(`/user/search/failed`, handleSearchError) // 订阅时通过ref调用最新的处理函数,永远不会拿到过期闭包 stompClient.subscribe(`/user/call/incoming`, (data) => { incomingCallHandlerRef.current?.(data) }) } useEffect(() => { if (!appContext.socket && cookies['logged-in']) { initSocket() } // 组件卸载时清理连接,避免旧实例残留 return () => { if (appContext.socket) { appContext.socket.disconnect() appContext.setSocket(null) } } }, []) return ( <> <AnimatePresence> { isIncomingCall && <CallModal onAccept={acceptIncomingCall} onReject={rejectIncomingCall} /> } </AnimatePresence> {/* 其他页面代码 */} </> ) }
额外注意点
- 建议在回调里增加日志确认
JSON.parse(data.body.toLowerCase())的解析结果符合预期,避免因为后端返回格式问题导致setIsIncomingCall传入的值一直和初始值一致,看起来像是状态没有更新 - 如果socket不需要跨多个路由/组件全局复用,最好不要把stompClient存在appContext中,直接在当前组件用ref管理实例生命周期即可,能大幅减少全局实例复用带来的闭包、残留订阅问题
内容的提问来源于stack exchange,提问作者Nikitorius
相关产品推荐
相关产品推荐

