Vue3项目里怎么封装WebSocket才好用又稳?
最近做电商后台的实时订单提醒和大屏看板,前后踩了三次原生WebSocket的坑——一会儿断连后忘了自动重连,一会儿不同组件同时连同一个服务端地址浪费连接数,一会儿接收到的JSON数据解析错了也没地方统一处理,折腾一周,终于整理出一套既适合单连接复用、又有自动重连+心跳保活+消息队列兜底的Vue3版本封装方案,今天好好聊聊。
先搞清楚,Vue3里封装WebSocket的核心需求是什么?
很多新手上来就直接把原生WebSocket写在setup里,用一次还好,用在多个组件里就全乱套了,所以首先得明确,好的封装要解决哪些痛点?
第一个痛点,连接复用,WebSocket是基于TCP的长连接,不像HTTP短连接可以随便开,一般一个浏览器窗口或者一个服务端只需要维持1-2条有效连接,如果每个组件都new一个WebSocket实例,一是服务端压力大,二是浏览器对同一个域名的WebSocket连接数有限制(Chrome、Firefox一般是8条,但有些老版手机浏览器可能更少),三是消息会重复推送到多个实例,处理起来麻烦。
第二个痛点,断连自动重连,网络波动、服务端重启、页面切换到后台太久被浏览器暂停连接,都是常有的事,手动刷新页面恢复太反人类了,必须得有自动重连机制,而且重连次数、重连间隔最好可以自定义,比如第一次隔2秒,第二次隔4秒,指数级递增,避免给服务器造成瞬间重连风暴。
第三个痛点,心跳保活与超时检测,有些网络设备(比如家庭路由器、公司防火墙)会把长时间没有数据传输的TCP长连接切断,这时候服务端和客户端都以为连接还在,但消息根本传不过去,所以得定期给服务端发个“心跳包”(比如空字符串、或者特定格式的JSON:{"type":"ping"}),同时设置超时时间,如果超过这个时间没收到服务端的“心跳回复”({"type":"pong"}),就主动断开连接,触发自动重连。
第四个痛点,统一的消息处理与错误处理,不同组件可能需要不同类型的消息(比如订单组件要监听order,大屏组件要监听dashboard),如果每个组件都在onmessage里写一堆if/else,代码太冗余,最好是用“事件订阅-发布模式”,把不同类型的消息对应到不同的回调函数,谁需要谁订阅谁,不需要了就取消订阅,避免内存泄漏,连接失败、消息解析失败、服务端主动关闭这些错误,最好有统一的处理入口,不用每个组件都去监听onerror、onclose。
第五个痛点,消息队列兜底,如果在连接断开的时候,我们刚好要发消息给服务端(比如刚刷新完页面就点了提交评论的按钮),这时候消息肯定会丢,所以得加个消息队列,连接断开时把要发的消息存起来,等连接成功后再按顺序发出去。
那具体怎么用Vue3 + TypeScript封装呢?
先给大家看一下目录结构吧,不用太复杂,放在src/utils/websocket.ts里就行,如果项目大的话,可以单独建个src/composables或者src/utils/websocket目录,把配置、类、composable分开,但一般中小项目一个文件就够了。
先定义一下类型,有TypeScript的辅助,代码会更稳,不会随便传错参数,类型包括连接配置、心跳配置、消息格式、事件回调类型这些,比如连接配置可以有wsUrl必填,reconnectCount(最大重连次数,默认10)、reconnectInterval(初始重连间隔,默认2000ms)可选;心跳配置可以有pingMsg(心跳消息内容,默认{"type":"ping"})、pongMsg(心跳回复内容,默认{"type":"pong"})、pingInterval(发送心跳的间隔,默认30000ms)、pongTimeout(等待心跳回复的超时时间,默认10000ms)可选;消息格式可以先定义个基础的,比如type必填,data可选;事件回调类型就包括open、close、error、message这几个原生事件,还有自定义的ping、pong、reconnecting(重连中)、reconnected(重连成功)、disconnected(彻底断开连接,超过最大重连次数)事件。
封装一个WebSocketManager的类,这个类用单例模式,确保全局只有一个实例,解决连接复用的问题,单例模式怎么写?在TypeScript里,可以把constructor设为private,然后用一个静态变量instance存唯一实例,再写一个静态方法getInstance获取实例,如果instance不存在就new一个,存在就直接返回。
在WebSocketManager类里实现各种功能,首先是构造函数或者init方法,用来初始化配置,不过因为是单例,最好把配置放在getInstance方法的参数里,第一次调用的时候传进去,之后调用就不用传了,然后是connect方法,用来创建WebSocket实例,连接到服务端地址;是disconnect方法,用来主动断开连接,清空消息队列,清除定时器,释放资源;是send方法,用来发送消息,如果连接还没打开或者已经断开,就把消息push到消息队列里;是subscribe方法,用来订阅事件;是unsubscribe方法,用来取消订阅事件;还有几个私有方法,比如handleOpen(处理onopen事件,发送消息队列里的所有消息,启动心跳定时器)、handleMessage(处理onmessage事件,解析JSON数据,处理pong回复,发布对应的事件)、handleError(处理onerror事件,发布error事件)、handleClose(处理onclose事件,清除心跳定时器和超时定时器,发布close事件,触发自动重连)、startPing(启动心跳定时器)、stopPing(清除心跳定时器和超时定时器)、startReconnect(触发自动重连,指数级递增重连间隔)这些。
封装一个Vue3的composable,比如useWebSocket,这样在setup里用起来更方便,符合Vue3的 Composition API风格,composable里可以做什么?调用WebSocketManager.getInstance()获取单例实例;提供一些更符合Vue3习惯的方法,比如用ref或者computed暴露连接状态(比如isConnected、isReconnecting、reconnectCountLeft这些);用onMounted的时候自动连接(可选),用onUnmounted的时候自动取消订阅当前组件的所有回调(避免内存泄漏,这个很重要!很多新手忘了这个,组件销毁后回调还在,不仅占内存,还可能导致错误);还可以封装一个便捷的subscribeMessage方法,专门用来订阅特定type的消息,不用每次都传事件名和判断type。
给大家举个实际的例子,比如在电商后台的订单组件里用,在大屏看板组件里用,订单组件里订阅order类型的消息,收到后弹个提示框,更新订单列表;大屏看板组件里订阅dashboard类型的消息,收到后更新图表数据;还可以在全局错误处理里订阅disconnected事件,超过最大重连次数后弹个提示框,让用户手动刷新页面或者联系管理员。
有没有什么需要注意的细节?
细节决定成败,这套方案用的时候有几个地方要特别注意:
第一个细节,JSON解析要加try/catch,原生WebSocket的onmessage事件接收的是字符串,有时候服务端可能会返回不是JSON格式的字符串(比如调试的时候打了个log),这时候JSON.parse会报错,导致整个onmessage事件中断,后面的消息都处理不了,所以必须加try/catch,解析失败的话发布error事件就行,不要影响其他逻辑。
第二个细节,重连间隔的指数级递增要设置上限,比如初始间隔是2s,上限是60s,这样每次重连间隔就是2s、4s、8s、16s、32s、60s、60s...不会无限递增下去,等待时间太长也不好。
第三个细节,页面切换到后台的时候要暂停心跳保活,切换回来的时候要恢复,很多浏览器为了省电,页面切换到后台后会暂停定时器,导致心跳包发不出去,超时检测误判,触发自动重连,所以可以用visibilitychange事件来监听页面的可见性,hidden的时候暂停心跳,visible的时候恢复心跳,并且主动给服务端发个ping包,确认连接是否还在。
第四个细节,消息队列要限制大小,如果连接断开很久,消息队列里存了成千上万条消息,等连接恢复后一次性发出去,不仅服务端压力大,网络带宽也可能不够,所以最好给消息队列设置一个上限,比如100条,超过的话就丢弃最早的消息,或者发布一个消息队列溢出的事件。
第五个细节,composable里的onUnmounted要取消订阅当前组件的所有回调,怎么实现?可以在subscribe方法里把当前组件的实例或者一个唯一的标识存到回调的元数据里,然后在composable里维护一个当前组件订阅的事件列表,onUnmounted的时候遍历这个列表,调用unsubscribe方法,取消所有订阅,或者,更简单的办法,用Vue3的provide/inject,在根组件里provide一个唯一的token,然后在composable里inject这个token,订阅的时候把token传进去,WebSocketManager类里把回调按token分组,取消订阅的时候直接把整个token对应的回调组删掉就行,这样更高效。
有没有必要用第三方库?比如socket.io-client或者vue-socket.io-extended?
这个得看具体需求,如果服务端也是用Node.js写的,而且需要更高级的功能,比如房间(room)、命名空间(namespace)、ACK确认、二进制数据传输优化这些,那用socket.io-client肯定更方便,不用自己写这么多封装,但如果服务端不是Node.js写的,或者只需要基本的长连接功能,那自己封装的这套方案足够了,而且更轻量,没有第三方库的依赖,代码也完全可控,可以根据自己的项目需求随时修改。
比如我之前做的那个电商后台,服务端是用Java写的,只需要基本的实时订单提醒和大屏数据推送,不需要房间、命名空间这些高级功能,所以自己封装的这套方案就很好用,没有引入额外的依赖,体积小,性能也不错。
最后总结一下
Vue3里封装WebSocket,核心就是解决连接复用、断连自动重连、心跳保活与超时检测、统一的消息处理与错误处理、消息队列兜底这几个痛点,用单例模式实现连接复用,用事件订阅-发布模式实现统一的消息处理,用指数级递增重连间隔实现自动重连,用心跳包+超时检测实现保活,用消息队列实现兜底,再封装一个Vue3的composable,符合Composition API风格,用起来更方便,注意加上onMounted自动连接、onUnmounted自动取消订阅的逻辑,避免内存泄漏。
这套方案我已经在好几个项目里用过了,包括电商后台、在线教育的弹幕、实时聊天的小demo,都很稳定,没有出过什么大问题,如果大家有什么疑问或者更好的建议,欢迎在评论区留言交流。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

