☰
uni-app微信小程序WebSocket实战:从选型到稳定运行
2026/10/16 9:22:47 网站建设 项目流程

做实时消息类的小程序需求,最绕不开的一句话就是:到底要不要上 WebSocket?在实际开发里,uni-app 微信小程序项目被问得最多的问题之一,就是“服务端消息怎么才能及时到手机上来”。大多数人听到“推送”两个字,第一反应是 WebSocket,但真上手以后会发现,WebSocket 并不是把地址一填、服务端一发、客户端一收这么简单。它牵扯到连接生命周期、心跳、断线重连、前后台切换、消息协议设计、页面订阅管理等一系列工程问题。这篇文章就围绕 uni-app 微信小程序开发的全流程,把 WebSocket 怎么接、怎么用、怎么用得合理讲清楚。

如果你是第一次做小程序,或者已经在用 uni-app 但 WebSocket 用得比较草率,这篇文章都值得看一遍。我尽量不做概念堆砌,按实际开发顺序走一遍:先决定要不要用长连接,再搭工程、写页面,最后把 WebSocket 的工程化封装和稳定性保障摊开讲。里面每一段代码都是可以直接复制到项目里改一改就能用的。

1. 选型先想清楚:为什么是 uni-app,以及什么场景才真正需要 WebSocket

1.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.json;manifest.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 里不支持div,uni-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 端就是浏览器原生 WebSocket,App 端则走 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。

这套配置并不是唯一正确答案,但能覆盖绝大多数消息类小程序的需求。最终参数必须根据你自己的业务场景做压测,比如你服务的用户量、服务端网关的类型、业务的实时性要求,都会影响心跳和重连策略。只是有了这些基线数值,至少不会在连接稳定性这个环节翻车。我后来每做一个新的实时项目,都会先把这套配置放进去跑一轮,再根据埋点微调,这已经是我个人比较固定的套路了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询