尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

React 用 useEffect 订阅 WebSocket:连接管理、自动重连与卸载清理

React 用 useEffect 订阅 WebSocket:连接管理、自动重连与卸载清理 React 用 useEffect 订阅 WebSocket:连接管理、自动重连与卸载清理写实时功能时,很多人第一版 WebSocket 代码是这样的:组件里new WebSocket(url),onmessage里setState。跑起来能收到消息,看着没问题。但上线后就会遇到一连串诡异现象:切个路由回来发现开了两条连接、后端重启后前端再也收不到消息、控制台不停报「Can’t perform a React state update on an unmounted component」。这些都不是玄学,而是 WebSocket 的生命周期没有和 React 组件的生命周期对齐。这篇就把连接的建立、清理、重连一步步做对。朴素写法:为什么会开出两条连接function Chat({ roomId }) { const [messages, setMessages] useState([]); useEffect(() { const ws new WebSocket(wss://example.com/room/${roomId}); ws.onmessage (e) { setMessages((prev) [...prev, JSON.parse(e.data)]); }; // 没有返回清理函数 }, [roomId]); return MessageList items{messages} /; }这段代码有两个致命问题:第一,没有清理函数。roomId变化时,React 会重新执行 effect,建立新连接,但旧连接从没关过。切三次房间就有三条连接同时在收消息,messages里全是重复数据。第二,在 React 18 严格模式(StrictMode)下,开发环境的 effect 会执行两次。这是 React 故意的:它挂载组件、卸载、再挂载,用来暴露你没写清理逻辑的 bug。没有 cleanup 的 WebSocket 代码在这里会立刻现形——你会看到两条连接。正确写法:cleanup 里关闭连接effect 的返回值就是清理函数,它会在「下次 effect 执行前」和「组件卸载时」被调用。把关闭连接放进去:function Chat({ roomId }) { const [messages, setMessages] useState([]); useEffect(() { const ws new WebSocket(wss://example.com/room/${roomId}); ws.onmessage (e) { setMessages((prev) [...prev, JSON.parse(e.data)]); }; // 关键:切换 roomId 或卸载时,先把旧连接关掉 return () { ws.close(); }; }, [roomId]); return MessageList items{messages} /; }现在切房间时,旧连接先close,再建新的;组件卸载时连接也会被回收。StrictMode 的双次挂载也不再泄漏——第一次挂载建的连接会在卸载时关掉。但还有个隐藏的坑:ws.close()是异步的,如果连接还在CONNECTING状态就调用 close,某些浏览器会先打开再关闭,onmessage仍可能触发一次。所以清理时最好把回调也摘掉:return () { ws.onmessage null; // 防止关闭过程中还触发 setState ws.close(); };这样即使连接在关闭窗口期收到一帧数据,也不会走到已卸载组件的setState,那条经典警告就消失了。自动重连:后端重启也能自愈真实网络里连接一定会断:后端发版、Nginx 超时、用户网络抖动。断了要自动重连,而且要用指数退避,别断了就疯狂重连把服务器打垮。重连逻辑放 effect 里很容易和清理逻辑纠缠,推荐抽成一个自定义 Hook。这里的关键是用一个shouldReconnect标志位区分「主动关闭」和「异常断开」:import { useEffect, useRef, useState } from react; function useWebSocket(url) { const [status, setStatus] useState(connecting); const [lastMessage, setLastMessage] useState(null); const wsRef useRef(null); useEffect(() { let retry 0; let timer null; // 主动清理时置 false,阻止 onclose 里再触发重连 let shouldReconnect true; const connect () { const ws new WebSocket(url); wsRef.current ws; ws.onopen () { retry 0; // 连上就把退避计数清零 setStatus(open); }; ws.onmessage (e) setLastMessage(JSON.parse(e.data)); ws.onclose () { setStatus(closed); if (!shouldReconnect) return; // 指数退避:1s、2s、4s…最多 30s,避免雪崩 const delay Math.min(1000 * 2 ** retry, 30000); retry 1; timer setTimeout(connect, delay); }; ws.onerror () ws.close(); // 出错统一走 onclose 重连 }; connect(); return () { shouldReconnect false; // 卸载是主动行为,别再重连 clearTimeout(timer); if (wsRef.current) { wsRef.current.onmessage null; wsRef.current.close(); } }; }, [url]); return { status, lastMessage }; }用起来就干净了:function Chat({ roomId }) { const { status, lastMessage } useWebSocket( wss://example.com/room/${roomId} ); const [messages, setMessages] useState([]); useEffect(() { if (lastMessage) setMessages((prev) [...prev, lastMessage]); }, [lastMessage]); return ( {status closed Banner连接断开,重连中…/Banner} MessageList items{messages} / / ); }shouldReconnect这个标志位是重点:没有它,组件卸载时的主动close会触发onclose,进而又调度一次重连,setTimeout在组件死后还在跑,重连成功又建了一条没人管的连接。有了它,主动关闭就是真的关闭。发消息:别在 render 里直接调 ws.send想发消息时,不能直接引用 effect 内部的ws(闭包里拿不到)。正确做法是把send从 Hook 暴露出去,内部读wsRef:const send useCallback((data) { const ws wsRef.current; // 只有 OPEN 状态才能发,重连期间发会抛异常 if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify(data)); } }, []); return { status, lastMessage, send };判断readyState OPEN很关键:重连窗口期连接处于CONNECTING,此时send会直接抛InvalidStateError。生产环境里更完善的做法是维护一个发送队列,连接恢复后再 flush,但先把状态判断加上就能挡掉绝大多数崩溃。小结必须写 cleanup:effect 返回函数里ws.close(),否则切换依赖和 StrictMode 双挂载都会泄漏连接。清理时先摘onmessage:防止关闭窗口期的数据帧触发已卸载组件的 setState。重连用指数退避 shouldReconnect标志:区分主动关闭与异常断开,避免卸载后僵尸重连。onopen里重置退避计数,onerror统一走onclose收口。发消息前判readyState OPEN,重连期间发送会抛异常。一句话记忆:WebSocket 的生命周期要挂在 effect 的 cleanup 上,连接是你开的,关也得你亲手关干净。
返回列表