ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

小程序WebSocket实战:心跳保活、断线重连与实时推送全解析

小程序WebSocket实战:心跳保活、断线重连与实时推送全解析 去年做一个小程序商城项目服务端同事把“订单状态实时推送”这个需求抛过来让我用 WebSocket 实现。我当时下意识想的是WebSocket 不是前端基础能力吗写个new WebSocket(url)再监听 message 不就行了结果真上手才发现在小程序里跑 WebSocket 远没有这么简单。域名校验、心跳保活、前后台切换、断线重连、消息去重任何一个环节没处理好线上就是一片哀嚎。这篇文章就把我踩过的坑、验证过的方案、以及平时排查问题的思路完整写出来希望能帮做小程序实时通信的同行少走点弯路。1. 先把需求看明白WebSocket 在小程序里到底解决什么问题1.1 轮询改成 WebSocket 的真实动机小程序里的实时通信需求最常见的就那几类订单状态变更推送、客服消息、物流轨迹更新、多端协同操作、消息中心未读角标。早期不少团队靠轮询硬扛比如每 3 秒请求一次订单状态接口。轮询的问题很明显用户在线时服务端压力巨大而且状态变更到用户看到之间永远隔着一次轮询周期体验上总有“慢半拍”的感觉。WebSocket 是长连接、全双工协议建立连接后服务端可以主动把数据推给客户端延迟从“秒级轮询”降到“毫秒级推送”。对商城这种场景来说用户支付成功后立刻看到订单状态变成“待发货”跟等下一次轮询才刷新心理感受完全是两回事。所以“用 WebSocket 做实时推送”这个技术选型本身没有问题问题往往出在小程序环境对连接生命周期的各种限制上。1.2 小程序里 WebSocket 的“能力边界”在小程序里写 WebSocket第一件事就是把浏览器那套思维丢掉。小程序没有浏览器原生 WebSocket必须用wx.connectSocket或者它返回的SocketTask对象。这意味着很多网上 H5 的 WebSocket 代码不能直接搬过来用。其次小程序对连接地址有硬性要求url参数必须以ws://或wss://开头而且正式上线时官方强烈要求使用wss://。如果你在微信公众平台后台只配置了 request 合法域名忘了配 socket 合法域名真机上连接会直接失败报 “url not in domain list”。这个坑非常常见后面会详细讲。还有一个容易被忽略的点小程序的 WebSocket 连接在小程序退到后台一段时间后会被系统回收。这是移动平台的资源管理策略和浏览器里页面隐藏后连接保持是完全不同的。开发时必须针对onHide/onShow做连接保护处理。另外开发者工具里的表现和真机差异很大工具里能保持的连接真机上可能几十秒就断了所以调试阶段一定要尽早真机验证别等提审前才发现问题。2. 基础接入从 wx.connectSocket 到 SocketTask2.1 老 API 和新 API 的区别选哪个微信小程序早期提供的 WebSocket API 是全局事件监听方式wx.onSocketOpen、wx.onSocketMessage这些函数注册之后所有连接的回调都混在一起。如果页面里多个连接或者多个页面各自建连回调就不知道是哪条连接触发的后期维护起来非常痛苦。自从基础库 2.2.0 开始wx.connectSocket会返回一个SocketTask实例连接的所有事件都挂在这个实例上每条连接各管各的事件互不干扰。我的建议是新项目一律用SocketTask老项目也尽快迁移。别抱着全局事件的方式不撒手那是为了兼容旧设备的过渡产物现在新代码还按老写法就是给自己挖坑。2.2 一个最小可运行的连接 demo这里给一个最基础但有代表性的连接示例代码里包含了打开、收消息、报错、关闭四个核心回调const socketTask wx.connectSocket({ url: wss://api.example.com/ws, header: { Authorization: Bearer ${token} }, // protocols: [chat], // 非必要建议别传后面解释 }) socketTask.onOpen(() { console.log(WebSocket 连接已打开) // 注意onOpen 后不要立刻大量发数据底层通道可能还没就绪 }) socketTask.onMessage((res) { // res.data 可能是字符串也可能是 ArrayBuffer console.log(收到消息, res.data) }) socketTask.onError((err) { console.error(连接出错, err.errMsg) }) socketTask.onClose((res) { console.log(连接关闭, res.code, res.reason) })这个代码结构虽然简单但已经包含了我认为最重要的四个钩子。实际项目中常见的错误是在onOpen里立刻调用socketTask.send有时会收到 “sendSocketMessage:fail” 的报错。原因是连接打开的底层通道还没有完全就绪稳妥的做法是 onOpen 之后先做一个几十毫秒的延时再开始发送第一批消息。2.3 参数选型的几个细节header参数可以携带自定义请求头比如把登录态 token 放在Authorization里。不过要注意小程序 WebSocket 的 header 在部分系统字段上有限制如果你发现服务端拿不到某些自定义头可以退一步用 token 拼在 url query 上服务端从 query 里取。两种方案我都用过token 放在 header 更符合 HTTP 习惯放 query 则兼容性最稳看团队约定。protocols参数是用来声明子协议的比如protocols: [chat]。这里必须强调只有当服务端也支持你声明的子协议时握手才会成功否则连接会被服务端拒绝。如果你只是做普通业务推送没有必要声明子协议就别传这个参数少一个变量就少一个坑。SocketTask.send支持发送字符串和ArrayBuffer。如果业务消息是 JSON一般直接传字符串如果要做二进制传输得先转成 ArrayBuffer。注意传输体积小程序对单帧数据量也有限制过大的消息建议拆分传输服务端再按序组装。3. 心跳保活与断线重连稳定运行的命根子3.1 为什么连接会“假死”甚至悄悄消失这是整个小程序 WebSocket 开发里最核心、最容易被低估的部分。很多新手做完收发消息就跑路结果上线后连接一会儿就断收不到推送又找不出原因。连接“假死”主要有几个来源第一是移动网络的 NAT 超时。手机在蜂窝网络或 Wi-Fi 下运营商网关会维护一张地址映射表空闲连接会被定期回收。这个空闲超时时间各家运营商不同短的几十秒长的几分钟没有统一标准。第二是服务端前面的负载均衡器、代理服务器的空闲超时。很多云厂商的负载均衡默认对空闲连接有一个回收时间比如 60 秒。客户端不发送数据LB 就认为连接已经死了悄悄断掉。第三是小程序切后台导致的系统挂起。微信小程序切入后台后系统可能暂停网络活动过一段时间直接把连接回收。用户再切回前台时连接已经无效了。第四是服务端主动断开比如服务发布、重启、或者服务端有主动清理半开连接的健康检查机制。客户端不主动探测是感知不到服务端已经断开连接的这在 TCP 技术里叫“半开连接”。3.2 心跳包设计客户端主动探测连接健康度解决“假死”的标准姿势是心跳保活。简单说就是客户端每隔一段时间发送一个心跳消息服务端收到后回一个响应客户端如果在设定时间内没收到响应就认为连接不可用主动关闭并重新建立连接。心跳协议一般用业务层消息实现比如发送{type:ping,ts:1642671234567}服务端回{type:pong,ts:1642671234567}。这里不建议用 WebSocket 底层的 ping/pong 控制帧因为小程序端的 API 没有暴露底层 ping/pong 的操作能力业务层心跳最可控。心跳间隔怎么定我的经验是15 秒到 30 秒之间。如果短于 10 秒某些服务器的日志会被心跳消息刷屏而且客户端频繁发消息也耗电如果长于 60 秒可能已经超过 NAT 或负载均衡的空闲回收时间连接早就被断了。心跳不要用setInterval建议用递归的setTimeout实现。因为setInterval的执行可能被其他任务阻塞而 setTimeout 每次重新设定能更精准地控制节奏。下面是一个简洁的心跳实现let heartbeatTimer null let heartbeatTimeoutTimer null function startHeartbeat() { clearHeartbeat() // 间隔 20 秒发一次心跳 heartbeatTimer setTimeout(() { sendHeartbeat() // 发送心跳后10 秒内没收到 pong判定连接不可用 heartbeatTimeoutTimer setTimeout(() { console.warn(心跳超时准备重连) socketTask.close({ code: 4000, reason: ping timeout }) }, 10000) }, 20000) } function handlePong() { // 收到任何服务端消息都说明连接是通的 clearHeartbeatTimeout() // 重新开始下一个心跳周期 startHeartbeat() } function clearHeartbeat() { if (heartbeatTimer) clearTimeout(heartbeatTimer) clearHeartbeatTimeout() } function clearHeartbeatTimeout() { if (heartbeatTimeoutTimer) clearTimeout(heartbeatTimeoutTimer) }注意一个细节收到任何服务端消息都可以清除心跳超时计时器不一定非要收到 pong。因为只要服务端有数据过来就说明底层连接是通的没必要在超时判断上搞得那么严格。3.3 断线重连策略别把重连做成“风暴”连接断开后要重连这个很简单。但怎么重连才是重点。最常见的问题是onClose触发后立刻重连如果服务端正在发布导致连接持续失败客户端就会以极快的频率疯狂重连给服务端造成巨大压力。这种“重连风暴”在小程序用户量上来之后会直接把后端打挂。我的重连策略是三步走指数退避、最大重试次数限制、随机抖动。指数退避的意思是每次重连的间隔时间翻倍递增比如第 1 次等 1 秒、第 2 次等 2 秒、第 3 次等 4 秒、第 4 次等 8 秒直到一个上限值我一般设为 30 秒。然后每次的等待时间再加上一个 0 到 3 秒的随机抖动避免大量客户端在同一时刻同时重连。同时还要加一个最大重试次数的限制。比如连续重连 10 次都失败就不再自动重连弹一个提示让用户手动刷新或者引导重新登录因为这种情况大概率不是临时网络抖动而是 token 过期或者服务端出了大问题一直重试没有意义。还有一个容易踩的坑主动关闭和被动关闭要区分。业务上用户退出登录时你会主动调用socketTask.close()关闭连接此时onClose也会被触发。如果重连逻辑写在onClose里就会导致“刚主动关闭又自动重连”的尴尬情况。解决办法是用一个标志位let userClosed false function closeSocketByUser() { userClosed true socketTask.close({ code: 1000, reason: user logout }) } socketTask.onClose((res) { if (userClosed) return // 这里才执行重连逻辑 scheduleReconnect() })3.4 小程序前后台切换是重连的关键时机小程序切入后台时连接很可能被系统回收。所以光在onClose里重连是不够的因为有些情况下连接是悄悄死的客户端可能连onClose都没触发只是之后发送数据时才发现连接不可用。因此必须在微信小程序的onShow生命周期里主动检查连接状态。判断标准很简单如果当前 SocketTask 的readyState不等于WebSocket.OPEN立刻触发一次连接重建。如果连接还是 OPEN 状态也要重新开启心跳计时器因为后台期间定时器可能已经被系统暂停。另一个细节是小程序在后台时不要继续发业务心跳。后台心跳不仅耗电而且系统可能很快回收连接发了也是白发。建议在onHide里清除所有定时器进入前台后重新初始化。4. 真实场景商城推送、列表加载更多、跨端开发怎么落地4.1 列表加载更多与 WebSocket 消息如何共存很多页面是“历史消息 实时消息”的混合场景最典型的就是消息中心、订单列表、物流轨迹列表。你一进页面先用普通 HTTP 请求拉第一页历史记录同时建立 WebSocket。之后新产生的数据通过 WebSocket 实时推给你。这里就有两个矛盾要处理。第一个矛盾是首屏时机。如果 HTTP 历史数据还没返回WebSocket 的实时消息就到了你要么把消息先缓存等历史数据渲染完再合并要么在历史数据回来之后以 WebSocket 消息为准做增量插入。我的习惯是建立一个统一的消息队列WebSocket 消息一律先进入队列等历史数据渲染完成后统一按时间戳合并这样能避免列表闪动和顺序混乱。第二个矛盾是“加载更多”和实时推送并发。用户下拉加载更多之前的数据时服务端刚好推来一条新消息如果直接 unshift 到列表头部用户正在往上翻旧数据结果列表自己往下弹体验非常差。正确做法是加载更多的请求发出后先标记当前列表处于“翻页中”新推送的消息先缓存而不是立刻上屏等翻页请求完成再统一处理。简单说实时推送优先保障最新列表而不是在用户翻旧数据时打断操作。这里还要提一句重复消息去重。服务端推送和 HTTP 拉取之间可能出现重叠比如你拉第一页时服务端把最新一条也放进 WebSocket 推了过来。客户端要维护一个消息 ID 的 Set收到消息时先查重重复消息直接丢弃避免列表里出现一模一样的订单记录。4.2 商城订单状态推送的完整链路商城里最典型的场景是用户下单后支付成功、商家发货、物流更新、用户确认收货这些事件都要实时反馈到用户端。我做的方案是建立“连接后订阅 重连后拉全量”的机制。用户进入订单详情页先建立 WebSocket 连接。连接打开后客户端向服务端发送一个订阅指令说明我要关注哪个订单、哪个用户。服务端把这条连接加入对应的订阅组。之后订单状态变化服务端主动推送事件。这里最容易忽略的是“重连后的状态同步”。用户可能断网后又恢复WebSocket 重连成功但重连期间订单状态已经变了服务端推送过的消息已经丢失。所以客户端在连接成功且订阅完成后一定要额外调用一次 HTTP 接口拉取该订单的最新状态把重连期间的状态补回来。用一句话总结推送负责“快”HTTP 拉全量负责“稳”。另外服务端推送的数据结构要尽量轻比如{ event: order_status_changed, data: { orderId: 20250101001, status: shipped, timestamp: 1704067200000 } }客户端收到后根据event分发到不同的业务处理函数而不是把所有消息都丢到一个回调里做 if-else 地狱。4.3 uniapp、Taro 这类跨端框架怎么处理如果你用 uniapp 开发小程序WebSocket 的写法跟原生小程序差不多uni.connectSocket的参数和回调基本对齐wx.connectSocket。但要注意uniapp 这套 API 是要跨 H5、小程序、App 多端兼容的H5 端底层是浏览器原生 WebSocket小程序端底层是wx.connectSocket行为细节有一些差异比如 headers、握手协议、回调触发时机。封装层最好自己再包一层把心跳和重连逻辑统一管理不要直接裸用框架 API。Taro 的情况类似但 Taro 3 在小程序端是把Taro.connectSocket映射到微信的wx.connectSocket在 H5 端则映射到浏览器 WebSocket两端的SocketTask方法命名一致但实现不同。如果你做了跨端项目记得在端上做条件编译处理差异逻辑。这里插一句跟 WebSocket 关系不大但必须提醒的小程序打包体积有限制uniapp 或 Taro 工程打包时经常出现 “source size exceed max limit 2mb” 之类的报错。我们项目有一次就是因为把某个大 JSON 文件通过 import 引用进了主包直接撑爆了 2MB 限制。WebSocket 封装的代码量其实很小但如果你的工程因为其它依赖包体积超标记得用分包加载或者按需引入来压缩体积不然再好的实时通信方案也发不了版。5. 调试与排查从“连接失败”到“消息不显示”的完整实录5.1 开发者工具是你最趁手的武器微信开发者工具的 Network 面板里有专门的 WS 标签能直观看到 WebSocket 连接状态、收发帧内容、心跳包是否正常。排查问题第一步永远是先看这里而不是去猜。我调试时会在几个关键回调里加 vConsole 日志把每一步都打出来这样在工具和真机上都能看到同一份日志// 每个回调都打日志问题定位快很多 socketTask.onOpen(() { console.log([WS] opened) }) socketTask.onMessage((res) { console.log([WS] recv:, typeof res.data string ? res.data : ArrayBuffer ${res.data.byteLength}B) }) socketTask.onError((err) { console.error([WS] error:, err.errMsg) }) socketTask.onClose((res) { console.log([WS] closed:, res.code, res.reason) })注意开发者工具里 Network 面板展示的 WS 帧是经过工具转发的某些情况下可能和真机表现不完全一致比如弱网模拟在 WebSocket 上就不完全生效。所以工具里没问题、真机有问题这种情况非常常见遇到别慌继续往下排查。5.2 真机调试三板斧真机调试是我排查 WebSocket 问题的主要手段。第一步在微信开发者工具点“真机调试”扫码后手机上的小程序会进入调试模式开发者工具能看到真机的实时 Network 数据包括 WebSocket 帧。这一步就解决了“工具上正常真机连不上”的差异问题。第二步在代码里保留 vConsole 日志开关真机上也能看到 error、close 的具体信息。特别是errMsg的内容非常有价值比如connectSocket:fail url not in domain list和connectSocket:fail timeout是完全不同的方向。第三步服务端要提前把 WebSocket 连接的建立、断开、心跳超时都打上日志并且带上连接 ID 或者用户 ID方便客户端和服务端按同一个 ID 对账。有一次线上问题客户端一直说没收到推送服务端日志一看连接早就被服务端主动断掉了问题瞬间定位。5.3 常见失败场景与错误码排查结合我自己的经历和网上不少同行的反馈整理了一张排查速查表基本能覆盖小程序 WebSocket 的常见故障现象常见原因处理方式connectSocket fail url not in domain list后台只配了 request 合法域名没配 socket 合法域名微信公众平台 - 开发管理 - 服务器域名 - socket 合法域名加上 wss 域名连接秒断onClose 立刻触发wss 证书无效、服务端主动断开检查证书链是否完整看服务端日志onOpen 后发消息报 sendSocketMessage:fail发送时机太早或连接已失效onOpen 后延时 50ms 再发发送前检查 readyState连接稳定但收不到服务端推送订阅关系没建立或消息协议不匹配确认是否发送了订阅指令确认消息格式是文本还是二进制收到消息但页面不更新回调里 this 指向问题或 setData 频率过高用箭头函数保存 this合并 setData 批量更新切后台再回前台消息不来了系统回收了连接客户端不自知onShow 里检查连接状态并重连服务端收不到心跳心跳消息格式和服务端约定不一致双方对齐协议字段名、类型、嵌套结构都不能猜errCode 10002 之类多数是握手阶段的服务端拒绝或域名校验失败贴出完整 errMsg 给服务端按错误信息方向排查看到这里你可能发现很多问题根源不在小程序端代码而是服务端协议、域名配置、证书这种周边因素。所以排查时一定要有全局视角客户端、服务端、网络三方面同时查别死磕一端。5.4 说一句抓包正规调试优先别走歪路群里经常有人问“怎么抓小程序 WebSocket 的包”。我的态度是能用开发者工具自带的 Network 面板和真机远程调试解决的问题就没必要上额外抓包工具。这两个官方手段已经能完整看到 WebSocket 帧、时间线、连接状态而且是官方支持链路稳定性最好。如果你确实需要分析更底层的流量请务必使用正规调试方式在自己的测试机和测试账号上操作保证不涉及其他用户的数据抓包工具和证书也只用在自己可控的测试环境里不要动生产环境更不要试图绕过客户端的安全机制去拦截别人的数据。那既不符合平台规则自己也可能惹上麻烦。定位问题最快的路径永远是日志对账服务端打印连接 ID客户端打印事件日志两边一拼就知道断在哪一环。5.5 WebSocket 连接但不接收信息一个典型实录有个项目出现过“连接打开也发了订阅消息但永远收不到服务端推送”的诡异问题。一开始我怀疑是小程序 API 问题后来拉服务端日志才发现服务端收到订阅消息后往连接上推送了数据但客户端 onMessage 里解析时发现服务端发的是二进制帧而我用字符串解析直接乱码并且被 catch 丢掉了。这是典型的“协议约定不一致客户端与服务端各自猜”。后来我们统一了消息格式约束所有业务消息用 JSON 字符串二进制只用于文件类数据传输并且服务端在握手响应里带上connection_established的确认消息客户端收到这个确认消息才认为订阅生效。这样“有没有建立订阅成功”“有没有收到确认”就变成了可检查的明确步骤排查效率大幅提升。6. 后续还能往哪些方向扩展现在这套方案我们已经稳定跑了三个线上项目从商城订单推送到企业内部 IM 消息基本套路一致SocketTask建连、业务心跳保活、指数退避断线重连、前后台切换触发重连、推送消息统一进队列再分发。每次新项目接入实时通信直接把这套封装拿过去改改协议就上省了不少事。如果你们项目后续有更大的实时需求可以在现有基础上做两件事。一是支持消息幂等每条推送带唯一消息 ID客户端用 Set 去重这样即使服务端重推或者消息乱序页面数据也不会乱。二是加上大概率推送和离线补拉连接正常时依赖 WebSocket 推送极速到达连接异常时靠 HTTP 拉全量状态兜底两端配合才能让用户感知到“永远在线”。最后再分享一个我个人的心得小程序 WebSocket 的代码量其实不大真正的复杂度全在连接生命周期管理上。写代码之前先把“连接打开、心跳超时、被动断开、主动断开、前后台切换、重连成功、重连失败”这几个状态在纸上画清楚手画就行再动手写封装比边写边想稳太多。我最初就是没画状态图直接写结果改了四版才把边界情况全处理干净。踩过几次坑之后现在每到一个新项目我拿到 WebSocket 需求第一件事就是先列状态再谈代码。
返回列表