心跳、重连、回执、活跃状态:一个 WebSocket 封装到底替你扛了多少事
项目从轮询切到 WebSocket 那天,我以为最难的部分已经结束了。
new WebSocket(url)一行就能建连,但真正接进业务后,问题才开始出现:断线后怎么恢复、后台标签页要不要继续推送、多个模块怎么共用连接、消息到了该交给谁。平台后来把这些职责收进了一个两百多行的 WebSocket 包,业务接入简单了很多,但读完实现也会发现:有些设计已经成形,有些能力仍只做了一半。这篇文章以真实项目中的平台 WebSocket 封装为样本,把它拆开逐层看:哪些职责值得封装,当前实现具体做到了什么,又有哪些边界和缺陷。包名、业务名称、配置字段和服务地址都做了等效改写与脱敏,不影响对设计和问题的讨论。拆完觉得手痒的话,下一篇我们把这个骨架亲手写出来。
🎯 本文目标
读完你应该能:说出裸用 WebSocket 会踩的五个坑;理解"topic + content"消息协议为什么是这层封装的地基;分清"发送心跳"与"检测半开连接"、"收到回执"与"可靠投递"之间的差距;看懂重连、消息分发和页面活跃状态同步的实现要点;并且能回答那个面试高频问题——"你们项目里 WebSocket 是怎么封装的,为什么值得封装"。
从轮询到 WebSocket:只是换了根管子
先交代背景。旧方案是轮询:前端每隔几秒发一次 HTTP 请求问服务端"有新消息吗"。它的四个问题是老生常谈:
| 问题 | 表现 |
|---|---|
| 数据延迟 | 两次轮询之间的消息,最坏要等一个完整周期才能看到 |
| 请求量浪费 | 没有消息的轮询占绝大多数,服务端白扛流量 |
| 单向通信 | 只能前端问、服务端答,服务端没有主动开口的通道 |
| 能力天花板 | 消息通知、声音提醒基本是极限,"进度条实时更新"这种事想都别想 |
换成 WebSocket,四个问题看起来都解决了:服务端可以主动推、连接保持复用、双向通信。但如果你的用法是"每个页面自己 new WebSocket,自己 onmessage",那么问题没有消失,只是从"拉不到"变成了"管不住"。
这是这篇文章想说的核心:WebSocket 解决的是传输问题,而工程上真正的难点是连接管理与消息治理。 前者浏览器原生 API 一行搞定,后者需要一个封装。
先裸写一遍,看看坑具体在哪
假设系统里有两个页面要用实时消息:一个全局消息中心(右上角铃铛),一个 AI 对话页(回答流式吐字)。裸写大概是这样:
// 消息中心页
const ws1 = new WebSocket(`wss://example.com/ws?token=${token}`)
ws1.onmessage = (e) => {
const data = JSON.parse(e.data)
// 这条消息是弹窗?声音?还是进度条?
// 它是发给我的,还是广播给所有人的?
// 如果我这条弹窗已经弹过了,怎么去重?
}
ws1.onclose = () => {
// 网络抖了一下?服务端重启了?要不要重连?连几次?隔多久?
}// AI 对话页——再复制一份几乎相同的代码
const ws2 = new WebSocket(`wss://example.com/ws?token=${token}`)
ws2.onmessage = (e) => { /* 又写一遍 JSON.parse 和分发逻辑 */ }
ws2.onclose = () => { /* 又写一遍重连逻辑,而且和隔壁那版行为不一致 */ }把坑列出来,正好五个:
- 重复建连。两个页面两条连接,服务端连接数随页面数线性增长;在微前端或 iframe 场景里,同一个人可能同时挂着四五条等价连接。
- 断线无感知。
onclose触发了,但没有任何机制告诉你"网络恢复后要重连";也没有次数、间隔、防并发的概念——手写的重连很容易变成重连风暴。 - 后台空耗。用户把标签页切到后台,连接、心跳照常运转;如果服务端仍按前台优先级推送,就会浪费计算和带宽。是否释放长连接本身,则取决于客户端和服务端的共同策略。
- 消息无路由。所有消息挤在同一个
onmessage里,靠if-else猜类型;新增一种消息就要改所有订阅方的分发逻辑。 - 投递无凭据。消息发出去之后,发送方永远不知道对方收没收到;前端收到了,服务端也不知道,"可靠推送"无从谈起。
接下来逐层看封装是怎么把这五个坑填掉的。先给全貌。
这层封装长什么样:七个方法,一个单例
我项目里用的这个包,对外暴露的东西少得反常——一个默认导出的对象,七个成员:
| 成员 | 作用 |
|---|---|
init(options) | 初始化并建立连接(推导地址、携带 token) |
subscribe(topic, callback) | 订阅某个主题的消息 |
send(msg) | 发送一条消息(仅连接就绪时生效) |
watchVisibility(routerOptions) | 注册标签页可见性变化处理器 |
topics | 初始化时导出的回调表引用(重置后可能与内部状态脱节) |
isReady | 初始化时的就绪状态快照,并非实时状态 |
destroy() | 重置内部数据和连接引用;当前实现并未完整关闭资源 |
内部是一个模块级单例 socketContext:持有一个 WebSocket 实例、回调表 callbacks、以及一份带默认值的配置(心跳间隔 10s、重连间隔 5s、重连次数 5 次)。在同一个 JavaScript 运行环境、同一份模块实例里,不同业务模块 import 到的是同一个对象;但不同浏览器标签页、iframe,或者分别打包了依赖的微前端应用,仍可能各有一份实例。模块单例不能自动升级成浏览器级单例。
连接的生命周期大概是这样的:
下面按"填坑"的顺序拆四层设计:消息协议、心跳与重连、回执、页面生命周期。需要提前说明:这里既会讲设计意图,也会指出当前实现与设计意图之间的距离。
第一层:消息协议——先让消息"有名字",其他一切才有意义
封装做的第一件事不是管连接,而是定义消息格式。所有走这条连接的消息,不管是业务消息还是系统消息,都是同一个结构:
{
"topic": "notification",
"content": {
"id": "1930xxxxxxxxxxxxx",
"generated": 1724820000000,
"type": "dialog",
"title": "任务完成",
"level": "info",
"message": "导出报告已生成,可前往下载"
}
}三个字段各司其职:
topic是路由地址。消息要推给哪个模块、干什么用,全靠它标识。notification是全局通知,ai-chat-stream可以是 AI 流式回答,task-progress可以是任务进度。content是消息体,项目协议通常包含id(消息 ID)、generated(生成时间)、type(消息类型,与 topic 配合区分同一主题下的不同形态,比如dialog弹窗通知和sound声音通知)。消息 ID 是否由雪花算法生成,是服务端协议的职责,不是这个前端包提供的能力。- 系统消息和业务消息同构。心跳是
{ topic: "heartbeat", content: { type: "system" } },用户激活状态是{ topic: "active", ... },回执是{ topic: "receive", content: { receiveMessageId } }。系统通道没有另起一套机制,它就是几个保留的 topic。
有了这个格式,前端的 onmessage 从"猜谜"变成"查表":
onMessage(evt) {
const result = JSON.parse(evt.data)
// 收到即回执(下一节展开)
send({ topic: 'receive', content: { type: 'system', receiveMessageId: result.content.id } })
const callback = callbacks[result.topic]
if (callback) callback(result)
else console.warn(`WsSocket 消息事件[${result.topic}]未绑定...`)
}业务侧的订阅退化成一行:
import socket from 'platform-websocket'
socket.subscribe('notification', (msg) => {
const { data: { content } } = msg
notify({ title: content.title, message: content.message, type: content.level })
})对比裸写版里那个越写越长的 if-else,区别在于:分发逻辑从每个订阅方收拢到了连接层,新增一种消息只需要一个新 topic,任何老代码都不用动。
协议的另一端:topic 注册机制
在这个项目的服务端协议里,topic 还采用了注册制:新增业务想用一个自定义 topic,需要先注册,声明这个 topic 下允许传递哪些 Content 子类型(比如 notification 下只允许 dialog 和 sound 两种)。这属于项目服务端的契约治理,不是前端连接封装自动提供的能力。
注册制的价值,是把前后端的消息契约从文档里的君子协定变成运行时约束。再配合服务端生成的全局唯一 content.id,去重、回执、审计才有稳定的关联键。是否真的采用雪花算法、是否在运行时强校验,需要以对应服务端实现为准。
这一层填的是坑 4(消息无路由),顺便给了坑 5(投递无凭据)一个地基。
第二层:心跳与重连——长连接的"脏活"集中到一处
心跳:先证明“我还在发消息”
心跳的实现不复杂:连接打开后启动一个 10 秒的定时器,每次发一条 topic: "heartbeat" 的系统消息:
heartbeat() {
if (!readyStatus) return
clearInterval(heartbeatTimer)
heartbeatTimer = setInterval(() => {
send({ topic: 'heartbeat', content: { type: 'system' } })
}, 10000)
}网线拔掉、NAT 映射失效、笔记本合盖再打开时,TCP 层可能迟迟感知不到异常,浏览器端的 onclose 也未必马上触发,这就是常说的半开连接。
但要分清两件事:当前实现只是定时发送心跳,没有等待 pong、记录最后响应时间,也没有在超时后主动关闭连接。因此它能让服务端知道客户端最近是否活跃,也可能避免中间设备因为连接长期空闲而回收它;客户端自己却不能仅凭这段代码可靠判定半开连接。完整方案还需要“发出 heartbeat → 收到 pong/回执 → 超过 N 个周期未收到则主动断开并重连”的闭环。
重连:次数、间隔有了,锁只做了一半
重连的完整逻辑浓缩后是这样(等效简化):
onClose(evt) {
if ((evt.code === 1006 || evt.code === 1000) && evt.target.readyState !== 1) {
clearInterval(heartbeatTimer) // 先停心跳,避免重连期间空转
reconnect()
}
readyStatus = evt.target.readyState === 1
}
reconnect() {
const r = config.reconnect // { lockReconnect, time: 5000, number: 5 }
if (r.lockReconnect || r.number === 0) return // 已有重连在排队 / 额度用尽
clearTimeout(r.setTimeout)
r.setTimeout = setTimeout(() => {
createSocketInstance()
if (!readyStatus) {
r.lockReconnect = false
r.number--
}
}, r.time)
}四个细节值得停一下:
- 只在
onClose的 1006 / 1000 分支停止心跳并重连。1006 表示异常关闭;1000 表示正常关闭,对它无条件重连值得商榷。生产实现通常还要区分用户主动退出、鉴权失败、服务端主动踢人和可恢复的网络断线。 lockReconnect字段没有真正上锁。当前实现会检查它,却从未在排队前把它设为true。真正减少重复任务的是clearTimeout(r.setTimeout):新任务进来前先取消旧任务。它能让同一时刻通常只保留一个待执行定时器,但不能把这段实现称为完整的重连锁。- 额度不是“失败后才扣”。
new WebSocket()是异步握手,createSocketInstance()返回后立即检查readyStatus,此时通常还是false,所以每次发起重连都会马上扣一次额度。连接成功后的onOpen也没有自动把额度恢复到 5。 - 固定 5 秒、最多 5 次只是一个止损线。它避免无限重试,但没有指数退避和随机抖动;大量客户端同时掉线时,仍可能在相同时间集中重连。
这一层开始解决坑 2(断线恢复),但还没有做到完整的半开检测和稳健重连。封装的价值也正在这里:所有业务共享一套策略,缺陷可以在一处修正;反过来,如果底层实现有问题,影响面也会覆盖所有使用方,所以“统一”不等于“天然正确”。
第三层:回执——"收到了"说出口,可靠推送才有起点
onMessage 里我故意留了一行没讲:
send({ topic: 'receive', content: { type: 'system', receiveMessageId: result.content.id } })每收到一条消息,前端自动回一条携带该消息 id 的回执,业务代码完全无感。别小看这一行:它让服务端有机会知道客户端是否已经收到并解析消息,是 at-least-once 的必要地基,但仅凭一个 ACK 还不能得到 at-least-once。服务端还要保存未确认消息、设置 ACK 超时、执行重发或重连补发,客户端也要按消息 ID 去重,这条链路才闭合。
当前实现还有一个边界:它会给所有入站消息回执,没有排除 heartbeat、receive 等系统 topic,也没有先校验 content.id。如果对端把 receive 再原样推回,就可能产生回执循环。更稳妥的做法是只对带有效 ID、且协议约定需要确认的业务消息发送 ACK。
回执放在连接层自动完成而不是交给业务,理由和心跳一样:这是连接的性质,不是业务的职责。业务方想自己确认处理结果,可以在自己的 topic 回调里再回业务级应答——两层确认互不冲突。
第四层:跟着页面生命周期走——活跃状态与连接归属
visibilitychange:同步活跃状态,而不是自动断开
这层封装利用 visibilitychange 把页面是否活跃同步给服务端:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && !isConnecting) {
// 标签页进入后台:告诉服务端"我离开了",并重置重连额度备用
send({ topic: 'inactive', content: { type: 'system' } })
resetReconnectParams()
} else {
// 回到前台:连着就报个 active,断了就重新连接
readyStatus ? sendActive() : initAndReconnect()
}
})注意它的做法是通知而非断开:切后台时发一条 inactive,回到前台再发 active。配合一个“常连路由白名单”(比如大屏监控页即使后台也保持活跃),服务端就可以针对非活跃页面降低推送频率或优先级。
但客户端并没有停止心跳,也没有关闭 WebSocket,所以它不会直接减少服务端连接数。是否真的“节能”,取决于服务端收到 inactive 后采取了什么动作。准确地说,这层能力是页面活跃状态同步,是服务端节能策略的信号入口。
isTop / parentHasConnection:微前端里由谁建连
初始化时还有一段判定:
if (connectionMode === 'shared') {
// 只有顶层窗口、或父级没加载过 WebSocket 时,才由我建连
if (isTop || !parentHasConnection) {
socket.init(...)
}
}微前端和 iframe 场景下,子应用可以通过“我是不是顶层窗口 / 父级是否标记为已经加载连接”的判定,避免自己再次初始化连接。但它只解决“谁来建连”,没有提供跨 window 的消息转发:子应用本地注册的回调不会自动跑到父窗口的回调表里。
因此这不是完整的“整个浏览器只连一条”。要在多个标签页或 iframe 之间真正共享连接,还需要 postMessage、BroadcastChannel、SharedWorker,或者由微前端框架提供消息桥。模块级单例只保证同一运行时、同一模块实例内复用。
这一层开始处理坑 1(重复建连)和坑 3(后台空耗),并把地址拼接、token 参数一并收进初始化逻辑。包本身只是把调用方传入的 token 拼到 URL 的 a 查询参数上;是否编码、签名或加密由调用方负责,Base64 编码也不等于加密。协议推导同样要注意输入格式:源码只对精确的 "http" 映射为 ws,传入浏览器常见的 "http:" 会走到 wss 分支。
业务视角:接入一个新推送需求,要写几行代码
四层设计讲完,换个视角——假设你是一个刚接触这个系统的业务开发,产品提了个需求:"任务中心的导出任务,进度要实时更新到页面上的进度条。"
在没有这层封装的项目里,这个需求的隐含工作量是:搞清楚怎么建连、token 从哪来、断线了进度会不会断更、页面切后台要不要管、消息格式跟谁约定……大概率最后"进度实时更新"被悄悄降级成"每 5 秒轮询一次"。
在这层封装之上,工作量是这样的:
第一步,跟后端约定一个新 topic(比如 task-progress)和它的消息类型(ProgressBarContent,带 percent 字段),后端在注册表里声明这个 topic 允许发送的消息类型。
第二步,前端订阅并渲染:
import socket from 'platform-websocket'
socket.subscribe('task-progress', (msg) => {
const { data: { content } } = msg
progress.value = content.percent
})连接通常已经在应用入口建立,业务侧不需要重复实现心跳和重连。不过这里不能省略订阅生命周期:当前实现的 subscribe 是覆盖式注册,也没有公开 unsubscribe。页面卸载时既不能随意调用全局 destroy,也不能自动解除单个 topic,只能由业务额外做一层回调集合与反订阅管理。补上这层之后,消息通知、声音提醒、进度条、图表刷新,在连接层眼里才真正可以统一成“topic + 消息体”。
这就是封装优势最直观的度量方式:接入一个新推送需求的边际成本,从一个"连接管理需求"降级成了一行订阅。 系统的实时能力从"每次都要评估值不值得开连接",变成了"随手可用"。
把优势摆到台面上:封装前后对账
四层拆完,回到标题的问题——这么封装,优势到底是什么?对账表:
| 维度 | 每页裸写 | 用这层封装 |
|---|---|---|
| 接入成本 | 每页 30~80 行连接管理代码 | subscribe 一行 |
| 断网恢复 | 各写各的,行为漂移 | 固定 5s、最多 5 次的统一重连入口;锁与退避仍待完善 |
| 后台标签页 | 无活跃状态语义 | inactive 通知 + 白名单;实际节能依赖服务端 |
| 消息路由 | onmessage 里 if-else 猜 | topic 查表分发,注册制契约 |
| 投递可靠性 | 发完不管 | 自动回执提供确认信号;完整重试与去重依赖两端配合 |
| 多模块 | 容易重复建连 | 同一 JS 运行时内共享模块单例 |
| 跨 iframe/多页签 | 各自拥有运行时 | 仍需消息桥、SharedWorker 等跨上下文方案 |
| 协议演进 | 改一处动全身 | 新 topic 即新能力,老代码零改动 |
表格之外还有三个不太显眼但更值钱的收益。
行为一致性。同一运行时中的业务共享同一份重连、心跳、活跃状态策略,排查问题时可以集中看一处;同时也要记住,底层缺陷会被所有业务一起继承。
演进自由度。因为协议先行,"WebSocket 只能做消息通知"这个天花板被拆掉了——同一个封装上,后来长出了 AI 流式对话(token 级推送)、任务进度条更新、图表实时刷新,全都只是新 topic + 新 Content 子类型,连接层一行未改。这正是当年从轮询升级到 WebSocket 时"不止简单消息通知"的承诺,靠封装兑现了。
演进集中度。心跳、重连、回执集中在连接层,意味着未来增加 pong 超时、指数退避或消息补偿时,可以集中修改。这里更准确的词是“集中治理”:它既让改进一次覆盖全局,也要求连接层接受更严格的测试。
诚实的局限
按惯例,也要说清楚当前实现没做到的事:
- 同名 topic 的回调是覆盖式的。
callbacks[topic] = callback,第二个订阅者会顶掉第一个。多个模块订阅同一个 topic 时必须自己再做一层分发,这是最容易被新人踩中的坑。 - 回执只到"收到",不到"处理"。前端收到消息即回执,业务处理失败服务端并不知道;要业务级确认还得另走应答。
- 重连锁没有真正生效。字段存在,但没有置为
true;固定 5s 也没有指数退避和 jitter,重连额度还会在握手结果明确前扣减。 - 心跳没有响应超时。它只负责发 heartbeat,不能由客户端独立检测半开连接。
- 没有离线消息补偿。断线期间的消息靠服务端基于回执自行重推,封装本身不维护客户端的消息队列。
destroy不是完整销毁。它没有关闭 socket、清除定时器或移除visibilitychange监听器,也没有 topic 级unsubscribe。- 公开状态是快照。导出的
isReady是初始化时的布尔值,不能反映后续连接状态;topics在全局重置后也可能指向旧对象。 - 对消息格式过度信任。
JSON.parse和result.content.id没有异常保护,格式错误的入站消息会直接抛异常。 - 它不是与平台无关的通用包。创建连接时会直接读取平台运行时上下文和产品配置开关,而不是全部通过参数注入;单独复用或测试时要处理这层隐藏耦合。
- 模块级单例不友好测试。想 mock 连接层只能整体替换模块,不如依赖注入的写法好测。
这些局限不否定“值得封装”,但决定了不能把当前版本直接称为生产级答案。抄作业时至少要补齐:真正的重连锁与退避、pong 超时、完整销毁、topic 级反订阅、动态状态读取和入站消息校验。
结语:连接是全局资源,消息是局部事件
最后把这篇的方法论收拢成一句可以迁移的话:
连接是昂贵的全局资源,消息是廉价的局部事件。好的封装,就是让业务代码只看见事件,永远不用操心资源。
这层两百多行的 WebSocket 封装真正有价值的地方,不是每个细节都已经写对,而是它尝试划出一条边界:连接建立、保活、恢复、地址推导放在连接层,业务主要通过 subscribe(topic, callback) 消费事件。当前实现在销毁、反订阅、半开检测和重连控制上仍有缺口,但边界方向是对的,后续也应该沿着这条边界继续修正,而不是让每个页面重新裸写一套。
这条边界感不止适用于 WebSocket。HTTP 请求层(拦截器、重试、取消)、AI 应用里的消息协议层(字节流如何长成有语义的消息),本质都是同一道题:把"资源管理"和"事件消费"分离,再在中间定义一个足够薄的协议。 协议定得薄而定型(一个 topic、一个 id、一个 type),上层就能无限长出新玩法——消息通知长成 AI 流式对话,用的还是同一条管子。
下次有人问你"WebSocket 要不要封装",可以先反问他一句:你的页面上,同时有几双手想抓这条连接?
如果这篇让你有了“我也想写一个”的冲动,那这个直觉是对的——骨架并不难写。下一篇我们用百行左右写出一个更容易测试的最小版本,再通过失败实验看清它离生产级还差什么。
🤔 几个可能被追问的点(点开看)
为什么不用 TCP keepalive,非要应用层心跳? 浏览器 JavaScript 无法直接控制底层 TCP keepalive 参数,而应用层心跳的间隔、消息内容和超时策略都可以按业务控制,还能顺带让服务端记录“最后活跃时间”。但只有“发”没有“等回应”仍不构成完整的半开检测;两端应约定 pong 或回执超时。
重连为什么固定 5 秒,不用指数退避? 固定间隔实现简单,但大量客户端同时断线时容易形成同步重试。更稳妥的做法是指数退避加随机抖动,例如在 0 ~ min(base * 2^n, 30s) 内随机取下一次等待时间,同时在连接成功后恢复额度。固定 5 秒只是当前实现,不代表它是更优策略。
一个 topic 只能挂一个回调,怎么办? 把 callbacks[topic] 从单个函数换成数组,分发时遍历调用,或者干脆用现成的 EventBus。注意补一个"重复订阅去重"和"页面卸载时反订阅",否则会内存泄漏。
回执机制对应消息语义学里的哪一档? 自动回执只提供了 at-least-once 所需的确认信号。还要有服务端未确认消息存储与重发、客户端按 content.id 去重,才能形成完整链路。工程上通常追求“至少投递一次 + 幂等处理”,不要轻易宣称传输层 exactly-once。
token 放 URL 查询参数安全吗? 不理想——查询参数可能进入服务端、代理和监控日志。浏览器原生 WebSocket 建连时不能像普通 HTTP 客户端那样自由设置 Authorization Header,常见方案包括短时签名 URL、首条消息鉴权、受控 Cookie,或在明确协议约束下使用子协议。这个包只负责拼接参数,并不负责编码或加密;对安全要求高的场景,优先考虑短时凭证,并限制未鉴权连接的存活时间。