恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
uni-app微信小程序WebSocket实战:从选型到稳定运行
首页
资讯中心
/
uni-app微信小程序WebSocket实战:从选型到稳定运行
uni-app微信小程序WebSocket实战:从选型到稳定运行
发布时间:2026/10/16 9:22:50
做实时消息类的小程序需求最绕不开的一句话就是到底要不要上 WebSocket在实际开发里uni-app 微信小程序项目被问得最多的问题之一就是“服务端消息怎么才能及时到手机上来”。大多数人听到“推送”两个字第一反应是 WebSocket但真上手以后会发现WebSocket 并不是把地址一填、服务端一发、客户端一收这么简单。它牵扯到连接生命周期、心跳、断线重连、前后台切换、消息协议设计、页面订阅管理等一系列工程问题。这篇文章就围绕 uni-app 微信小程序开发的全流程把 WebSocket 怎么接、怎么用、怎么用得合理讲清楚。如果你是第一次做小程序或者已经在用 uni-app 但 WebSocket 用得比较草率这篇文章都值得看一遍。我尽量不做概念堆砌按实际开发顺序走一遍先决定要不要用长连接再搭工程、写页面最后把 WebSocket 的工程化封装和稳定性保障摊开讲。里面每一段代码都是可以直接复制到项目里改一改就能用的。1. 选型先想清楚为什么是 uni-app以及什么场景才真正需要 WebSocket1.1 uni-app 在小程序生态里解决的是“多端复用”问题uni-app 的核心价值不是它能“代替微信小程序原生开发”而是它把 iOS、Android、各个小程序平台、H5 统一成一套 Vue 语法代码。对很多中小团队来说这是很现实的选择一套代码覆盖多渠道人力投入能省一半以上。我见很多项目从原生小程序切到 uni-app主要诉求并不是写起来更爽而是以后如果要上 H5 或支付宝小程序不用重新写一套业务逻辑。当然选 uni-app 也要接受它的成本。平台差异不会凭空消失只是从“两个项目跑两套代码”变成了“一个项目跑多套编译逻辑”。尤其在小程序基础能力上比如 WebSocket、地理位置、支付、分享uni-app 把微信小程序的原生 API 包装了一层但底层的平台限制依然存在。所以在做技术选型时我的判断标准一直是业务是不是真的需要多端同步铺开如果只做微信小程序原生开发确实有优势如果明确要跨端那 uni-app 是性价比最高的路线。单说微信小程序这一端uni-app 的编译产物和原生小程序的运行机制完全一致。你写的onLoad、onShow会被编译成小程序页面生命周期v-model、:class也会被转成 WXML 里能识别的语法。这一点经常被新手忽略理解了微信小程序本身的运行规则再用 uni-app很多坑其实是不会踩的。1.2 WebSocket 不是“实时接收”的唯一答案先泼一盆冷水很多业务场景根本不需要 WebSocket。消息类小程序常见的需求无非三种形态主动拉取、定时刷新、被动通知。主动拉取就是用户点击列表、打开详情页面主动请求 HTTP 接口这没什么争议。定时刷新则是一种“伪实时”方案前台用一个定时器每多少秒请求一次接口。被动通知才是 WebSocket 的领地服务端有新消息、有订单状态变化、有告警事件客户端不能一直靠用户手滑来刷新需要服务端主动把数据推下来。很多人把定时刷新和 WebSocket 对立起来其实它们并不是非此即彼。我见过一个项目业务方拍板要上 WebSocket理由仅仅是“老板觉得轮询太 Low”。结果后端没有长连接基础设施前端连了好几天消息质量反而不如原来的 5 秒轮询稳定。合理的做法应该是用一张表量化决策对比维度短轮询长轮询WebSocket实时性秒级到分钟级接近实时毫秒级实时服务端压力高QPS 随客户端数量线性增长连接挂起占线程资源连接占用但消息量小时很轻松客户端功耗请求频繁电量和流量偏高中等低实现复杂度低普通 HTTP 就能做中较高需要完整连接管理适用场景后台快递状态、排行榜、定时任务消息频率不高的提醒IM、协同编辑、行情、实时告警这张表可以当作判据如果业务能接受 10 秒以上的延迟优先用短轮询如果延迟要求在 3 秒左右可以考虑长轮询只有当服务端事件随时可能发生、而且用户需要立刻感知时WebSocket 才是合理选择。1.3 什么时候坚决不要用 WebSocket还有一类场景看起来像是“实时推送”实际上 WebSocket 并不合适。第一种是低频通知。比如用户每天只会收到一两条业务提醒把 WebSocket 挂在后台保持一整天的成本就太高了。这种需求更适合消息订阅接口加被动刷新小程序启动时调一次“最近通知”接口用户回到前台时再调一次已经足够。第二种是服务端没有长连接能力的系统。如果后端团队只能用 HTTP 接口却想强行实现服务端推送WebSocket 会变成一场灾难。连接握手要改网关状态维护要引入 Redis断线重连要设计策略这些投入远大于轮询。第三种是 H5 和小程序跨端获取数据都偏重的项目。H5 端 WebSocket 的行为和小程序端差异很大如果在 uni-app 里写一套连接逻辑同时跑两端往往要针对 H5 单独兼容。对中小型项目来说宁可把业务设计成“进页面拉数据下拉手动刷新”也不要让长连接成为整体架构的负担。2. 从零搭一个可运行的微信小程序工程2.1 项目创建与目录结构用 uni-app 开发微信小程序最常用的入口是 HBuilderX直接新建项目选 uni-app 模板也可以让老 Vue 项目的团队用命令行把工程拉起来。HBuilderX 的好处是把运行、编译、上传小程序这几步都集成好了新手不容易迷路。我建议先用一个空模板起步不要选带 UI 库的全家桶模板这样你能看清楚每个文件是干什么的。创建完项目后关键是理解目录结构。pages.json是页面路由配置相当于原生小程序的app.jsonmanifest.json是应用级别配置包括小程序 AppID、权限声明、第三方 SDK 参数App.vue是全局入口可以监听onLaunch、onShow、onHide这些小程序的全局生命周期。页面文件放在pages目录下每个页面一个文件夹里面通常是四个文件.vue、.js、.json、.scss或.css但实际只需要一个.vue文件样式和逻辑都写在里面。初次运行到微信开发者工具的步骤也值得说清楚。先在 HBuilderX 里配置“微信开发者工具路径”然后在 manifest.json 里填上你的小程序 AppID点击“运行到小程序模拟器”。这时候 HBuilderX 会自动编译到一个临时目录微信开发者工具会打开这个目录。如果一直提示找不到项目多半是开发者工具的“服务端口”没有打开需要在微信开发者工具的安全设置里把服务端口启用。2.2 manifest.json 中决定微信小程序表现的配置项manifest.json 是 uni-app 项目的关键配置很多“编译正常但真机表现不对”的问题都出在这里。第一个是 AppID。开发调试阶段可以用测试号但涉及 WebSocket 和真实接口域名时测试号很容易踩到配置不一致的坑。我建议尽早用正式小程序的 AppID把域名白名单一起配好。第二个是模块权限配置比如地理位置、摄像头、扫码如果你在页面里用了uni.getLocation但没有在 manifest.json 里开权限真机上会静默失败。第三个是“微信小程序”专属的设置包括 npm 模块构建、插件配置、分包目录等。有一个细节是 uni-app 的 manifest.json 会在编译时生成微信开发者工具中的project.config.json。所以你不要去手动修改临时目录里的配置那样下次编译会被覆盖。所有改动都应该在 manifest.json 里做让编译过程自动生成对应配置。2.3 网络层的统一封装request 的跨端差异小程序里发 HTTP 请求原生和 uni-app 略有差异但思路一致必须有统一封装不能到处直接调用uni.request。封装的意义在于统一处理登录态、响应码、错误提示以及后续如果要切域名或加签名只需要改一个文件。下面是一个很基础但够用的封装import { BASE_URL } from /config export function request(options) { return new Promise((resolve, reject) { uni.request({ url: ${BASE_URL}${options.url}, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, ...options.header }, success: (res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data) } else { uni.showToast({ title: res.data.message || 请求失败, icon: none }) reject(res) } }, fail: (err) { uni.showToast({ title: 网络异常, icon: none }) reject(err) } }) }) }这里把后端统一返回结构规定为{ code, message, data }成功时直接取出data。如果后端接口已经是 RESTful 风格的裸数据就把判断逻辑改成res.statusCode 200后直接resolve(res.data)。重点是在fail回调里处理超时和断网同时要避免每个调用方都自己弹 toast。登录失效的判断也建议放到这里统一做跳转和处理。需要留意的是小程序在传统浏览器里没有 cookie 概念很多后端默认从 cookie 取用户身份的做法在这里行不通。项目里通常用 token 机制通过请求头里的Authorization字段传给服务端。上述封装里的...options.header就有扩展空间你可以在这个位置把本地存的 token 注入进去或者增加一个统一的header逻辑。3. 页面开发里容易忽略的小程序差异3.1 生命周期与数据加载姿势在 uni-app 里写页面很多人会用 Vue 的mounted去拉数据但实际上小程序场景下更可靠的是页面生命周期。小程序页面有自己的一套生命周期onLoad页面首次创建时执行onShow页面每次显示都会执行onReady页面初次渲染完成时执行onHide页面被切走时执行onUnload页面销毁时执行。从业务的直觉来说首屏数据放onLoad每次进入页面都希望刷新时数据放onShow订阅类的长连接动态应该配合onShow/onHide做重连或暂停。很多消息页的实时数据一直不更新就是因为数据拉取逻辑写在了created里边小程序返回页面时组件不一定重建生命周期不会重新走一遍。一个典型的列表分页写法是翻页用onLoad初始化onReachBottom触发加载下一页下拉刷新用onPullDownRefresh。注意onReachBottom只有在页面滚动到底部时会触发如果你用了scroll-view组件并且设置了固定高度触发条件就不一样了需要监听scroll-view自身的滚动事件。export default { data() { return { list: [], page: 1, finished: false, loading: false } }, onLoad() { this.fetchList() }, onReachBottom() { if (!this.finished) this.fetchList() }, methods: { async fetchList() { if (this.loading || this.finished) return this.loading true const data await request({ url: /list, data: { page: this.page } }) this.list this.list.concat(data.items) this.page 1 this.finished data.items.length 10 this.loading false } } }这段代码里的loading和finished是有讲究的。不加loading连续快速滚动到底部会触发多次重复请求不加finished数据加载完后进入下一页仍会白请求一次。这两个状态位是分页请求的底线任何页面都建议保留。3.2 列表渲染与组件通信uniapp 的列表渲染用的是 Vue 的v-for但要注意 key 的使用。列表数据如果是从服务端拉下来的每条记录一定要有唯一的id作为:key否则在删除、排序时很容易出现渲染错乱尤其在嵌套组件时表现更明显。另一个小差异是wx:if和v-if可以混用吗不要混用。在 uni-app 里建议统一用 Vue 的v-if/v-show它就是编译到小程序底层的条件渲染不要倒回去写wx:if否则维护成本极高。组件通信方面父子组件用props和$emit是标准姿势。跨页面共享实时数据更好的方式是全局状态管理比如 Pinia 或 Vuex但写简单页面时也不必为了一个状态引入整个状态库。WebSocket 推送的消息如果你希望多个页面都能响应我建议用统一的订阅管理器而不是让每个页面直接监听 SocketTask 实例这个后面会展开。页面跳转也要注意uni.navigateTo会保留当前页面可以返回uni.switchTab只用于 tabBar 页面uni.redirectTo会关闭当前页面。如果你用navigateTo连续跳了很多层页面栈会超过十层小程序平台会直接跳转失败所以在长链路场景下要配合redirectTo。这些细节在 H5 上往往不会暴露但真机上非常致命。3.3 rpx 与样式兼容要点uni-app 小程序样式的单位建议使用rpx。小程序把屏幕宽度定义为 750rpx所以在 iPhone 的 375px 逻辑宽度下1px 等于 2rpx。设计稿是按 750 宽度出的理论上来讲可以直接把设计稿里的 px 数字写成 rpx减少换算。但要注意rpx在小程序里的计算是响应式的不同屏幕宽度下会被等比放大这个特性对大屏适配非常友好可一旦用在字体尺寸上很容易出现同一段文字在不同手机上忽大忽小所以字体大小我到目前还是更倾向用固定px。样式兼容还有一个经典场景position: fixed和底部安全区。iPhone 全面屏底部有一条小黑条固定底部的按钮很容易被遮挡。正确做法是在遮挡区域加上padding-bottom: constant(safe-area-inset-bottom)和padding-bottom: env(safe-area-inset-bottom)。另一个容易踩的坑是标签选择器。微信小程序的 WXML 里不支持divuni-app 虽然允许你在 template 里写view但不要直接写模板字符串的class和style过度嵌套。更稳妥的做法是每个页面最高层用一个普通 class避免全局样式覆盖。官方组件如button、scroll-view都有平台的默认样式差异如果你的页面在 H5 上正常、小程序上错位首先要怀疑的是组件默认样式而不是自己的布局写错了。4. WebSocket 在 uni-app 中的 API 层级与工程化封装4.1 理解 uni.connectSocket 与 SocketTask 的关系在 uni-app 里使用 WebSocket核心是uni.connectSocket。这个方法会创建一个底层连接并且在不同端上会被编译成对应的实现微信小程序端是原生 WebSocket 能力H5 端就是浏览器原生 WebSocketApp 端则走 App 的 Socket 模块。uni.connectSocket接收一个对象参数常用字段包括url、header、method、protocols。url 必须以wss://开头。微信小程序对连接安全性有强行要求生产环境只允许配置过的合法域名而且必须是wss加密协议。有的团队在本地测试用ws://开发工具里勾了“不校验合法域名”能跑一到真机就全部失败这个坑很常见。真机调试时如果有问题先确认域名白名单和协议。uni.connectSocket返回一个 SocketTask 对象后续所有操作都要基于这个 task 来做const task uni.connectSocket({ url: wss://your-domain.com/websocket, header: { content-type: application/json }, success: () {}, fail: (err) { console.error(connect failed, err) } }) task.onOpen(() {}) task.onMessage((res) {}) task.onClose(() {}) task.onError(() {}) task.send({ data: JSON.stringify({ type: hello }) }) task.close({ code: 1000, reason: done })这里要注意老版 uni-app 还提供了uni.onSocketOpen、uni.onSocketMessage这类全局方法它们是历史遗留方案。如果你在同一个页面里同时混用 SocketTask 的回调和全局事件方法消息回调会被重复注册连接断开时行为也会不可预期。新项目一律建议围绕 SocketTask 做封装彻底避开全局事件。4.2 全局唯一的 SocketManager连接只能有一份微信小程序最忌讳的一件事是每个页面都单独创建一个 WebSocket 连接。平台对小程序端的并发连接数有严格限制而且从产品体验上讲用户打开首页连接一次跳到详情页又连接一次两个连接各自维护一套心跳既浪费资源又容易触发平台的连接数告警。更合理的模型是整个小程序只有一个全局连接所有页面共享。这个目标单靠uni.connectSocket是做不到的必须做一个单例管理器。我通常的做法是维护一个全局唯一的 SocketManager 类模块导出单例class SocketManager { constructor() { this.task null this.status closed // closed / connecting / open this.url this.heartbeatTimer null this.reconnectTimer null this.reconnectTimes 0 this.maxReconnect 8 this.sendQueue [] this.handlers new Map() } connect(url) { if (this.status open || this.status connecting) return this.url url this.status connecting this.task uni.connectSocket({ url, method: GET, success: () {}, fail: () {} }) this.bindEvents() } bindEvents() { this.task.onOpen(() { this.status open this.reconnectTimes 0 this.startHeartbeat() this.flushQueue() }) this.task.onMessage((res) { this.dispatch(res.data) }) this.task.onError(() { this.status closed this.stopHeartbeat() this.scheduleReconnect() }) this.task.onClose(() { this.status closed this.stopHeartbeat() this.scheduleReconnect() }) } send(payload) { const data JSON.stringify(payload) if (this.status ! open) { this.sendQueue.push(data) return } this.task.send({ data }) } startHeartbeat() { this.stopHeartbeat() this.heartbeatTimer setInterval(() { if (this.status ! open) return this.task.send({ data: JSON.stringify({ type: ping }) }) }, 30000) } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer) this.heartbeatTimer null } } scheduleReconnect() { if (this.reconnectTimes this.maxReconnect) return const timeout Math.min(1000 * Math.pow(2, this.reconnectTimes), 10000) this.reconnectTimes 1 this.reconnectTimer setTimeout(() { this.connect(this.url) }, timeout) } flushQueue() { while (this.sendQueue.length) { const data this.sendQueue.shift() this.task.send({ data }) } } subscribe(type, handler) { if (!this.handlers.has(type)) this.handlers.set(type, new Set()) this.handlers.get(type).add(handler) } unsubscribe(type, handler) { const set this.handlers.get(type) if (set) set.delete(handler) } dispatch(raw) { let msg try { msg JSON.parse(raw) } catch (e) { return } const type msg.type const set this.handlers.get(type) if (set) set.forEach((handler) handler(msg.data, msg)) } close() { this.stopHeartbeat() if (this.task) this.task.close({ code: 1000 }) this.status closed } } export default new SocketManager()这个单例的设计要点有三个。第一连接状态只在 manager 内部流转所有页面都不需要知道底层 task 实例。第二send方法在连接尚未建立时不会丢失消息而是进入sendQueue在onOpen时才补发。第三消息分发用类型匹配页面只订阅自己关心的消息类型不会出现一个回调函数处理所有消息的混乱情况。4.3 消息协议设计不要让消息变成“裸字符串”我见过不少项目WebSocket 消息直接发一个 JSON 字符串数据结构完全靠服务端随机决定。客户端收到什么就解析什么解析完再写一堆 if-else 去判断“当前消息是订单还是聊天”。这种写法在初期能跑但一旦消息类型超过十种代码就会变成灾难。合理的设计是给所有消息定一个统一信封。出站消息和服务端入站消息各有一套规范比如服务端推送格式{ type: order.updated, data: { orderId: xxx, status: paid }, ts: 1712345678000 }客户端发起的请求类消息建议带上requestId{ type: subscribe.order.list, data: { page: 1 }, requestId: uuid-xxx }requestId的目的是让客户端能区分“这条响应到底对应我刚才发的哪条请求”。服务端在处理完请求后会把同样的requestId带回响应中。这样就相当于用一条单向的 WebSocket 通道实现了“类请求响应”模式而不是全部靠推。这个设计对订阅列表、拉取详情这类低频操作很实用可以避免在客户端维护一大堆临时回调。4.4 消息分发让页面只拿到自己关心的数据单例全局连接的另一个好处是消息可以统一收统一分。页面在onLoad或mounted时通过subscribe订阅自己需要的消息类型在onUnload时unsubscribe。这样一个实时订单页面就只需要关心order.updated聊天页面只需要关心chat.message互不干扰。import SocketManager from /utils/socket export default { data() { return { orderStatus: } }, onLoad() { SocketManager.connect(wss://your-domain.com/websocket) this.orderHandler (data) { this.orderStatus data.status } SocketManager.subscribe(order.updated, this.orderHandler) }, onUnload() { SocketManager.unsubscribe(order.updated, this.orderHandler) } }这里有一个必须注意的细节this.orderHandler不能直接在订阅时传匿名函数否则onUnload时你根本没有同一个函数引用可以移除。这个坑会让页面已经销毁了但回调依然存在于全局 manager 里消息还会触发this.orderStatus的赋值甚至报错这就是典型的内存泄漏和无效 setData。5. 把连接养“硬”的四个关键设计心跳、重连、前后台、网络切换5.1 心跳既要按时发也要按响应判断WebSocket 连接建立后服务端会为每个连接维护一个资源池。如果客户端一直不发送数据很多网关或负载均衡器会在一段时间后判定连接空闲并直接断开。服务端当然可以主动设置 keep-alive但网关层的空闲超时往往不由应用层控制所以客户端必须自己发心跳。心跳的常见做法是客户端每隔固定时间发送一个{ type: ping }服务端收到后回一个{ type: pong }。这里有两个细节经常被忽略。第一心跳发送不能无限叠加两个定时器不能同时存在所以要先清掉上一个定时器再创建新的。第二心跳消息本身也应该走统一协议不要单独发明一个字段否则服务端解析逻辑就多了一条分支。更稳一点的做法是不只定时发 ping还要维护一个“最后收到消息时间”的变量。onMessage每收到一条消息就刷新这个时间而心跳定时器在发送 ping 的同时也检查lastMessageTime是否超过阈值如果超过说明整个连接可能已经处于半开状态主动关闭让重连逻辑接管。5.2 断线重连指数退避 最大次数封顶连接一定会断问题只在于断了之后怎么恢复。最笨的重连方式是每 5 秒固定地重新连接这在服务端故障时会形成循环风暴几万个客户端同时重连直接把服务端打挂。正确的做法是使用指数退避第一次断开等 1 秒第二次等 2 秒第三次等 4 秒依次往后翻倍直到一个上限比如 10 秒封顶。上面的 SocketManager 代码里已经实现了这个逻辑。scheduleReconnect每次触发时先判断reconnectTimes是否达到上限达到就停止。这里要提醒你onClose和onError都有可能触发重连所以要给重连操作做一个幂等保护不能让一个断开事件被排入多个定时器。比较简便的方式是在scheduleReconnect开头先清除已有的reconnectTimer。另一个细节是用户主动退出页面、主动关闭连接时不应该触发自动重连。所以close方法里要同时清理重连定时器和心跳定时器并且把reconnectTimes清零。否则用户都关掉连接了它还在后台偷偷重连既浪费资源也污染后续重新连接的逻辑。5.3 前后台切换小程序不允许连接一直活着小程序进入后台后系统不会让 JavaScript 一直运行。微信小程序在后台的运行有严格时长限制用户切到其他应用几分钟后整个小程序很可能被挂起甚至销毁WebSocket 连接自然也会断开。很多开发者只在启动时连接一次就再不管了用户第二天回来看到的永远是一根断掉的连接。正确做法是把重连时机绑定到前后台切换。全局在小程序onShow时检查连接状态如果status不是open就主动重新 connect在onHide时可以选择保持心跳但停掉某些业务逻辑或者干脆关闭连接等回到前台再连。就我的经验比较稳妥的策略是进入后台后不要立刻主动断连因为用户可能只是切出去回个消息几秒就回来但也不要去维持高频心跳因为后台运行受限心跳可能发了也不会有响应。具体到代码层面这个逻辑应该放在 App.vue 的onShow/onHide里因为页面级onShow/onHide只代表页面可见性变化不代表小程序整体前后台切换。如果每个页面都自己处理重连切换页面时会重复触发反而引入大量竞态问题。5.4 弱网与切换网络用连接状态机过滤无效消息移动端最容易触发 WebSocket 断开的就是网络切换从 Wi-Fi 换到 4G、从电梯里走出来、锁屏一会儿再解锁。这些场景里客户端可能还不知道连接已经失效还在继续发消息实际上消息根本没送达。建议在 SocketManager 里维护一套清晰的状态机。状态只有三个closed、connecting、open。所有外部调用都基于状态判断行为send时如果不是open就进发送队列connect时如果已经在connecting或open直接忽略。这套状态机还能避免一个常见的 bug网络暂时不可用时uni.connectSocket的fail和onClose同时触发导致重连逻辑跑两遍。网络状态变化可以通过uni.getNetworkType和uni.onNetworkStatusChange监听。真实场景里从弱网恢复的瞬间旧的连接可能并不报错但数据已经不通了。如果你想做得更细可以在页面回到前台时主动发送一个{ type: ping }然后开启一个“等待响应”的短定时器超过 10 秒没收到 pong 就强制关闭并重连。这个小动作会让整个连接更皮实付出代价只是极少的流量和几行代码。6. 大规模消息流下的合理使用准则6.1 连接复用是底线页面级订阅是规范“合理使用 WebSocket”的第一条底线就是整包只保留一条连接。这条规则不仅在技术上能减少资源占用更能在业务上避免数据孤岛。假设首页和详情页各自建了一条连接服务端推了一个全局消息你到底应该下发到哪条连接如果全发客户端要自己过滤如果只发一条另一条页面就收不到。所有这种混乱都是因为连接不是唯一的。在全局唯一连接的基础上页面级订阅就成了自然的配套规范。每个页面只订阅自己关心的消息类型离开时统一解绑。这里还要补充一点页面之间传递实时数据不应该依赖全局广播去更新所有页面。一个订单详情页收到的推送应该是 detail 页面自己去订阅order.updated并处理而不是由路由或缓存机制强制刷新其他无关页面。6.2 消息并发与队列uni-app 的 send 不能无脑连发WebSocket 的 send 方法本身是异步的底层有发送缓冲机制。在小程序端如果你在极短时间内循环发送几十条消息部分消息可能因为底层连接还在上一轮发送中而被丢弃。比较稳妥的做法是给发送动作增加一个轻量级队列发送前先检查当前是否正在发送中如果正在发把数据放入队列等上次发送完成的回调触发后再取下一个。上面的 SocketManager 已经用sendQueue解决了“连接未建立时消息不丢”的问题。但还要注意队列不能无限膨胀。一个聊天页面如果离线了几分钟本地可能积压了几百条待发送消息如果全部补发反而会把服务端打垮。所以我会给队列设一个上限比如 100 条超出上限的消息直接丢弃并提示用户发送失败让用户手动重发。这个细节比大多数文档里写的“队列发送”更符合真实业务。还有一个容易被忽略的点发送消息失败后的错误处理。task.send的fail回调不一定会触发onError如果不主动处理这条失败消息会无声无息地消失。所以发送逻辑里应该带上fail回调记录日志并把失败消息重新放回队列或上报埋点。6.3 与 HTTP 接口的分工哪些事不该走 WebSocket一个稳定项目的 WebSocket 和 HTTP 是分工明确的而不是“既然已经连了所有请求都走它”。WebSocket 适合处理服务端主动推送和实时交互但不适合承载大数据流量更不适合承载一次性的查询请求。举个例子用户点开一个历史订单列表这应该走 HTTP 接口拉取因为它是用户主动发起的、低频的、需要明确 loading 状态的请求。如果把它做成{ type: query_order_list }丢到 WebSocket 上连接一旦正在重连请求就会卡在队列里用户看到的 loading 永远转不完。回退逻辑也没有 HTTP 那么直观。反过来订单状态变更通知、聊天消息、在线状态变化这些事件型数据走 WebSocket 就非常合适。服务端一旦有事件产生直接推下来客户端不需要额外主动拉取。所以从架构上你应该让 WebSocket 专注于“推送事件”让 HTTP 专注于“查询和提交”当客户端需要某个完整列表时优先触发 HTTP 请求而不是试图通过长连接实现一套 RPC。6.4 推送频率与页面承载把大消息拆小WebSocket 消息虽然实时但小程序的渲染性能是有上限的。服务端如果在一个事件里塞了 100 条聊天记录客户端把它一次性 setData 到界面上页面会明显卡顿。合理的做法是在服务端做聚合或裁剪客户端也要做节流。比较有效的策略是窗口合并。比如 1 秒内同一类型的消息最多只允许 dispatch 一次其余消息先缓存到本地变量等窗口结束再统一合并更新。这样即使服务端瞬时推了 20 条chat.message列表也只会刷新两次而不是二十次。数据变化的实时性会被延迟零点几秒但这个损失对绝大多数业务来说完全可以忽略。同时要注意 setData 的频率。this.orderStatus data.status这种轻量赋值还好如果是往一个大数组中push消息再整个 setData那就要小心了。小程序的 setData 是整套数据快照更新的过程大数据量更新很容易在低端手机上卡出问题。建议把消息数据结构设计成“小对象”形式并且只更新需要变化的字段而不是把整个聊天列表反复替换。6.5 可观测性用埋点和日志量化连接健康度很多项目上线后WebSocket 的稳定性只能用“用户反馈说收不到消息”来判断这是很被动的。合理的做法是在 SocketManager 里加入可观测的埋点把连接生命周期记录下来用于定位问题。建议至少记录这些关键事件连接开始、连接成功、连接失败、连接断开、重连次数、心跳超时、消息发送失败、消息延迟。每条埋点带上时间戳、网络类型、页面路径和当前重连次数。当用户反馈收不到消息时你可以拉出这些日志看到底是连接没建立、重连次数耗尽、还是消息收到了但渲染组件出了问题。更进一步可以做简单的质量指标上报。比如统计“连接存活率”session 时长 / 理论上应该存活时长。上线后的第一个版本我会先只做采集和日志不急着做自动告警等稳定运行一两周后用这些数据决定心跳间隔是否要调整、重连上限是否要加大。7. 上线前我建议你自查的几件事7.1 域名、协议与白名单检查清单微信小程序对 WebSocket 域名有严格限制这是上线前最容易被卡住的点。下面的清单基本覆盖了所有常见问题建议对照着过一遍检查项说明常见问题必须是 wss 协议生产环境不能使用 ws开发工具能跑真机连不上域名加入 Socket 合法域名在小程序后台配置配置了 HTTP 白名单但没配 Socket 域名域名不能带路径合法域名是协议加域名填成了wss://xxx.com/ws这种路径证书链完整wss 依赖有效证书某些测试证书在手机上验证失败后端不主动断空闲连接服务端要配合心跳客户端还在界面服务端已经关了连接IP 地址不能使用腾讯侧要求域名方式访问调试时写了 IP 地址生产环境直接失败并发连接数全平台共用一条连接页面各自连接导致触顶我自己的习惯是开发阶段就在本地配一个带合法证书的域名映射把研发环境也走wss://这样开发、测试、生产走的是同一套协议可以大大减少“开发时正常、上线后异常”的幺蛾子。7.2 真机测试中的环境差异和排查方法微信开发者工具里 WebSocket 一切正常不代表真机正常。工具里的网络环境、证书校验、连接后台策略都和真机不一样。所以 WebSocket 相关改动至少要在 Android 和 iOS 真机上各跑一段完整流程包括弱网、切后台、切网络。排查时第一步是看连接生命周期日志。在开发者工具里打开调试模式看到onOpen与onClose的出现时机在真机上用 vConsole 插件看 WebSocket 日志。如果连接成功但收不到消息先确认是不是域名白名单的问题如果连接一直失败抓包看握手响应。很多后端团队在排查小程序 WebSocket 问题时会忘了小程序要求第一次握手必须带Origin头但 uni-app 在 send 事件里并没有暴露太多细节此时最有效的办法是让后端同学查看 WebSocket 握手阶段的日志而不是光盯着应用层消息看。7.3 我踩过几次坑之后的默认参数如果让我给一份可以直接抄的默认参数我的基线配置是心跳间隔 30 秒服务端接口空闲超时尽量设置在 70 到 90 秒保证客户端两次心跳之间有充分的时间差。重连起点 1 秒指数退避按 2 倍递增封顶 10 秒最大重连次数 8 次。发送队列上限 100 条超出丢弃并提示用户。页面订阅统一在onUnload里解除订阅函数引用必须提前存到this上。后台回前台时检查连接状态不是open就重新 connect。这套配置并不是唯一正确答案但能覆盖绝大多数消息类小程序的需求。最终参数必须根据你自己的业务场景做压测比如你服务的用户量、服务端网关的类型、业务的实时性要求都会影响心跳和重连策略。只是有了这些基线数值至少不会在连接稳定性这个环节翻车。我后来每做一个新的实时项目都会先把这套配置放进去跑一轮再根据埋点微调这已经是我个人比较固定的套路了。