即时通讯前端核心模块:消息状态管理与断线重连实践
在即时通讯项目里emj.js 这个文件往往不是入口也不是 UI却是整个消息链路的枢纽。刚开始接触 IM 前端的人容易把注意力放在输入框、气泡、未读角标这些看得见的东西上反复折腾布局和动画等真正联调起长连接才发现大量问题都出在同一个地方消息状态没有被统一管理。所以我想把围绕 emj.js 这类核心文件的设计思路和踩坑经验整理出来给正在写聊天功能、以及想重构旧 IM 工程的人作参考。内容里不包含某个具体轮子的安装步骤重点讲清楚消息模块的职责边界、设计结构和容易出现事故的地方。1. 为什么要在项目里单独维护一个 emj.js消息逻辑的中心化边界一个聊天产品表面上只有会话列表、聊天窗口和系统通知几个页面但底层要维护的状态远比你想象的复杂。连接是否在线、待发送队列里有什么、哪些消息还在等确认、某个会话最新一条消息的序号是多少、未读数该加还是该减这些状态如果散落在组件里过两周就会变成谁也改不动的毛线团。1.1 一个聊天界面背后藏着多少“公共状态”先列举一下我实际维护过的 IM 前端里几乎每个聊天页面都会碰到的状态连接状态当前是空闲、连接中、在线、离线还是正在重连发送队列用户已经点出发送但还没到达服务端的消息等待确认表发出去等 ack 的消息以及它们的重试次数消息去重账本收到过哪些消息 ID避免重复插入会话水位线每个会话本地已接收到的最新消息序号未读计数每个会话和总体的未读数量。这些状态有一个共同特点它们不属于某个具体页面而属于整个即时通讯应用。如果聊天窗口维护一份连接对象会话列表又维护一份系统通知再维护一份那三个页面同时打开时就会有三份互相独立的连接状态。断网恢复时每一份都会各自重连、各自补拉于是服务端看到的是同一个用户在短时间内反复建连、重复拉消息。这个场景一点都不罕见尤其是把 IM 代码从一个页面复制到另一个页面再改一改就上线的项目。所以我在建议别人搭建聊天模块时第一件事往往不是写 UI而是先创建全局唯一的核心实例。在这些工程里这个实例经常被放在名为 emj.js 的文件里。它的职责是“所有消息状态的唯一来源”。界面组件不直接持有 socket、不自己维护队列只向这个核心实例发送指令、订阅事件。到这里中心化的边界就清楚了网络和消息归核心管UI 归组件管。1.2 让 UI 只表达“意图”不让组件直接碰网络帧中心化之后第一收益是数据流变得可预测。用户想发一条文本消息聊天窗口只需要调用核心模块提供的方法// 不推荐的组件写法直接把原始帧怼到 socket 上 chatSocket.emit(message, { type: 1, content: inputValue, sessionId: currentSessionId }); // 推荐写法组件只表达意图 emj.sendMessage({ sessionId: currentSessionId, contentType: text, content: inputValue });这两种写法的差别短期看不出来改几次需求就能体会到。前者要求每个聊天页面都清楚消息帧的字段结构、序列化方式和发送时机后者把这一切都收敛到 emj.js 内部。换协议、加字段、做本地草稿缓存时UI 层一行都不用动。当然中心化不等于把所有东西都塞进同一个文件。emj.js 应该守住自己的边界连接、发送、接收、重连、补拉、去重、事件分发。至于消息内容是文本、图片还是自定义卡片那属于业务层不建议全部堆在核心文件里。换句话说emj.js 是一个“消息管道”不是“业务收纳箱”。这个边界如果一开始划得清楚后面扩展新消息类型时会非常舒服。2. emj.js 的核心骨架状态机、事件总线与双消息队列聊完为什么再看看怎么设计。一个能落地的 emj.js 内部通常不是一堆零散的函数而是三个互相配合的部分连接状态机、事件总线、消息队列。2.1 一个能落地的类结构先给一个简化版的骨架后面所有讨论都围绕这个结构展开class EmjChatCore { constructor(options) { this.connection { status: idle, retryCount: 0 }; this.outbox new Map(); // 待发送/待确认的消息 this.inboxBuffer []; // 收到但还没分发的消息 this.seenMessages new Set(); // 已去重的消息 ID this.sessionWatermark new Map(); // sessionId - 最大 seq this.syncVersion null; this.eventBus new EventEmitter(); } connect(token) { /* 建立长连接进入 connecting */ } sendMessage(payload) { /* 封装、幂等、入队、发送 */ } markRead(sessionId, seq) { /* 记录已读水位并上报 */ } _handleFrame(frame) { /* 协议解析后的入口 */ } _dispatch(type, payload) { /* 事件广播 */ } }这里的关键不是类的写法而是职责划分。outbox 管“本地发出去的、还没被服务端确认”的消息inboxBuffer 管“从网络收到但还没正式分发给 UI”的消息eventBus 负责把消息事件告诉所有关心它的组件sessionWatermark 记录各个会话的消息进度。四个结构各管一摊消息流的调试就会轻松很多。2.2 连接状态机为什么不是 online/offline 两个布尔值很多即时通讯前端在初期会用isConnected这样一个布尔值表示连接状态。用一段时间就会发现问题用户看到的是“连接正常”实际上已经处于重连的第 8 次退避UI 想展示“正在重连中”却没有单独的状态可区分。我通常建议至少保留五个状态idle初始、connecting连接中、online在线、offline已掉线、reconnecting正在重连。状态流转需要符合明确规则idle - connecting主动连接时connecting - online收到服务端连接成功响应online - offline网络断开或心跳超时offline - reconnecting网络恢复或达到首次重连条件reconnecting - online重连成功reconnecting - offline连续重连达到上限进入离线等待用户操作。状态机的价值在于UI 可以通过监听conn:change事件拿到完整的状态描述来渲染提示核心模块也可以通过状态判断当前能不能发消息、要不要做补拉。比如在offline状态下发送接口仍然可以收下用户的消息放进 outbox但不会真的推给网络这样用户输入的内容不会因为断网而丢失。2.3 事件总线的稳定事件协议少用回调多订阅即时通讯业务有很强的“多页面联动”特征发送成功要更新会话列表收到新消息要更新当前聊天窗口断线重连要刷新全局状态。如果这些联动都用回调来串很容易出现 A 页面回调里调用 B 页面方法的互相依赖。换成事件总线之后订阅方和发布方彻底解耦。我会在 emj.js 里维护一张稳定的事件表例如事件名载荷触发时机conn:statechange{ status, retryCount }连接状态变化时chat:newmessage{ sessionId, message, unreadCount }收到新消息并完成去重后chat:messageack{ clientMessageId, serverSeq }服务端确认消息成功时chat:outboxchange{ clientMessageId, state }发送队列状态变化时chat:syncdone{ sessionId, fromSeq, toSeq }一次增量补拉完成时这些事件命名遵循作用域:动作的规律方便搜索也方便统一管理。UI 组件在创建时订阅在销毁时调用返回的unsubscribe函数解绑。事件契约一旦定下来UI 层和核心层就可以并行开发这也是中心化结构的一个隐藏优势。3. 断线重连与消息补拉emj.js 最容易被写崩的三兄弟即时通讯真正考验工程质量的地方不是在线时消息收发而是断断续续的网络环境下能不能保证消息不丢、不乱、不重。重连、心跳、补拉这三块几乎所有出问题的线上事故都和它们有关。3.1 心跳间隔与超时踢除的关系服务端通常会在一段时间内没收到任何数据包时判定连接为死连接并踢除。这个时间叫空闲超时也可以叫踢除时间。客户端的心跳间隔必须小于这个时间好让服务端持续刷新最后活跃时间。比较稳妥的经验是心跳间隔设置为服务端踢除时间的四分之一到三分之一。举个例子如果服务端 60 秒没收到数据就断连那心跳间隔建议在 15 到 20 秒之间。设置成 50 秒也许能省一点流量但一次网络抖动就可能让连接被服务端提前清理得不偿失。此外心跳不能只发不收客户端要记录“最后收到 pong 的时间”。连续几个心跳周期都没有 pong状态机就要从online转到offline然后进入重连流程。3.2 重连退避与抖动防止客户端自灭断线之后立刻重连是最自然的想法在多人同时在线的场景里也是最危险的想法。极端情况下网络故障恢复后大量客户端同时重连服务端瞬间压力陡增于是又打挂一批连接形成雪崩。比较可靠的策略是指数退避加随机抖动function getReconnectDelay(retryCount, base 1000, max 30000) { const exponential Math.min(base * Math.pow(2, retryCount), max); const jitter Math.floor(Math.random() * 500); return exponential jitter; }每一次失败的尝试都把退避时间翻倍直到达到上限同时加入 0 到 500 毫秒的随机量让不同客户端的重连时间错开。还有两个原则要写死在代码里一是账号被服务端主动踢下线时不要自动重连二是用户手动切换账号时先把旧连接彻底关闭再走新流程。这两条不做很容易出现 A 账号掉了、B 账号又连上来事件却还在往 A 的页面发。3.3 重连成功之后不是万事大吉seq 缺口与增量补拉重连成功只代表“通道恢复了”不代表“消息对上了”。如果本地还停在上一次的 seq服务端已经有新消息进来客户端就必须做增量补拉否则聊天窗口会缺消息。具体做法是为每个会话维护一个本地水位线lastSeq。重连成功后先做一次会话快照同步确认各个会话的版本号然后对打开的活跃会话向服务端请求“从本地 lastSeq1 开始的增量消息”。服务端返回消息列表和最新的serverSeq本地按消息序号写回并更新水位线。这一步最容易出的问题是重复补拉。如果补拉请求还没返回又来一次重连第二次补拉可能把同一批消息再拉一遍于是聊天窗口里出现同一条消息被插入两次。解决办法是补拉期间设置一个“该会话正在补拉”的标记相同时间窗口内的重复请求直接丢弃同时配合seenMessages集合按消息 ID 做最终去重。4. 发送链路上的时序与幂等从客户端消息 ID 说起聊完接收侧再看发送侧。即时通讯的发送和普通 HTTP 请求有一个本质区别发送者对“消息到底成没成功”没有确定性感知。网络超时、ack 丢失、服务端处理成功但响应丢了这些情况都可能发生。因此发送链路上必须有一套自己的时序治理方案。4.1 客户端消息 ID全局唯一还是设备唯一一个常见误区让服务端生成消息 ID。问题是消息一旦离开客户端客户端自己的队列和 UI 都需要一个稳定的 ID 来关联状态。更合理的做法是消息 ID 在客户端生成并且真正全局唯一而不是仅仅设备唯一。生成规则可以很朴素function generateClientMessageId() { const timestamp Date.now().toString(36); const randomPart Math.random().toString(36).slice(2, 8); const devicePart (deviceId || unknown).slice(-4); const seq nextMessageSeq().toString(36); return ${timestamp}-${randomPart}-${devicePart}-${seq}; }全局唯一的好处在于跨端去重。用户手机没网时点了三次发送其中一条消息其实已经发出去了等服务端把 ack 发回来手机端只需要根据消息 ID 就能判断出这是哪一条不会把同一条消息显示成两遍。服务端同样可以把消息 ID 作为去重键重复到达的请求直接返回已有 ack不做二次入库。4.2 发送状态机pending、waitingAck、acked 与 failed发送队列里的每一条消息都应该有自己的状态而不是一个简单的布尔值。我在工程里常用这样一张转移表状态含义UI 通常怎么展示pending用户已发送还没推给网络灰条/小圆圈等待waitingAck已推给服务端等确认已发送但未确认acked服务端确认成功正常发送成功failed超时或服务端明确失败红色感叹号canceled用户主动撤回/删除不再展示每条消息在 outbox 里带着这些字段clientMessageId、sessionId、contentType、content、state、retryCount、sentAt。UI 监听chat:outboxchange事件刷新气泡状态。特别注意waitingAck和failed不能被一股脑地合并成“失败”。弱网环境下消息发送的时间可能比较长直接展示失败会诱导用户重复发送反而制造更多重复数据。4.3 ack 超时后的重试保持 ID 不变还是换一个新 IDack 超时后核心模块要不要自动重发我的经验是可以自动重发但次数要限制比如默认重试 3 次而且重发时必须沿用原来的 clientMessageId。这样服务端如果首次已经入库第二次收到相同 ID 就会直接返回 ack本地安全收敛。但有一种情况要生成新 ID用户看到消息一直没确认手动点击“重新编辑”并改动内容后再发送。这时候内容已经变了不能再用原来的去重键否则服务端会认为它和旧消息相同而拒绝更新。简单说同一份内容的自动重发用旧 ID内容变更后的再次发送用新 ID。这既是时序问题也是业务语义问题。5. 多端同步与未读数emj.js 的边界感即时通讯到了后期一定会面临多端问题手机、电脑、网页三个端同时在线消息要在各端之间保持同步。emj.js 作为核心模块在这个阶段最需要克制不能把一切业务逻辑都吞进来。5.1 未读数到底由谁算本地算还是服务端算有些团队喜欢在本地算未读数收到一条新消息就判断当前会话的窗口是不是正在最前面是就不加不是就加一。这套逻辑在单端时代没问题多端时代立刻翻车你在电脑上把某个会话读完了手机上的未读数并不会自动清零。更稳妥的做法是未读数的计算以服务端下发的数据为准本地做展示层校准。服务端在消息同步时附带每个会话的未读计数字段本地维护一个“最后已读水位 lastReadSeq”。收到新消息时如果消息序号大于 lastReadSeq未读数增加用户打开会话并已读后调用markRead(sessionId, seq)上报本地已读水位服务端再下发其他端的同步指令。emj.js 要做的是维护好lastReadSeq和未读数字段而不是自己去数有哪些消息没看。5.2 已读回执的时序先改本地再上报再等广播多端已读还有一个容易踩坑的地方用户用一个端读了消息另一个端必须能在短时间内也变成已读。实现上我习惯在 emj.js 里把markRead设计成两个动作先把本地 lastReadSeq 更新同时把 UI 上的未读数立即清零给用户即时反馈然后才把已读回执异步上报给服务端。服务端确认后会向其他在线端广播“该会话已读到某个 seq”。如果只做本地清零而不上报另一个端永远不知道这个会话已经读过了。如果先上报再改本地用户会感到界面卡顿特别是在弱网环境下。先本地生效、再异步同步是最贴合用户感知的做法。5.3 版本号增量同步与全量降级多端同步的数据不只是消息还有会话列表、置顶状态、免打扰设置、草稿等元数据。这些内容如果每次都全量拉取用户会话多的时候非常浪费流量。我通常用一个syncVersion字段做增量同步每次业务数据变化服务端把版本号递增客户端保存自己当前版本号启动或重连后请求增量 diff。增量同步不是银弹。当版本号差值超过阈值比如客户端落后了几个版本或者本地数据已经被清理过增量合并会变得又慢又容易出错。这时候要果断降级成全量快照拉取。这个判断逻辑放在 emj.js 里对 UI 透明UI 只需要知道数据最终会保持一致不需要关心是增量还是全量。6. 几则值得写进排查手册的实战笔记前五节讲的是设计这一节讲我在实际调试这类消息模块时遇到过的、可以供人少走弯路的几个问题。每个问题都先描述现象再给排查思路。6.1 事件监听泄漏页面切走之后旧页面还在被提起场景用户在聊天窗口和会话列表之间反复切换一段时间后收到一条消息页面上同一气泡出现两次而且页面越切越卡。很多人第一时间怀疑是消息去重没做好查了一圈其实问题出在事件订阅上。组件在初始化时调用了emj.on(chat:newmessage, handler)但页面销毁时没有解绑。于是每次切换页面都残留一个 handler。收到一条消息时所有残留页面都收到了事件自然重复渲染。排查办法很简单在事件分发处打印当前订阅者数量或者用浏览器内存快照观察 Detached listeners。修复方式也很简单让emj.on返回unsubscribe函数强制调用点在destroy或 effect 清理时执行解绑。6.2 重连成功瞬间的旧消息“回档”去重账本要提前准备好场景用户网络断开两分钟期间服务端积累了一批消息。重连成功后补拉接口把消息拉回聊天窗口开始一条一条插入。但插入过程中又收到实时推送的同一批消息于是聊天记录里出现了“同一时间段内容从 lastSeq1 开始重复”的奇观。这个问题的根源在于补拉过程和实时推送过程是两条并行的消息通路没有在入口统一去重。我后来在_handleFrame入口处做了一个强制动作无论消息来自补拉还是实时推送都先判断seenMessages是否已存在messageId或serverSeq存在就丢弃不存在才继续分发。需要注意的是seenMessages不能无限增长要按会话水位线滑动清理只保留最近一段时间的消息 ID。6.3 状态机与 UI 渲染之间的竞态断网瞬间的“发送成功”假象场景用户点击发送网络刚好断开。由于发送方法把消息推进 outbox 后立刻返回UI 把气泡渲染成“已发送”。半秒后底层 socket 的断开事件才触发状态机切到 offline。用户看到的是“发送成功”实际上消息根本没有出网。这个问题的本质是状态更新存在时间差UI 应该以 outbox 里的消息状态为准而不是以“调用发送方法后就算完成”为准。我在状态设计里加了waitingAck并且让 UI 在收到chat:outboxchange事件后才真正渲染对应状态。断网瞬间发生的发送状态会从waitingAck慢慢变成failed或“未确认”不会出现虚假的成功。6.4 一条“消息重复出现”的完整排查链路如果你在项目里遇到了和消息重复相关的问题先别改代码按这条链路往下查先确认 UI 层有没有同一个组件被多次创建、同一个事件订阅被重复注册再查消息分发入口是否所有来源补拉、实时推送、本地回写都走了同一个去重逻辑接着查服务端 ack消息是不是因为客户端重试而重复入库服务端有没有按消息 ID 去重最后查客户端发送队列用户点击发送时是不是按钮回调里调用了两次发送方法。排查的顺序很重要先看接收侧再看分发侧再看服务端最后才怀疑发送侧。不按顺序的话很容易去改一个本来没问题的模块把简单问题越调越乱。在真正收拢即时通讯核心模块时我个人体会最深的一点是大部分问题不是网络导致的而是状态边界不清晰导致的。连接状态只有一个来源、消息使用全局唯一 ID、UI 不直接碰网络帧这三条约定看上去平淡但守住它们的工程在线体验和问题排查都会明显轻松。最后分享一个小技巧每次改动 emj.js 内部逻辑前先准备一个 fake socket 的测试用例喂固定帧序列验证发送、重连、补拉的结果很多看似玄学的问题其实一眼就能看出来。