在即时通讯项目里,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。重连成功后,先做一次会话快照同步,确认各个会话的版本号;然后对打开的活跃会话,向服务端请求“从本地 lastSeq+1 开始的增量消息”。服务端返回消息列表和最新的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 不变,还是换一个新 ID
ack 超时后,核心模块要不要自动重发?我的经验是:可以自动重发,但次数要限制,比如默认重试 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 重连成功瞬间的旧消息“回档”:去重账本要提前准备好
场景:用户网络断开两分钟,期间服务端积累了一批消息。重连成功后,补拉接口把消息拉回,聊天窗口开始一条一条插入。但插入过程中又收到实时推送的同一批消息,于是聊天记录里出现了“同一时间段内容从 lastSeq+1 开始重复”的奇观。
这个问题的根源在于,补拉过程和实时推送过程是两条并行的消息通路,没有在入口统一去重。我后来在_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 的测试用例,喂固定帧序列验证发送、重连、补拉的结果,很多看似玄学的问题其实一眼就能看出来。