Skip to content

手写最小 WebSocket 封装:百行左右理解心跳、重连与消息分发 ​

上一篇《心跳、重连、回执、活跃状态:一个 WebSocket 封装到底替你扛了多少事》拆完那层两百多行的封装,一个很自然的念头是:“这些骨架我是不是也能写?”

单拎出心跳、重连、topic 分发,每个都是百字内讲完的知识点;但“能发送心跳”和“能检测半开连接”、“会重连”和“不会制造重连风暴”之间都隔着不少细节。这篇先用百行左右写出一个可运行骨架,再通过实验主动暴露它的缺口,看清最小版和生产级之间的距离。

🎯 本文目标

读完你应该能:独立写出一个带单例、topic 分发、心跳发送、重连和资源清理的最小 WebSocket 封装;用两个实验验证断线重连,并观察“只发心跳”为什么仍然无法可靠判定半开连接;拿着补课清单知道自己的版本离生产级还差哪几步。


动手之前:先确认你真的需要它 ​

写封装是有成本的,动手前先过三道检查,任何一道过关就别写,用现成的就够:

只有服务端到客户端的单向推送——比如纯消息下发,SSE 往往更合适:浏览器 EventSource 内置断线重连,也更容易复用 HTTP 基础设施。服务端仍可能需要发送注释心跳,避免代理因长时间无数据而关闭连接,但客户端连接管理通常比 WebSocket 简单。SSE 和 WebSocket 怎么选,我在流式输出那篇里专门对比过。

消息频率低到分钟级——轮询并没有死透,分钟级的"准实时"用轮询,成本和复杂度都更低。别为了技术正确性上长连接。

只有一个页面、一种消息——通常没必要上升为全局框架,但仍可以做一层很薄的局部封装,集中处理关闭、异常和测试。封装的收益来自复用连接与统一策略,复杂度要跟消费方数量和可靠性要求匹配。

三道检查都不过关——多个模块要消费消息、需要双向通信、频率在秒级——好,那就值得动手了。判断标准一句话:当"连接"成为被多个模块争用的全局资源时,才值得为它做一层治理。


百行左右:把骨架写出来 ​

完整代码如下,保留单例、topic 分发、心跳发送、重连和销毁五个骨架。它仍然不是生产级实现,但每个缺口都会在后文明确标出来:

js
// mini-ws.js —— 最小可用的 WebSocket 封装
function createSocketContext(WebSocketImpl = WebSocket) {
  const ctx = {
    socket: null,
    ready: false,
    callbacks: new Map(),             // topic -> Set<回调>
    heartbeatTimer: null,
    reconnectTimer: null,
    reconnectCount: 0,
    maxReconnectCount: 5,
    manuallyClosed: false,
    url: '',
  }

  ctx.onOpen = () => {
    ctx.ready = true
    ctx.reconnectCount = 0
    ctx.startHeartbeat()
  }

  ctx.onMessage = (evt) => {
    let msg
    try {
      msg = JSON.parse(evt.data)
    } catch {
      return console.warn('收到无法解析的 WebSocket 消息')
    }
    if (!msg || typeof msg !== 'object' || typeof msg.topic !== 'string') {
      return console.warn('收到格式不合法的 WebSocket 消息')
    }

    const systemTopics = new Set(['heartbeat', 'receive'])
    if (msg.content?.id && !systemTopics.has(msg.topic)) {
      ctx.send({
        topic: 'receive',
        content: { type: 'system', receiveMessageId: msg.content.id },
      })
    }

    const listeners = ctx.callbacks.get(msg.topic)
    if (listeners) listeners.forEach((callback) => callback(msg))
    else if (!systemTopics.has(msg.topic)) console.warn(`消息事件[${msg.topic}]未绑定`)
  }

  ctx.onClose = () => {
    ctx.ready = false
    ctx.stopHeartbeat()
    if (!ctx.manuallyClosed) ctx.reconnect()
  }

  ctx.startHeartbeat = () => {
    ctx.stopHeartbeat()
    ctx.heartbeatTimer = setInterval(() => {
      ctx.send({ topic: 'heartbeat', content: { type: 'system' } })
    }, 10000)
  }

  ctx.stopHeartbeat = () => clearInterval(ctx.heartbeatTimer)

  ctx.reconnect = () => {
    if (ctx.reconnectTimer || ctx.reconnectCount >= ctx.maxReconnectCount) return
    const delay = Math.min(1000 * 2 ** ctx.reconnectCount, 30000)
    ctx.reconnectCount++
    ctx.reconnectTimer = setTimeout(() => {
      ctx.reconnectTimer = null
      ctx.connect()
    }, delay + Math.random() * 500)
  }

  ctx.send = (msg) => {
    if (ctx.socket?.readyState === WebSocketImpl.OPEN) {
      ctx.socket.send(JSON.stringify(msg))
    }
  }

  ctx.subscribe = (topic, callback) => {
    if (!ctx.callbacks.has(topic)) ctx.callbacks.set(topic, new Set())
    ctx.callbacks.get(topic).add(callback)
    return () => ctx.callbacks.get(topic)?.delete(callback)
  }

  ctx.connect = (url) => {
    ctx.url = url ?? ctx.url
    ctx.manuallyClosed = false
    if (!ctx.url || [WebSocketImpl.CONNECTING, WebSocketImpl.OPEN].includes(ctx.socket?.readyState)) return
    ctx.socket = new WebSocketImpl(ctx.url)
    ctx.socket.onopen = ctx.onOpen
    ctx.socket.onmessage = ctx.onMessage
    ctx.socket.onclose = ctx.onClose
  }

  ctx.destroy = () => {
    ctx.manuallyClosed = true
    ctx.stopHeartbeat()
    clearTimeout(ctx.reconnectTimer)
    ctx.reconnectTimer = null
    ctx.callbacks.clear()
    const socket = ctx.socket
    ctx.socket = null
    if (socket) {
      socket.onopen = socket.onmessage = socket.onclose = null
      socket.close(1000, 'client destroy')
    }
    ctx.ready = false
  }

  return ctx
}

const socket = createSocketContext()          // 模块级单例
export default socket
// 用法:
// socket.connect('wss://example.com/ws?token=xxx')
// const unsubscribe = socket.subscribe('notification', (msg) => render(msg.content))
// 页面卸载时:unsubscribe()

逐个说清几个“看起来平平无奇,其实是有意为之”的地方。

为什么是模块级单例。createSocketContext() 只在模块加载时调用一次,之后同一 JavaScript 运行时中、引用同一份模块实例的 import 会拿到同一个 socket。它不会自动跨浏览器标签页、iframe 或独立打包的微前端共享连接。构造函数支持注入 WebSocketImpl,则是为了让测试可以传入 fake WebSocket。

为什么 onClose 里先停心跳再重连。断线后心跳定时器如果不清,会在重连等待期继续空转;重连成功后 startHeartbeat 又会开启新定时器。先清理旧定时器,是避免重复心跳最便宜的保险。

为什么重连同时需要锁、退避和额度。reconnectTimer 保证同一时刻只有一个待执行任务;等待时间按 1s、2s、4s……增长,并加一点随机抖动;累计发起 5 次仍未连接成功就停止。连接一旦成功,onOpen 把计数归零。额度的本质是承认一个事实:客户端能覆盖短暂网络抖动,却不能靠无限握手修复服务端故障。

为什么回调使用 Map<string, Set<Function>>。同一个 topic 可能有多个消费者,Set 避免后订阅者覆盖先订阅者;subscribe 返回反订阅函数,让页面卸载时可以清理自己的回调,而不必销毁整条全局连接。

为什么回执不能无条件发送。示例只给带有效 content.id 的非系统消息回 ACK,避免 receive 消息再次触发 receive。ACK 只表示连接层已收到消息;是否处理成功,仍应由业务协议单独确认。

为什么消息必须走 { topic, content } 协议。解析之后按 topic 查表分发,分发逻辑收敛在连接层,业务只跟 topic 打交道。不过“新增 topic 零成本”只是对连接层而言,前后端协议、类型定义、权限和监控仍然需要同步演进。


跑通它:两个实验 ​

配任何一个 ws 服务就能跑。最省事的是 Node 起 ws 库的受控 echo 服务。注意不能把 ACK 也原样回发,否则客户端和服务端会互相制造 receive 消息:

js
// echo-server.js —— node echo-server.js
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 8765 })
wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    const msg = JSON.parse(data.toString())
    console.log('收到:', msg)
    if (msg.topic === 'receive') return // ACK 到此为止,不再 echo
    ws.send(JSON.stringify(msg))
  })
})

跑通之后,别急着庆祝,做两个实验——比读十篇文章管用:

实验一:断线重连。连接建立后杀掉服务进程,观察 onClose 触发,随后客户端按 1s、2s、4s……退避重连;服务恢复后重新触发 onOpen,心跳重启,重连计数归零。再开十个浏览器标签页做一次,你会看到模块单例并不能跨标签页复用连接,也能直观看到 jitter 为什么有价值。

实验二:证明“只发心跳”仍然不够。修改服务端:收到 heartbeat 后直接 return,但保持 WebSocket 不关闭。客户端仍会每秒或每 10 秒发送心跳,ctx.ready 也一直是 true,不会触发重连。这个结果恰好证明:发送心跳只能提供探测信号;要检测半开连接,客户端还必须等待 heartbeat ACK/pong,并在超过 N 个周期没有响应时主动 close()。

⚠️ 不要用“拔网线后触发了 onClose”证明心跳有效

拔网线后是否快速触发 onClose,主要取决于浏览器、操作系统和网络栈。即使删除应用层心跳,它也可能照样触发;反过来,即使持续发送心跳,没有响应超时逻辑也可能很久都不判死。验证心跳机制应控制“服务端是否响应”,而不是只控制本地网络开关。


从最小骨架到生产级:补课清单 ​

最小版能跑之后,离生产级仍隔着一张清单。这里也不把上一篇的项目封装当作“标准答案”:最小版有些地方比它多做了一步,两者也都有尚未完成的能力。

#能力最小版现状缺了会怎样生产级做法
1重连控制已用单个 timer、指数退避和简单 jitter多实例仍可能同时重连;网络状态切换也未参与调度使用全抖动退避,结合 online/offline,明确不同 close code 是否可重试
2半开判定只发心跳,不检测回应心跳变成单向的自言自语,连接死了也不知道心跳带序号,超过 N 个周期没收到回执即判死、主动断开重连
3回执只发送连接层 ACKACK 丢失、服务端未重发时仍然会丢消息服务端保存未确认消息并重发,客户端按 ID 去重;业务成功另设应答
4订阅生命周期已支持多回调和 unsubscribe忘记调用仍会泄漏;单个回调异常可能打断后续回调框架生命周期自动解绑,逐个隔离回调异常并记录监控
5页面活跃状态无后台仍按前台策略接收消息visibilitychange 同步 active/inactive,具体降级由服务端决定
6跨上下文共享每个标签页、iframe 各自连接同一用户可能挂多条连接SharedWorker 统一持有连接,或用 BroadcastChannel/postMessage 建消息桥
7销毁清理已清 timer、回调并主动 close极端事件时序仍可能访问旧实例解绑原生事件、验证 fake timer 与 close/reconnect 竞态
8可测试性已支持注入 WebSocket 构造器尚未提供现成 fake 与时钟控制fake WebSocket + fake timer 覆盖 open/message/error/close 全时序
9发送背压未处理 bufferedAmount消费端变慢时内存持续堆积设队列上限、丢弃或降采样策略,监控 bufferedAmount
10鉴权续期URL 由调用方传入token 过期后可能陷入无效重连使用短时凭证,按关闭原因刷新凭证后再重连

这张表的信息量不在“怎么做”,而在能力不能靠命名获得:有 heartbeat() 不等于能判定半开,有 receive 不等于 at-least-once,有 destroy() 也不等于所有竞态都清干净。判断一个封装是否可靠,要沿着失败时序检查状态是否真的闭环。

“什么时候停止补课”没有固定序号。只要用于生产,半开判定、可控重连、完整销毁、消息校验和可测试性属于基线;可靠投递、跨上下文共享、背压与鉴权续期,则取决于业务是否真的需要。封装的度要与失败成本匹配,而不是与代码行数匹配。


结语:写最小版的意义 ​

最后回答一个可能一直悬着的问题:既然网上和公司里都有现成封装,为什么还要自己写一遍这个最小骨架?

因为最小版是你理解任何封装的坐标系。写完它,再去读上一篇的真实包,就能看出哪些代码在管理资源、哪些代码在定义协议,也能识别“字段存在但能力没有闭环”的情况。选型时不再只看 API 名字,而会追问:重连任务能否并发?心跳有没有响应超时?销毁后还有没有定时器?知识可以搬运,判断力来自对失败路径的验证。

这也是这个站反复出现的方法:先手写最小实现,再看框架替你做了什么。RAG 是这么写的(最小检索链路),Agent 是这么写的(手写循环对账 LangGraph),WebSocket 也不会是最后一个。

🤔 动手时的几个追问(点开看)

为什么 ACK 放在业务分发之前,还要过滤系统 topic? 连接层 ACK 表示“消息已收到并通过基础解析”,不依赖业务回调是否成功,所以放在分发之前。它只应发送给协议约定需要确认、且带有效 ID 的消息;给 receive 本身再回 ACK,会在 echo 或错误服务端实现下形成回执循环。

心跳间隔怎么定? 没有脱离环境的安全数字。间隔应短于链路中最小的 idle timeout,同时结合服务端容量、期望故障发现时间、移动网络耗电和后台定时器节流确定。更关键的是定义“允许连续丢几个响应”,而不是只决定多久发一次。

怎么给这个封装写单测? 示例已经让 createSocketContext 接收 WebSocket 构造器。测试时传一个实现 CONNECTING/OPEN、send/close 和事件触发方法的 fake 类,再配 fake timer,就能稳定模拟 open/message/close 的任意时序。重点覆盖重复 close、销毁后 close、重连等待中恢复和非法消息,而不只是“能否连接成功”。

为什么指数退避之后还要 jitter? 如果 1000 个客户端同时断线,它们即使都按 1s、2s、4s 退避,仍会在相同时间发起握手。jitter 把每个客户端的等待时间打散,避免服务恢复瞬间再次被同步请求压垮。生产实现通常采用 full jitter,而不是只固定加 500ms。

原创声明

本文首发于我的个人博客。如需转载请注明作者及 原文链接。

👀 本文阅读 ··· 次

基于 VitePress 构建

本站总访问量 ··· 次·访客数 ··· 人