上个月接了个需求要做微信小程序聊天室支持文字消息和图片消息图片要能直接发到聊天流里。团队里一位同事提议先上HTTP轮询2秒拉一次省事。我说这个方案在简单场景下能跑但聊天室里撑不住多少用户。后面我们还是用了“微信小程序聊天室 WebSocket 文件上传发送图片”这套组合。这篇文章就把完整的实现思路、关键代码、以及上线前被反复摩擦的坑都整理出来。想照着做一个能跑的小程序聊天室或者想搞懂WebSocket在小程序里怎么落地的人都适合读。1. 聊天室场景为什么非WebSocket不可1.1 轮询不是不行只是这个场景不合适先说需求本质。聊天室和普通页面最大的区别是消息是双向、高频、实时的。用户A发一条消息房间里的其他用户希望在几百毫秒内看到而不是等下一次轮询。轮询方案大概是这样的客户端每隔2秒请求一次接口服务端把新消息返回。表面上能实现但算一笔账就不好看了。假设一个房间有100人每人2秒拉一次平均每秒就有50个请求打到服务端这50个请求里绝大多数是空轮询——没有新消息白白消耗带宽和服务器连接资源。如果是10个房间同时在线每秒就是500个请求。而且轮询的响应报文带HTTP头、Cookie、各种元数据单次请求的实际传输成本远大于一条消息本身的体积。WebSocket解决的就是这个问题。它通过一次HTTP握手升级为长连接之后服务端可以主动向客户端推送消息客户端随时也能发送消息真正做到了全双工通信。聊天室里一条消息从发出到别人收到延迟大概是毫秒级而且连接建立后每次收发消息的头部开销非常小不需要反复携带Cookie和认证信息。所以结论很直接聊天室这种实时双向交互场景用WebSocket不是追时髦是技术选型上的必然选择。轮询不是不能用而是它在并发、实时性、服务器压力三方面都不划算。1.2 微信小程序WebSocket API的边界与限制小程序把WebSocket封装成了wx.connectSocket这一组API。和浏览器里的new WebSocket()相比调用方式更偏回调风格底层协议还是标准的WebSocket所以服务端不需要做特殊适配只要是支持WebSocket的服务端都能对接。但小程序有几个限制必须在动手前就弄清楚线上环境连接地址必须走wss://协议走ws://会被拦。也就是说服务端必须有TLS证书WebSocket服务要挂在HTTPS容器后面。wx.connectSocket的域名必须配置在小程序后台的“socket合法域名”里上传文件则要配在“uploadFile合法域名”里。两个域名分开配置很多人在这里踩坑。一个小程序可以同时创建多个WebSocket连接但业务上不建议这么做。多处建连会导致消息重复、状态难以管理后面我会详细讲为什么最好做全局单例。另外一个容易混淆的点是图片上传走的是wx.uploadFile聊天消息走的是wx.sendSocketMessage两条通道各管各的。图片不是塞进WebSocket里发而是先上传到服务器拿到URL再把URL通过WebSocket广播出去。这个思路贯穿整个项目后面会拆开细讲。1.3 技术栈与前后端协议设计前提后端我用的是Node.js环境下的ws库轻量、稳定写聊天室足够用。如果你用Java的Netty、Go的gorilla/websocket、Python的websockets协议层都是通用的下面的设计思路完全能搬过去。真正需要提前定死的是消息协议。WebSocket只负责把数据从一个点搬到另一个点不管里面装的是什么。如果前后端各写各的前段发的是{content:你好}后段期待的是{type:chat, data:{...}}联调阶段就会变成互相甩锅的现场。我习惯用这样的统一信封结构{ type: chat, data: {} }type表示消息类型至少包含以下几种chat聊天消息文本或图片都走这个类型ping/pong心跳检测broadcast系统通知比如用户进房、退房error服务端返回的错误信息协议结构一旦定下来后面加新功能就只加枚举不改骨架。这个习惯帮我省掉了大量联调争吵建议你也这么做。2. 小程序端连接生命周期从建立到保活2.1 全局单例连接管理页面别自己连聊天室项目最容易踩的第一个坑就是把WebSocket连接写在Page里。假如你在onLoad里创建连接、在onUnload里关闭连接会出现什么问题页面栈跳转的时候聊天页A退出、聊天页B进入连接会被反复创建和销毁。每创建一次连接服务端那边的旧连接不一定立刻释放客户端的消息监听也会乱掉。更麻烦的是小程序里不止聊天页需要消息消息列表页、会话列表页可能也要订阅新消息通知。每个页面各管各的连接光同步未读数就能写出一堆补丁代码。我的做法是单独维护一个SocketManager单例模块连接只有一份所有页面共用。大致结构如下// socketManager.js class SocketManager { constructor(url) { this.url url; this.task null; this.retryCount 0; this.isManualClose false; this.heartbeatTimer null; this.messageHandlers []; } connect() { this.isManualClose false; this.task wx.connectSocket({ url: this.url, success: () console.log(socket connecting) }); this.task.onOpen(() { this.retryCount 0; this.startHeartbeat(); }); this.task.onMessage((res) { const msg JSON.parse(res.data); this.messageHandlers.forEach(fn fn(msg)); }); this.task.onClose(() { this.stopHeartbeat(); if (!this.isManualClose) { this.scheduleReconnect(); } }); this.task.onError(() { // 错误会在onClose里统一处理 }); } send(obj) { if (this.task this.task.readyState 1) { this.task.send({ data: JSON.stringify(obj) }); } } onMessage(handler) { this.messageHandlers.push(handler); } close() { this.isManualClose true; this.stopHeartbeat(); this.task this.task.close(); } }页面侧只关心两件事发送消息调用manager.send()接收消息调用manager.onMessage()。连接什么时候建、什么时候断由Manager统一负责。这里有个细节页面里注册的onMessage回调在页面卸载时一定要注销否则页面都销毁了回调还挂在Manager里一条消息来了会触发多个页面的渲染逻辑轻则报错重则内存泄漏。2.2 心跳保活与指数退避重连WebSocket连接建立起来之后并不是永远稳稳当当的。移动网络下运营商的NAT设备、代理服务器、甚至小程序自身在切换前后台时都可能把空闲的长连接回收掉。表现是连接表面上还开着实际上消息已经发不过去也收不到了。解决这个问题就靠心跳。客户端每隔一段时间发一个ping包服务端回pong包。如果客户端在限定时间内没收到pong就认为连接已经死亡主动关闭并重连。具体参数我一般这样设置每20秒发一次ping发完ping后5秒内没收到pong判定连接异常主动调用task.close()触发onClose再走重连逻辑心跳实现startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer setInterval(() { this.send({ type: ping }); this.waitPongTimer setTimeout(() { this.task this.task.close(); }, 5000); }, 20000); } stopHeartbeat() { clearInterval(this.heartbeatTimer); clearTimeout(this.waitPongTimer); }收到服务端pong时把waitPongTimer清掉即可。重连不能失败后立刻重试。原因很简单如果服务端刚好宕机重启所有在线的客户端会同时触发重连瞬间的高并发连接请求可能再次把服务端打死。正确做法是指数退避第一次重连等1秒第二次等2秒第三次等4秒以此类推最大不超过30秒。连接成功后退避计数归零。scheduleReconnect() { const delay Math.min(30 * 1000, 1000 * Math.pow(2, this.retryCount)); this.retryCount; setTimeout(() this.connect(), delay); }另外小程序从后台切回前台时App.onShow里也要检查当前连接状态。SocketTask.readyState为0表示连接中1表示已连接2表示正在关闭3表示已关闭。如果状态不是1就主动重连。2.3 消息到达回调的全局分发设计聊天室里的消息不只是聊天页要用。用户可能停留在会话列表页这时新消息要更新未读数用户可能在看历史消息这时新消息要追加到列表底部。如果每处都直接绑定Socket回调回调会越来越多越来越乱。我推荐做一层轻量级的事件分发器SocketManager只负责拆包拆完扔给事件总线业务页面按需订阅。const eventBus { handlers: {}, on(event, fn) { if (!this.handlers[event]) this.handlers[event] []; this.handlers[event].push(fn); }, off(event, fn) { const fns this.handlers[event] || []; const index fns.indexOf(fn); if (index -1) fns.splice(index, 1); }, emit(event, data) { (this.handlers[event] || []).forEach(fn fn(data)); } }; // SocketManager收到消息后 socketManager.onMessage(msg { eventBus.emit(message: msg.type, msg.data); });聊天页里这样订阅onLoad() { this.onChatMessage (data) this.appendMessage(data); eventBus.on(message:chat, this.onChatMessage); } onUnload() { eventBus.off(message:chat, this.onChatMessage); }这套模式本身没什么技术含量但能有效避免多页面消息同步的混乱。尤其是后面加了“图片消息”、系统通知、踢人下线之类的事件事件总线能让你少掉很多头发。3. 发送图片上传通道与实时通道的协作3.1 图片选择与压缩小程序端的内存博弈聊天室发图片第一步是选择图片。小程序端我用的是wx.chooseMedia支持相册选图和拍摄一次可以选多张。选完之后拿到的是临时文件路径这个路径只在当前小程序运行期间有效不适合长期保存。选完图之后必须先压缩。现在的手机随便拍一张照片都有3MB到5MB如果直接把原图上传不仅慢还费流量。聊天场景下图片主要是在手机屏幕上展示压缩到宽度1000像素左右、品质80%肉眼几乎看不出差别体积能压到200KB上下。压缩用wx.compressImagewx.compressImage({ src: tempFilePath, quality: 80, success(res) { const compressedPath res.tempFilePath; // 继续走上传流程 } });如果有特别大的图比如超过5MB我会直接提示用户重新选择。不要相信客户端能处理一切限制输入才是最省事的策略。3.2 wx.uploadFile上传流程与回包设计压缩完成之后走wx.uploadFile把图片传到服务器。这一步是HTTP上传不是WebSocket。function uploadImage(filePath, callback) { wx.uploadFile({ url: https://api.example.com/upload, filePath: filePath, name: file, formData: { senderId: u123, msgId: generateMsgId(), scene: chat }, success(res) { const data JSON.parse(res.data); if (data.code 0) { callback(data.data); } else { callback(null); } }, fail() { callback(null); } }); }服务端收到文件后把文件保存到对象存储或者服务器本地然后返回一个结构化JSON{ code: 0, data: { url: https://cdn.example.com/images/xxxx.jpg, width: 1200, height: 900 } }url是图片的访问地址width和height是图片尺寸。为什么要回传尺寸因为聊天界面渲染图片消息时需要提前知道宽高比来占位不然消息列表会在图片加载完成后突然跳动体验很差。需要注意整个上传过程必须保证url是HTTPS的否则小程序里图片加载不出来。如果你暂时没有对象存储放服务器本地目录也行但要用一个支持HTTPS的静态资源域名来访问。3.3 图片消息的WebSocket推送与三态展示上传拿到URL之后才轮到WebSocket登场。客户端把图片消息发给服务端{ type: chat, data: { msgId: m001, from: u123, content: { type: image, url: https://cdn.example.com/images/xxxx.jpg, width: 1200, height: 900 }, timestamp: 1730000000000 } }这里可以对比一下“为什么不用WebSocket直接传图片二进制”。二进制数据塞进WebSocket消息里一条几MB的图片会让消息通道瞬间变重所有在线用户的连接都会被拖慢而且断点续传、失败重试都很难做。先上传拿URL再通过WebSocket只传一段很轻的JSON连接始终轻盈图片渲染还能直接走CDN缓存一举多得。客户端展示图片消息时我建议做成三态发送中、已发送、发送失败。发送前的处理生成本地临时msgId把消息先插入列表显示为“发送中”上传图片上传成功后通过WebSocket发送图片消息收到服务端ack后把状态更新为“已发送”如果上传或发送失败状态变为“发送失败”允许点击重发这里有一点特别重要msgId必须客户端生成。这样即使网络抖动导致重复提交服务端也能根据msgId去重客户端也能准确匹配哪条消息发送成功了。4. 消息协议与前后端联调细节4.1 统一JSON信封图片和文本一条协议文本消息和图片消息如果各走一套结构联调时前后端各看各的文档很容易错位。我用的是同一套信封只是data.content里的结构不同。文本消息{ type: chat, data: { msgId: m002, from: u456, content: { type: text, text: 你好 }, timestamp: 1730000000001 } }图片消息{ type: chat, data: { msgId: m003, from: u456, content: { type: image, url: https://cdn.example.com/images/xxxx.jpg, width: 1200, height: 900 }, timestamp: 1730000000002 } }服务端和客户端都只判断content.type然后走不同的渲染分支。历史消息的存储结构也完全统一拉取接口只用一套分页参数省掉大量重复代码。4.2 时间戳、消息序号与乱序处理聊天消息排序是个容易被低估的细节。客户端收到消息后直接按接收顺序渲染是省事但补拉历史消息时本地消息和服务端消息混在一起按什么排我见过有人用本地Date.now()排序结果两台手机时间差了2分钟消息顺序完全乱掉。正确做法是所有消息以服务端时间戳为准客户端本地时间只用来做临时展示。服务端在落库时统一生成timestamp客户端排序时只看这个字段。另外分页拉取历史消息时不要用时间戳做游标要用msgId。因为同一毫秒内可能有多条消息时间戳会撞车而msgId是唯一的。客户端记录当前可见的最后一条msgId上拉加载时传lastMsgId服务端返回更早的消息。断线重连后的消息补偿也是这样重连成功后客户端传一个lastMsgId服务端把该ID之后的消息批量推给客户端。这个方案比“拉最近N条”更精准不会漏消息也不会重复渲染。4.3 服务端广播与落库策略服务端收到一条聊天消息后处理顺序应该是先落库再广播。先落库保证消息持久化成功广播过程中即使某个客户端断线重连后也能通过补拉接口取回消息。反过来如果先广播再落库一旦落库失败这条消息就在历史记录里永久消失了。广播时按房间维度过滤。假设一个消息带roomId字段服务端维护一张roomId - Setsocket的映射表收到消息后只给这个房间内的连接推送。单机部署时内存映射表就够了。如果服务端要多实例部署一个房间里的人可能分散连接到不同实例这时就要加一层Redis pub/sub实例A收到消息后发到Redis频道所有订阅了该频道的实例再各自推送给本机连接的用户。这个方案我在项目里实际用过代码量不大但扩展性立刻不一样了。不要在一开始就上Kafka那套重型方案聊天室先保证WebSocket稳定再考虑跨实例消息分发已经足够。5. 真实项目中的坑踩过你才知道5.1 合法域名、wss与开发环境的坑新手做小程序聊天室第一个卡点往往不是代码而是真机调试时连接不上。线上环境wx.connectSocket和wx.uploadFile的域名都必须在小程序后台配置wx.connectSocket对应“socket合法域名”wx.uploadFile对应“uploadFile合法域名”两个域名分别配置。如果服务端只部署了一个域名比如api.example.com那WebSocket连接地址、上传地址、静态图片地址都可以挂在它的子路径下分别按不同合法域名类型配置即可。开发阶段可以在开发者工具里勾选“不校验合法域名”这样不用每次都去后台改配置。但真机预览时必须配好否则报错信息直接写明fail url not in domain list一眼就能认出是这个坑。还有TLS的问题。小程序真机上基本不接受ws://必须wss://。开发环境如果不想买证书可以用自签名证书但真机上也要安装信任否则还是连不上。我在本地方案是直接用一台挂载了证书的服务器反向代理WebSocket服务省去一堆证书信任的破事。5.2 Android/iOS差异化表现与网络切换小程序在Android和iOS上对WebSocket的“照顾”完全不一样。Android手机上很多机型为了省电会在锁屏后掐断长连接。iOS上长连接保留得相对久一些但Wi-Fi和4G/5G切换时TCP连接大概率也会断开。这两种情况都不是代码BUG而是移动网络环境决定的。应对方案分两层第一监听网络状态变化。用wx.onNetworkStatusChange监听网络切换事件切换后延迟1到2秒检查SocketTask.readyState不是1就触发重连。第二监听小程序前后台切换。App.onShow里检查连接状态必要时立即重连。不要等心跳超时那样用户在回到前台后的几秒内会看不到新消息体验很糟糕。还有一个小细节iOS的wx.connectSocket在onClose之后如果紧接着调用connect()偶尔会出现“connectSocket:fail url not in domain list”这种莫名其妙的问题。后来发现是域名配置没问题是旧连接还没完全释放。解决办法是在重连前加一个setTimeout延迟300毫秒再建连让底层连接彻底清理干净。5.3 图片并发上传与文件存储的优化用户发图片经常是连发好几张的。朋友圈式操作选图的时候恨不得一次勾9张。如果9张图同时发起wx.uploadFile既有流量冲击也可能触发小程序的并发限制后续还有可能收到“uploadFile:fail”的报错。我的建议是维护一个上传队列并发数控制在2到3个。队列的实现不复杂class UploadQueue { constructor(maxConcurrent 2) { this.maxConcurrent maxConcurrent; this.running 0; this.queue []; } add(task) { this.queue.push(task); this.next(); } next() { while (this.running this.maxConcurrent this.queue.length) { const task this.queue.shift(); this.running; task().finally(() { this.running--; this.next(); }); } } }每张图片的完整流程是选择 - 压缩 - 入队 - 上传 - 拿URL - 发WebSocket消息。整个流程串行化消息列表里每张图按顺序显示“发送中”用户不会感觉到明显卡顿。存储层面的建议是图片不要放在应用服务器的本机磁盘。聊天图片的访问频次很高本机方案既占磁盘出口带宽也撑不住。正确做法是上传后转存到对象存储访问路径用绑定了自定义域名的HTTPS地址。这样小程序端加载图片能走CDN缓存服务端也没有静态文件IO的压力。最后一个跟图片相关的坑小程序里的image组件加载网络图片时如果URL没有固定宽高比列表渲染会反复抖动。所以服务端回传图片URL时一定要带上width和height客户端渲染时先用宽高比占位图片加载完成后再替换。这一个小改动聊天体验直接上升一个档次。最后分享一个我实际项目里很受益的设计原则聊天室的WebSocket连接里永远不要传大的二进制内容一切消息都通过URL引用。文本走socket图片先上传再广播URL文件走uploadFile通道这样WebSocket连接始终保持轻量稳定图片缓存和CDN扩展也都很方便。后续如果要在聊天室里加语音消息、视频消息这个原则依然适用只需要在上传通道里多做一步转码处理就够了。