ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序聊天室开发实战:WebSocket协议、图片上传与消息可靠性设计

微信小程序聊天室开发实战:WebSocket协议、图片上传与消息可靠性设计

做小程序这么久,我一直觉得聊天室是那种“看起来不难、做起来全是坑”的功能。文本收发只是第一步,真正让你头疼的往往是图片消息:选图、压缩、上传、发送、回显、失败重试,任何一个环节没设计好,用户都能立刻感知到“这聊天室好难用”。我最近给一个活动项目做了一套微信小程序聊天室,基于 websocket 做实时消息收发,配合文件上传实现发送图片,前后折腾了小两周,把方案、代码、踩坑过程整理成这篇长文,给准备做类似功能的朋友一点参考。

如果你正要给小程序做聊天室、在线客服、群聊这类功能,或者对 websocket 在小程序端的落地细节感兴趣,这篇文章会从协议设计一直聊到线上问题排查。我不打算贴一堆大而全的代码库,核心是把消息协议、连接管理、图片链路和状态同步这四件事讲透。

1. 整体设计与技术选型

1.1 为什么是 websocket 而不是轮询

聊天室的第一件事是选通信方式。我在项目里直接定了 websocket,因为对比下来确实没有更好的选择。

先说最简单的定时轮询:小程序里 setInterval 每隔 1~2 秒拉一次接口,写起来确实快,但问题非常明显。1000 个用户在线时,服务器每秒要处理近千次 HTTP 请求,绝大多数还是空转,平白消耗流量和带宽。另一个问题是实时性永远差一截,你设 2 秒轮询,消息延迟就在 0~2 秒之间抖动,用户稍微细心一点就能感觉到“对方的回复慢半拍”。

长轮询比纯轮询好一点,客户端发请求后服务端挂起,有消息再返回,没有消息就等超时再重连。这个方案虽然能实现“伪实时”,但每个连接都要频繁建立和释放 HTTP 连接,服务端要维护的“挂起请求队列”也是不小的复杂度,出问题还难排查。

websocket 则是一次 HTTP 握手升级为 TCP 长连接,之后双方随时可以互相推送数据。对小程序的聊天场景来说,延迟低、双向通信、服务端能主动推送,这些特性都是刚需。微信小程序从基础库 1.7.0 就开始支持wx.connectSocket,后续版本又补齐了wx.sendSocketMessage和各个监听回调,属于官方原生能力,不需要引第三方 SDK,兼容性也不用太担心。

这里插一个我踩过的坑:websocket 的 URL 必须是wss://前缀,而且要在小程序后台配置“socket合法域名”。开发阶段可以在开发者工具里勾选“不校验合法域名”,但生产环境不配置后台域名,真机上是连不上的。这个属于基础配置,但很多人一开始就栽在这。

1.2 消息协议设计:先画好信封再写内容

聊天室开发中最容易犯的错误,就是把消息格式想得太简单:{user: '张三', text: '你好'},先跑通再说。结果开发到一半,要加消息类型、要加消息 ID、要加时间戳、要支持图片消息了,前端后端一起改协议,一改就是两三天,改完还得回归测试。

我这次的做法是:动手写第一行业务代码之前,先把消息协议定死。整体用 JSON 承载,做一个带type字段的信封结构。

消息类型我先定了这么几类:

  • chat:普通文本消息
  • image:图片消息
  • system:系统通知,比如有人进房、有人退出
  • heartbeat:心跳消息
  • ack:消息应答,服务端收到消息后回执给发送方

每一类消息的 payload 各自独立,比如文本消息有 content,图片消息有 url、宽高、大小,系统消息有 action 和描述。

这里特别强调一点:消息一定要带msgId,而且必须在客户端生成。我是用“时间戳 + 随机数”拼出来的,保证同一设备短时间内不会重复。有了 msgId 才能做消息去重、失败重发、本地回显与服务端回执的对应。没有 msgId 的协议,后面做重试逻辑会非常痛苦。

另外提个建议,消息协议要做到平台无关。如果你后面要扩展到 App 或者 Web 端,协议结构必须是通用的,别在小程序端把消息对象塞成只有小程序认识的字段。我见过有团队直接把小程序里的 page 对象序列化后发出去,换端直接崩。

1.3 小程序端 websocket 的先天限制

真机调试验证后,我发现小程序对 websocket 有一些“硬规则”,你必须提前知道:

  • 同一小程序同时最多只能有 5 个 websocket 连接。聊天室场景一般一个连接就够,但如果你同时开着开发者工具的真机调试、某些调试面板的预览连接,很容易触发第 6 个连接被拒。
  • 连接数不大,但断开重连的频率要控制好。iOS 上小程序切到后台几秒钟,websocket 会被系统断开,Android 部分机型也有类似问题。所以“感知离线并自动重连”是必须做的,不能指望长连接天然稳定。
  • 域名和证书校验严格。websocket 域名必须是备案过的合法域名,证书不能是自签的,否则真机直接连不上。
  • 不要引入 websocket 的 polyfill 或者第三方封装库。小程序自带 API 够用,外部库容易出现兼容问题,白白增加打包体积。

这些限制不是劝退,而是提醒你:架构上要把断线重连、心跳保活这些机制设计进去,而不是等线上出问题了才补。

2. 后端通信层搭建与核心实现

2.1 连接管理与房间映射

后端我选的是 Node.js + ws 库,没有历史包袱,ws 库高性能且 API 很干净。整体思路是:每个 websocket 连接进入后,先做身份鉴权(从 header 里取 token),然后绑定房间 ID,把 socket 实例存进一个 Map。

Map 的结构大概是这样的:

  • clientMap: uid -> socket 实例
  • roomMap: roomId -> Set(uid)

客户端发消息时,消息里带toUid字段(对聊天室来说就是 roomId),服务端根据toUid找到这个房间的所有 uid,再逐一从 clientMap 里拿到 socket 实例发过去。客户端断开时,从两个 Map 里都移除,同时给同房间其他人推一条 system 消息。

这里有一个很容易被忽略的点:聊天室不只是“广播给所有人”,有时候要做单聊、有时候要做群聊、有时候要做系统消息。所以即使是同一个消息通道,也要在协议层区分好toUid的语义是“用户 ID”还是“房间 ID”。我一开始只设计了房间维度,后面加单聊功能时改协议,来回改了好几处。

房间列表、成员列表、离线消息这些,这次因为是活动临时用,消息量大但不需要长期保存,所以我直接广播不落库。如果你要做正式的 IM 产品,离线消息的存储和拉取需要单独设计,这里不展开。

2.2 心跳保活与断线检测

websocket 虽然是长连接,但中间链路——移动网络、NAT 映射、CDN——随时可能“静默掐断”。表现就是连接看起来还在,但数据已经收发不了了。

我采用的方案是客户端主动发心跳:每 30 秒发一条{type: 'heartbeat'},服务端如果 60 秒内没收到这个房间的连接的任何数据,就认为超时,主动断开连接并清理资源。

这里有两个经验值:

30 秒是我实测下来的一个比较稳的间隔。多数移动网络环境的 NAT 超时在 60 秒左右,取一半余量,既可以及时感知死连接,又不会因为过于频繁的心跳浪费流量。如果你想要更精细的控制,可以做成自适应——连续几次没收到 pong 再动态调低心跳频率。

服务端也可以主动发 ping/pong 帧,ws 库原生支持。但我在真机测试中发现,部分 Android 机型对服务端主动 ping 的响应不稳定,回 pong 有延迟,反倒不如让客户端主动发心跳更简单可控。所以最终方案是:客户端主动发、服务端只收,超时断开。

断线重连这里,我的经验是要用“指数退避 + 延时重连”。踩过的坑是:socket 关闭后立即重连,大概率失败,因为底层连接还没完全释放。我给 onClose 之后设置了 1 秒、2 秒、4 秒……最多 30 秒的退避间隔,同时监听wx.onSocketClose的真实关闭事件再触发重连,实测稳定很多。

2.3 消息广播与并发控制

服务端广播消息有个隐蔽的坑:在 message 事件回调里直接遍历所有 socket 并 send,如果某个 socket 已经死掉但还没触发 close 事件,send 会直接抛异常。我的做法是每次发送前检查socket.readyState === WebSocket.OPEN,并且用 try-catch 包住 send 调用。出错就把这个连接标记为待清理,交给定时器统一移除,不在 send 循环里操作 Map,避免并发问题。

广播顺序也需要考虑。如果一条消息要发给 1000 个用户,一次性把所有 socket 都拿出来循环发,事件循环会被长时间占用,新进连接的消息就会被延迟处理。按房间分桶,逐个连发,实测体验更平滑,内存占用也更可控。

3. 前端消息发送与页面状态管理

3.1 页面结构:消息列表、输入区、底部操作

聊天室页面无非三块:消息列表、输入区、底部操作按钮。用微信小程序原生 WXML 写并不复杂,但真正让人头疼的是消息列表的滚动、输入框的 focus、键盘弹出这些交互细节。

我的做法是scroll-view+scroll-into-view实现“新消息自动滚到底部”。具体来说:给每条消息绑定一个id="msg-${msgId}",把scroll-into-view绑定到最新消息的 id。输入框的adjust-position属性在小程序里默认会随键盘上推,聊天页面要确保这个属性是开着的,否则键盘会把输入框挡住。

体验细节上有一个小坑:首次进入页面时,如果消息还没加载完就直接用scroll-into-view滚到底部,加载完成后页面会跳一下,视觉上像闪屏。我的做法是首次加载先直接定位到底部,等消息渲染完后用scroll-top方式滚,之后再用scroll-into-view。消息量不大的时候,这个优化不做也问题不大,但聊天的消息量一上来,体验差距很明显。

3.2 消息数据流:socket 层与页面层怎么协作

我把 socket 封装成独立模块,暴露connect(roomId)、send(msg)、onMessage(callback)、close()这几个方法。页面 onLoad 时 connect,onUnload 时 close。socket 层不做业务判断,只负责把 JSON 字符串 parse 后抛给回调。

消息进来后,页面要做这几件事:

  1. 判断消息类型,文本的 push 到一个消息数组,图片的组装成图片消息对象再 push。
  2. 用 msgId 做去重:查一下这个 msgId 是否已经在数组里。如果做了 ack 重发,服务端可能重复推送同一条消息,不去重就会看到“同一条消息出现两次”。
  3. 更新 UI 状态,把数据塞进 data 触发 WXML 渲染。

这里遇到一个性能问题:消息列表很长时,每次setData传整个 messages 数组,渲染会明显卡顿。实测超过 200 条后,必须做增量渲染——每次只传新增的那几条消息,列表用wx:for加wx:key做 diff。另外给列表做一个“最多保留最近 300 条”的滚动裁剪,新消息进来时把最老的裁掉,实测滚动流畅度提升非常明显。

3.3 发送流程:本地回显 + 服务端确认

消息发送链路我设计成“先本地回显,后服务端确认”:用户输入内容后,先本地组装消息对象,push 到消息列表(状态标记为 sending),然后通过 socket 发送给服务端,服务端广播给所有在线客户端。发送方收到 ack 回执后,把本地那条消息状态改成 sent,同时更新 UI。

直接做广播给自己有个问题:如果只是单人聊天,服务端广播给自己会重复收到自己发的消息。所以在协议里加上fromUid和toUid,做识别。但我后来换了种方案:不让服务端广播给自己,而是单独给发送者发一条 ack 回执。这两种思路的取舍,我会在后面“常见问题”里详细说。简单讲,ack 回执只负责“发送成功”这个信号,逻辑更清晰,也更好做消息去重。

图片消息的发送链路会比文本长一环:先上传拿到 URL,再通过 websocket 发图片消息。这块内容比较多,我在下一节单开一段细说。

4. 图片上传与图片消息的完整链路

4.1 图片选择与压缩策略

小程序端选图,新 API 是wx.chooseMedia,替代了已废弃的wx.chooseImage,支持从相册选、调用相机、多选。聊天场景推荐sizeType: ['compressed'],微信会在选择时帮你压缩,但实测下来,部分安卓机型的 compressed 图仍然可能有 2~3MB,对聊天来说还是太大。

所以我又做了一步额外压缩:拿到图片临时路径后,用wx.getImageInfo获取宽高,再用 canvas 绘制到最大 1280px 宽(长边限制),导出成 jpeg 质量 0.7。

这样做的收益很明显:

  • 图片体积压到 200~500KB,上传更快、更省流量。
  • 聊天列表加载 CDN 图片更流畅。
  • 微信压缩的格式和尺寸不稳定,canvas 压缩的输出更可控。

这里有一个大坑:canvas 导出图片会丢失 EXIF 信息,导致某些手机拍的照片方向不对,出现“竖拍横了”的情况。解决方法是先在wx.getImageInfo里读 orientation,canvas 绘制前做旋转矩阵变换。这个坑我刚开始没注意,后来用户反馈“聊天里的图片方向不对”才追查出来。如果你直接用wx.chooseMedia的原始路径,微信会保留 orientation,但走 canvas 压缩后必须自己处理,千万别漏。

4.2 上传接口与鉴权

图片上传用的是wx.uploadFile,它内部是 multipart/form-data 的 POST 请求。要注意三点:

  • uploadFile 的 URL 必须在小程序后台配置为“uploadFile 合法域名”,和 socket 域名不是同一个配置项,千万别搞混。
  • header 里带上 token,服务端用来解析用户身份和权限。
  • formData 里带 roomId、msgId 等业务参数,这样服务端收到文件回调时,能把这些参数透传到消息中心,不需要额外从 URL 里解析。

上传进度用wx.uploadFile的onProgressUpdate监听,在聊天界面顶部显示进度条。实测下来如果图片比较大,进度条能给用户很强的“操作反馈”,不然光一个 loading 转圈,用户很容易以为卡死了。

4.3 图片消息的发送与回显

图片消息的格式我定为这样:

{ "type": "image", "msgId": "1722330000000_img_999", "fromUid": "user_001", "toUid": "room_activity_001", "timestamp": 1722330000123, "payload": { "url": "https://cdn.example.com/xxx.jpg", "width": 1280, "height": 960, "size": 321456 } }

完整流程是:

  1. 用户选图 → 本地生成 msgId → UI 先显示一个灰色占位图,上面蒙“上传中”的遮罩。
  2. 并行调用wx.uploadFile上传 → 拿到 CDN URL。
  3. 用已经生成的 msgId 组装图片消息,通过 websocket 发送。
  4. 服务端广播图片消息 → 所有客户端比对 msgId,把自己本地那条“上传中”的占位更新为真正的图片。

这里的关键点在于:为什么第一步就把 msgId 生成好?因为重试逻辑需要它。如果上传完成后 websocket 发送失败了,重发时用同一个 msgId,服务端做去重,就不会在房间里出现两条一模一样的图片消息。这个细节不加,测试阶段很难发现,但真实用户在网络抖动时会看到图片重复,体验很糟。

4.4 多图发送与上传队列

聊天室场景经常会一次选多张图片,服务端不限制,但客户端要注意:不要同时发起所有上传,I/O 并发太大会导致部分请求超时。

我的做法是维护一个上传队列,每次最多并发 2 个上传任务,全部完成后统一把消息数组通过一次 websocket 批量发送。这里的“批量发送”就是直接发送一个数组 JSON,服务端拆开后逐条广播。实测这个方案既稳又简单,服务器压力也小。

如果你没有做并发控制,真实场景下 5 张图同时上传,大概率有一两张会卡在超时上,用户只能干等或者重试。

5. 常见问题与排查技巧实录

5.1 问题一:扫码预览时 websocket 一直连不上

这个问题的排查顺序很重要:

  1. 确认小程序后台已配置 socket 合法域名,且域名是wss://且证书有效。
  2. 开发者工具里确认勾选了“不校验合法域名”(仅开发阶段)。
  3. 看开发者工具控制台的连接报错编号,判断是域名没配、证书过期还是连接被拒。
  4. 真机调试时,手机上控制台能看到的报错更直接,优先用它定位。

我遇到最坑的一次:后端 Nginx 配置了 SSL,但只配了 HTTP/1.1,没有显式配置 websocket 的 Upgrade 头。websocket 升级请求走的是Upgrade: websocket头,Nginx 反代时必须显式加上这两行:

location /ws { proxy_pass http://backend_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 3600s; }

少了Upgrade和Connection这两行,开发者工具里可能连得上(因为开发者工具有部分场景不做严格校验),但真机上直接失败。这个差异花了半天才排查出来,写在这里帮大家避坑。

5.2 问题二:图片发送后自己看到“发送失败”,但其他人收到了

这可能是聊天室项目里最让人崩溃的 bug。根因是本地回显状态和服务端确认状态错位:本地把消息置为 sending,上传成功后置为 sent,但服务端广播给自己时因为toUid对不上被过滤,导致自己看到的状态永远是“发送中”或者“发送失败”。

解决方案我上面提到过,就是用 ack 回执:服务端收到消息后,单独给发送者发一条{type: 'ack', msgId: 'xxx'},前端收到 ack 后再把对应消息置为 sent。这样逻辑上非常干净——发送成功与否,只取决于有没有收到 ack。

为什么不靠“服务端广播给自己”?因为广播给自己要额外判断fromUid === myUid,还要做去重;本地回显和广播消息是两个不同的对象,去重逻辑很难覆盖周全。用 ack 回执,只负责发送成功这一个信号,省心很多。

5.3 问题三:微信返回的临时路径不能持久使用

用wx.chooseMedia拿到的是形如http://tmp/xxx.jpg的临时路径,在 iOS 和 Android 上都有生命周期限制,离开页面或小程序关闭就失效了。如果你把临时路径存到本地或者直接发出去,过一段时间再看就变成空白图了。

正确做法是:一律上传后使用 CDN 返回的 URL,本地预览用临时路径没问题,但发给其他人的、存到聊天记录里的,必须是 CDN 地址。

5.4 问题四:消息乱序

websocket 传输本身是有序的——TCP 保证传输顺序,但客户端收到消息后如果做了异步处理,比如图片懒加载、本地消息回显与远端消息合并时处理不当,UI 顺序就可能错乱。

我的做法是服务端广播时带上一个seq序号(单调递增),客户端收到消息后先按 seq 排序再插入列表。因为是单服务实例,seq 可以直接用内存计数器生成。如果你要做多服务器分布式聊天室,seq 就得用分布式 ID 生成器,比如 snowflake 算法,这里不展开,但设计时要提前考虑到。

5.5 问题五:退出页面后 socket 还在

这是一个很隐蔽但很容易踩的坑:小程序页面 onUnload 时如果不主动关闭 socket,连接还会存在,而且消息回调可能继续触发。如果回调里有 setData,但页面已经销毁,直接报错。

我的处理方式是:页面 onHide 时如果不需要保持连接,就停掉心跳定时器,避免误判在线;onUnload 时显式调用 close。同时在回调里做一个page._disconnected = true标志位判断,更新 UI 前先检查这个标志。这个防御性代码成本极低,但能避免大量“偶发白屏”“偶发报错”的线上问题。

我个人做完这个项目最大的感触是:聊天室这种功能,表面是“能发文本、能发图”,但真正上线后,断线重连、消息去重、上传失败、顺序错乱、页面销毁这些细节,一个都不会少。如果时间允许,强烈建议你在动手写第一个页面之前,先把消息协议、重连策略、消息去重方案商量好。协议定死了,后面所有开发都是在按协议填空,越往后越省事。

最后再分享一个小技巧:把聊天室的 socket 监听、重连、心跳、ack 处理这些逻辑统一收敛到一个模块里,页面只关心消息数据和 UI 渲染。这个抽象层做好之后,不管后端是单房间还是多房间,不管将来要不要加表情、加视频,改动成本都会小很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进