简介:这是一套基于WebSocket实现浏览器端文本、视频与语音即时通讯的完整示例工程,面向有一定前端或Java基础、希望快速掌握实时双向通信原理的开发者。工程以Java后端配合HTML/JS前端组织代码,涵盖摄像头采集、麦克风拾音、文本消息推送等典型调用场景,并附有麦克风与摄像头测试页面、Docker部署文件及一键启动脚本,可快速运行体验,也便于二次改造。压缩包共111个文件,以png图片、js脚本、java源码、html页面为主,另有xml配置、css样式、Dockerfile、jar包等辅助内容,整体体积仅959KB,目录结构清晰,能方便定位到前后端核心代码。目前已有56人学习,对理解WebSocket握手、消息转发以及浏览器媒体设备接入都有直接的参考价值,适合作为课程设计、毕设项目或初期立项的起步模板。
1. 浏览器端即时通讯为什么绕不开 WebSocket
如果你接到一个「基于 WebSocket 实现浏览器端文本、视频、语音的即时通讯」需求,先别急着写代码——最容易被低估的是浏览器端的协议边界。HTTP 轮询能模拟推送,但服务端没办法在数据到达的同一时刻触达客户端;SSE 能把文本单向推下来,可视频和语音的信令交换需要双向通道,轮询带来的延迟和乱序会让 WebRTC 协商极其痛苦。WebSocket 是浏览器唯一能直接使用的双向实时通道,文本走 WS 帧,视频语音走 WebRTC、由 WS 做信令,这套组合才是标题背后真正要落地的架构。这篇笔记面向那些要在一个月内做出可演示 WebIM 的开发者,想把连接管理、心跳机制实现、媒体协商的套路和这些年踩过的坑一次讲清楚。
2. 先搭文本通道:连接管理、心跳机制实现与消息协议
2.1 为什么浏览器端只能选 WebSocket:轮询与 SSE 的边界
浏览器网页里没法开裸 TCP 或 UDP 端口,应用层能用的是 HTTP、SSE 和 WebSocket。HTTP 轮询的做法是客户端每隔几秒发一次请求,服务端把新消息放在响应里带回;消息量小的时候够用,但服务端不能主动推送,轮询间隔压缩到 1 秒时,服务器和客户端同时在烧流量,消息延迟仍然不可控。SSE 用长连接解决了服务端向客户端单向推送的问题,但它没有可靠的上行通道,客户端想发一条消息还得另走 HTTP POST,两条通道容易产生乱序。这两种方案做文本聊天都勉强,视频语音的信令交换则彻底暴露短板:信令需要双向、实时、按序,轮询下等两个来回才能完成一次交换,延迟直接进秒级。
WebSocket 从根本上解决了这个问题。它是 HTTP 升级协议,同一个 80/443 端口完成握手后建立全双工通道,服务端可以主动把帧推到浏览器端,客户端也能在同一条连接里持续上行。对标题这套即时通讯方案来说,WebSocket 使用的位置在架构里是「控制面 + 文本面」:文本消息、在线状态、信令、ACK 都走 WS 帧;媒体帧不走 WS,留给 WebRTC。注意浏览器端 WebSocket API 只暴露了 send 和 onmessage 这样的文本/二进制接口,没有暴露底层 ping 帧的发送方法,所以下文的心跳机制实现要在业务层自己写。这也是为什么很多新人在连接「看起来正常」的情况下,一挂到正式环境就被网关斩断。
2.2 最小连接封装:心跳机制实现与指数退避重连
一个可用的 WS 封装至少要做三件事:连接建立、心跳保活、异常重连。常见做法是先封装一个 IMConnection 类,把所有状态管理收拢到一个文件里,后续不管是文本聊天还是 WebRTC 信令都复用同一个实例:
class IMConnection { constructor({ url, token, heartbeatInterval = 30000, timeout = 10000, onMessage }) { this.url = url; this.token = token; this.heartbeatInterval = heartbeatInterval; // 心跳间隔,单位 ms this.timeout = timeout; // 心跳超时阈值,单位 ms this.onMessage = onMessage; this.heartbeatTimer = null; this.reconnectCount = 0; this.manualClosed = false; this.ws = null; this.connect(); } connect() { this.ws = new WebSocket(this.url, 'im-protocol'); // 子协议用于服务端识别客户端类型 this.ws.onopen = () => { this.reconnectCount = 0; this.startHeartbeat(); this.send({ type: 'auth', token: this.token }); }; this.ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'pong') { this.clearHeartbeatTimeout(); this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatInterval); } else { this.onMessage(msg); } }; this.ws.onclose = (event) => { this.stopHeartbeat(); if (this.manualClosed) return; const delay = Math.min(5000, 1000 * 2 ** this.reconnectCount); this.reconnectCount++; setTimeout(() => this.connect(), delay); }; this.ws.onerror = () => this.ws.close(); } sendHeartbeat() { if (this.ws.readyState !== WebSocket.OPEN) return; this.send({ type: 'ping', ts: Date.now() }); this.heartbeatTimer = setTimeout(() => { this.ws.close(); // 超时未收到 pong,直接断开,触发 onclose 重连 }, this.timeout); } send(obj) { this.ws.send(JSON.stringify(obj)); } close() { this.manualClosed = true; this.stopHeartbeat(); this.ws.close(); } }这段代码的逻辑是:连接打开后先发鉴权消息,之后立刻排下一次心跳;每条普通消息到达时都会刷新心跳定时器,只有收到 pong 才重新计数。发 ping 之后如果 10 秒内没等到 pong,直接调用 ws.close(),让 onclose 走进重连分支。心跳机制实现用 setTimeout 而不是 setInterval,关键原因是事件循环阻塞时 setInterval 会堆积多个心跳包,连接没死也能把服务端打挂;setTimeout 每次重新排,天然错峰。
这里有三组参数要重点调。第一是心跳间隔,建议 30 秒,它必须小于链路中的网关转发超时,常见网关 read timeout 是 60 秒,取一半是安全做法。第二是心跳超时阈值,建议 8 到 10 秒,太短会误判,太长会让假死连接在服务端占着资源不放。第三是重连退避,按 1 秒、2 秒、4 秒翻倍,封顶 5 秒;不加封顶的话,服务端重启高峰时几千个客户端会形成惊群,瞬间把 CPU 打满。还可以在 onclose 里记录 close code,1006 是异常断开,1000 是主动关闭,这两个值对于排查下面的第五章节里的「假死」问题非常有用。
2.3 消息信封与幂等:让文本消息不多不少恰好一次
WebSocket 帧本身只是传输层,业务层必须自己定义消息格式。我一般用统一信封,所有消息都包在同一套结构里,上层按 type 分发。下面是一个文本消息的 JSON 信封:
{ "version": 1, "msgId": "u_1001_20250101120000_000001", "type": "text", "from": "user_1001", "to": "user_1002", "payload": { "content": "你好" }, "ts": 1735718400, "ack": false }字段里 version 预留给协议演进,msgId 必须全局唯一,type 是消息类型,枚举建议固定为 text、signal、ping、pong、ack、sync、error 这七类,别临时起名字。payload 按 type 自由定义,ts 是客户端发送的毫秒时间戳。服务端收到后会给消息分配一个全局递增的 seq,客户端用 seq 做排序和断线补拉的游标。
为什么需要幂等处理?TCP 保证字节不重不漏,但业务层重发机制会制造重复。客户端回 ACK 超时后,服务端会用相同的 msgId 重发一次,如果客户端不做去重,用户会看到同一句话出现两遍。去重实现很简单:
const seenCache = new Set(); function handleMessage(msg) { if (['ping', 'pong', 'ack', 'sync'].includes(msg.type)) return; const key = msg.msgId || `${msg.seq}`; if (seenCache.has(key)) return; seenCache.add(key); if (seenCache.size > 500) { const oldest = seenCache.values().next().value; seenCache.delete(oldest); } if (msg.type === 'text') { renderMessage(msg); ws.send(JSON.stringify({ type: 'ack', msgId: msg.msgId })); } }这个 Set 只保留最近 500 条 msgId,为什么是 500?因为服务端重发策略一般是 3 秒间隔、最多重试 3 次,500 条足够覆盖重发窗口,同时内存占用可以忽略。要注意去重不能只做在协议层,渲染层最好也按 msgId 做一次 key 判断,双保险能拦住补拉与实时推送重叠时产生的重复消息。
3. 视频语音的浏览器端实现:WebSocket 做信令,媒体走 WebRTC
3.1 摄像头码流为什么不能塞进 WebSocket 帧
一个常见的直觉是:WebSocket 能传 ArrayBuffer,能不能把摄像头帧直接塞进去?技术上能,工程上不要碰。WebSocket 底层是 TCP,TCP 的可靠性在实时音视频里反而是灾难:一个丢包触发重传,后续所有数据在缓冲区里排队,视频帧延迟直接飙到几百毫秒以上,画面开始卡顿、音画不同步。WebRTC 的媒体走 UDP,配合前向纠错和码率自适应,接受偶尔丢包但不允许排队,这才是实时音视频需要的传输行为。
所以标题里视频语音的真正分工是:WebSocket 负责把两个浏览器端「拉到一起」——交换 SDP 和 ICE 候选就是信令;WebRTC 负责把 A 的摄像头和麦克风送到 B。这个分工在 2 到 4 人的小会里最直白,人数再多就要引入 SFU 服务端转发媒体,WS 依然只做信令。下面的实现先把小会场景跑通。
3.2 getUserMedia 采集参数:回声消除、分辨率与帧率
浏览器端拿摄像头和麦克风,唯一的入口是 navigator.mediaDevices.getUserMedia。约束参数不是随便填的,下面这组是我在 Chrome 和 Safari 上都能稳定工作的写法:
const constraints = { audio: { echoCancellation: true, // 回声消除,防止对端听到自己的声音 noiseSuppression: true, // 降噪,过滤空调风扇类平稳噪声 autoGainControl: true // 自动增益,人离麦克风远时自动补偿音量 }, video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30, max: 30 } } }; const stream = await navigator.mediaDevices.getUserMedia(constraints); document.querySelector('#localVideo').srcObject = stream;参数里最容易被忽略的是 video 的 width 和 height 用 ideal 而不是 exact。exact 会让浏览器在设备不支持指定分辨率时直接抛 OverconstrainedError,ideal 则会选择最接近的分辨率。frameRate 给 30 帧足够,60 帧对上行带宽和编码器压力都翻倍,移动端浏览器基本都不支持。audio 的三个开关里,echoCancellation 最重要,不开的话对方听到的是自己说话的回声,这个坑在第五章还会再提一次。
拿到 stream 后要注意两点。第一,预览用video.srcObject = stream,不要用URL.createObjectURL(stream),后者是过时 API,部分浏览器已经移除了。第二,<video>标签上加autoplay和playsinline,尤其 playsinline 在 iOS Safari 上不加会导致预览自动全屏,体验直接翻车。
3.3 SDP 与 ICE 候选交换:WebSocket 上的信令流程
现在进入关键部分。A 和 B 两台浏览器建立视频通话,WebSocket 承担的是信令通道。完整流程是:A 创建 RTCPeerConnection,把本地流轨道加进去,创建 offer,setLocalDescription,然后通过 WebSocket 把 SDP 发给 B;B 收到后 setRemoteDescription,创建 answer 回传;之后两边在 onicecandidate 里持续交换 ICE 候选。A 端的代码大致是这样:
const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.example.com:3478' }, { urls: 'turn:turn.example.com:3478', username: 'guest', credential: 'pass' } ] }); stream.getTracks().forEach(track => pc.addTrack(track, stream)); pc.onicecandidate = (e) => { if (e.candidate) ws.send({ type: 'signal', payload: { candidate: e.candidate } }); }; const offer = await pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true }); await pc.setLocalDescription(offer); ws.send({ type: 'signal', payload: { sdp: pc.localDescription } });iceServers 里 STUN 是必配的,它帮客户端发现自己的公网地址和端口映射;TURN 是打洞失败时的兜底中继,生产环境必须配,否则经过对称型 NAT 的用户之间媒体流完全无法打通。offerToReceiveAudio和offerToReceiveVideo两个选项建议显式置 true,Safari 上如果不置位,对端可能收不到媒体轨道,这个问题排查起来很隐蔽。
WebSocket 收到对端信令时,要按消息类型走状态机,而不是一股脑全当成 SDP 处理:
ws.onmessage = async (event) => { const msg = JSON.parse(event.data); if (msg.type !== 'signal') return; const { sdp, candidate } = msg.payload; if (sdp) { if (sdp.type === 'offer') { await pc.setRemoteDescription(sdp); await pc.setLocalDescription(await pc.createAnswer()); ws.send({ type: 'signal', payload: { sdp: pc.localDescription } }); } else if (sdp.type === 'answer') { await pc.setRemoteDescription(sdp); } } else if (candidate) { await pc.addIceCandidate(candidate); } };这套状态机的核心原则是:先处理 SDP,再排队处理 ICE 候选,乱序的 candidate 要等 setRemoteDescription 完成后再 addIceCandidate,否则会抛出异常。实际项目中还有很多边界,比如 A 发起呼叫时 B 不在线、B 已经在通话中、A 在 B 应答前就挂断,这些都用独立的信令消息(calling、ringing、accepted、rejected)处理,代码会复杂一截,但结构不变。
4. 鉴权、多端与消息一致性:即时通讯上线前的三个边界
4.1 握手鉴权与 token 失效重连
文本、信令都走在同一条 WebSocket 连接上,所以连接建立时的鉴权比 HTTP 接口更严格。常见做法是把 token 放在 URL query 里,服务端在 WebSocket 握手阶段校验,失败直接拒绝连接。这种方法实现最快,但 token 会写进网关日志,敏感项目建议改成连接建立后第一条消息鉴权:客户端发{ type: 'auth', token },服务端校验通过前不推送任何业务数据。
token 过期是即时通讯里最常见的掉线场景。WebSocket 长连接在 token 过期后不会自动断开,服务端的做法是主动推一条auth_expired消息,然后 close(4001);客户端收到后调登录接口换新 token,再重新连接。这里有一个必须处理的细节:重连逻辑要区分「token 失效」和「网络抖动」。网络抖动走指数退避自动重连,token 失效则要停掉自动重连,否则会用旧 token 无限循环握手,把鉴权服务打到限流。
4.2 多端在线与 ACK 重发:让每条消息都到达
同一个账号在手机、网页、桌面三端同时在线时,服务端要把一条消息推给该账号的所有在线 session。客户端的 ACK 不只是「已读回执」,它的真正作用是告诉服务端「这条消息已经到达某个端」。服务端为每条下发消息生成全局 seq,客户端收到后回 ack 带上 seq,服务端只有在某个端 ack 之后才推进那个端的游标。如果 3 秒内没收到 ack,用相同 msgId 重发,最多三次,这就是前面幂等逻辑存在的意义。
这里要区分两个概念:ack 表示「送达」,read 表示「已读」。已读回执要另外用一条read消息,带上被读消息的 msgId 列表,服务端再广播给同会话的其他端。很多新手把 ack 当已读用,导致「送达」和「已读」语义混在一起,多端状态下状态同步一片混乱。
4.3 断线补拉与消息游标
连接断开期间错过的消息,不能只靠服务端重发,因为服务端不知道客户端断在哪里。正确的做法是客户端重连成功后主动发起同步请求:
{ "type": "sync", "fromSeq": 123, "limit": 200 }fromSeq 是客户端本地连续收到的最大 seq,服务端把这个 seq 之后的消息批量下发。为什么是 fromSeq 而不是「最后一条 msgId」?因为 seq 是全局递增的,单调有序,而 msgId 只是唯一标识,不携带顺序信息。limit 控制在 200 条,防止长时间离线后一次性推送过多消息把弱网连接打崩;如果超过 200 条,客户端再用新的 fromSeq 继续拉下一批,直到拉完。
补拉和实时推送在时间上会重叠,服务端不能保证「你发起 sync 之后再来的消息」和「sync 返回的批量消息」没有交集。这一步完全靠客户端的 msgId 去重兜底,和 2.3 节同一套逻辑,不要为补拉单独写一套去重。
5. WebSocket 即时通讯避坑笔记:五个高频现场
做浏览器端即时通讯这几年,踩得最多的坑不在 WebSocket 本身,而在协议边界、网关转发和浏览器策略上。下面五个现场都是能复现的,每个都按现象、原因、解决的顺序说清楚。
5.1 HTTPS 页面连 ws:// 被浏览器直接拦死
现象:页面在 HTTPS 下,代码里写的是new WebSocket('ws://...'),打开控制台看到 Mixed Content 报错,连接一个都建不起来。
原因:浏览器安全策略要求安全上下文里发起的子资源请求也必须加密,非加密的 ws:// 在 HTTPS 页面里属于混合内容,直接拦截。
解决:生产环境统一用 wss://;本地开发如果必须用 ws://,让页面也在 HTTP 或 localhost 下访问,浏览器对 localhost 有豁免。线上排查时可以写一句const wsUrl = location.protocol === 'https:' ? 'wss://' : 'ws://' + host,从页面协议动态推导,避免部署环境切换时改代码。
5.2 网关转发超时:连接「假死」后 1006
现象:客户端和服务端都认为连接是好的,但服务端一段时间收不到任何消息,再过一会儿客户端 onclose 收到 1006 异常断开码。
原因:连接经过 Nginx 等网关转发时,网关有默认的 read timeout,常见值是 60 秒。WebSocket 长连接若在超时时间内没有产生任何数据帧,网关会主动断开连接。TCP 层可能没有任何异常报文,两端都以为还活着,这就是假死连接。
解决:心跳机制实现必须在网关超时之前发出数据,心跳间隔 30 秒、超时阈值 10 秒是稳妥组合;同时把网关侧 read timeout 调到 75 秒以上,给重连续命的空间。心跳间隔不是越小越好,1 秒一次会让网关和服务器都在处理无效流量,30 秒足够。
5.3 后台标签页定时器节流,心跳发不出去
现象:用户把页面切到后台,过几分钟切回来,发现消息收不到,连接已经断了,而且 onclose 事件没有立刻触发。
原因:浏览器对后台页面的定时器做节流,setTimeout 的触发间隔会被拉长到 1 分钟甚至更久,心跳机制实现里的 30 秒定时器在后台根本不按预期执行,连接静默断开。
解决:把心跳定时器挪到 Web Worker 里,Worker 的定时器不受页面可见性节流影响。同时监听 visibilitychange,页面重新可见时立即补发一次 ping,检查 ws.readyState,如果已经是 CLOSED 就触发重连。
5.4 同一条消息出现两遍
现象:弱网下一条文本消息偶尔被渲染两次,频率在 ACK 超时重发时有明显上升。
原因:ack 超时后服务端重发同一 msgId 的消息,或者补拉 sync 和实时推送的消息在时间上重叠,两份相同的消息先后到达客户端。
解决:客户端用 Set 缓存 msgId 去重,渲染层再按 msgId 做一次渲染 key 判断。服务端重发时严格保持 msgId 不变,补拉接口返回的消息同样使用服务端下发的 msgId,让两套路径共用同一个去重缓存。
5.5 回声与啸叫:对端听到自己说话
现象:视频通话里对方能听到自己的回声,严重时出现尖锐啸叫,尤其在戴上耳机后更明显。
原因:采集约束里没开 echoCancellation,或者设备驱动不支持该参数导致浏览器静默忽略。本地扬声器播放的声音再次进入麦克风,又没有被消除,原路传回对端。
解决:getUserMedia 里显式设置echoCancellation: true, noiseSuppression: true, autoGainControl: true,同时不要把采集到的音频流同时接给本地预览的<audio>标签和 WebRTC 轨道,避免本地重复播放。会议室类场景回声音量较大时,单靠浏览器端 AEC 不够,需要服务端混音时对每个远端流单独做回声消除。
6. 协议版本化与弱网压测:让这套链路跑满一年
最后给两个能让项目长期存活的做法:协议版本化,和上线前的弱网压测。
消息信封里的 version 字段不是摆设。客户端发版不齐是常态,老版本客户端连接上来后,服务端发现它的 version 低于当前支持范围,要返回一条upgrade_required消息,再关闭连接,客户端根据这条消息引导用户刷新页面或更新 App。这比让老客户端在模糊的 1006 错误里干等要友好得多。服务端升级协议时,至少保证旧版本还能运行一个版本周期,不要今天加字段明天就让老连接全部失效。
压测不要只测 HTTP 接口,WebSocket 连接管理要单独测。下面是我每轮上线前必跑的脚本,用 Node 模拟 1000 个客户端持续连接和心跳:
const WebSocketClient = require('ws'); const TOTAL = 1000; const clients = []; let received = 0; for (let i = 0; i < TOTAL; i++) { const ws = new WebSocketClient(`ws://localhost:8080/im?uid=${i}`, 'im-protocol'); ws.on('open', () => { ws.send(JSON.stringify({ type: 'auth', token: 'loadtest-token' })); setInterval(() => ws.send(JSON.stringify({ type: 'ping' })), 30000); }); ws.on('message', () => { received++; }); clients.push(ws); } setInterval(() => { const alive = clients.filter(c => c.readyState === WebSocketClient.OPEN).length; console.log(`alive: ${alive}/${TOTAL}, messages: ${received}`); }, 5000);这个脚本重点看三样东西:1000 个连接同时建起来后,alive 数量是否稳定在 1000;跑 10 分钟后服务端文件描述符数量和内存是否持续上涨(涨就是泄漏);关闭一半客户端后,服务端能否及时清理 session。弱网模拟不能只用本地回环,我一般还会用 tc 命令在测试机上加入工丢包和延迟,比如tc qdisc add dev eth0 root netem loss 10% delay 100ms,观察补拉逻辑在丢包环境下是否正常工作。
我的固定习惯是:每次改完心跳参数或重连逻辑,先跑一轮 10 分钟压测,再开一个 5% 丢包的弱网环境跑一轮回归。这两步能拦住绝大部分连接管理上的玄学问题,比在线上翻车后熬夜查日志划算得多。这套链路做到这里,文本、视频、语音的骨架已经完整,剩下的就是按业务往上填功能了——希望帮到你。
本文还有配套的精品资源,点击获取