简介:基于WebSocket实现浏览器端文本、视频、语音即时通讯的完整工程示例,面向具备Java基础的前后端开发者,可解决实时消息推送、音视频通话等场景的落地问题。资源包共111个文件,压缩后约959KB,包含18个Java后端源码、20个JavaScript脚本、14个HTML页面、3个CSS样式表及7个XML配置,并有Dockerfile、启动脚本和jar依赖,覆盖服务端转发、前端采集、界面展示与部署运行全链路。内容预览中的microphoneTest2.html、cameraTest.html等测试页面,可用于快速验证麦克风和摄像头调用流程。项目目录结构清晰,配有license、gitignore等工程化文件,方便直接导入或二次开发。目前已有56人学习,适合作为课程设计或企业级实时通信项目的参考模板,帮助理解WebSocket协议、媒体流处理与前后端交互机制。
1. 即时通讯不只有消息推送,WebSocket 才是浏览器端的长期连接底座
「基于 WebSocket 实现浏览器端文本、视频、语音的即时通讯」这件事,表面看是三种媒体形态的叠加,本质上却是同一个架构问题:如何在浏览器里建立一条能长时间存活、可双向实时推送的控制通道,再用它去驱动音视频媒体流。文本走 WebSocket 的 data 帧,视频和语音走 WebRTC 的媒体通道,而 WebSocket 负责的只是 SDP 与 ICE 候选的交换。这套方案适合不想接入付费云服务、希望在自己的服务器上掌控全链路的团队,也适合做在线客服、远程协助、小班课这类需要私有化部署的实时交互产品。它和聊天软件最大的区别在于:WebSocket 只保证字节流可靠到达,业务语义、消息路由、连接保活、音视频协商,全都要自己在上面搭建。整条链路最难的不是写代码,而是理清信令状态机和连接的生命周期。
2. 先把文本消息这条路跑通:协议设计、服务端与浏览器最小实现
2.1 为什么文本即时通讯首选 WebSocket:从轮询到全双工的实时性差距
在 WebSocket 普及之前,浏览器端做即时通讯只能靠 HTTP 轮询或长轮询。轮询的问题不在于「浪费流量」,而在于「实时性被轮询间隔锁死」:间隔设 3 秒,消息再快到客户端也是 3 秒后才拉下来;间隔设 1 秒,服务器就要承受每秒一波无意义的请求。长轮询虽然能缩短响应延迟,但每次连接重建都要重发 HTTP 头部,而且服务端需要维护大量挂起的请求,一旦连接数上来,内存和文件描述符消耗都很可观。
WebSocket 解决的是「连接复用」与「双向推送」两个核心痛点。一次 HTTP Upgrade 握手之后,客户端和服务端之间是一条长连接,服务端可以随时主动把消息推给客户端,客户端也随时可以发数据上去,整个链路的首字节延迟从「轮询间隔」压缩到「网络 RTT」级别。用它做即时通讯,是成本最低、延迟最可控的方案。但这同时也意味着:消息格式、在线状态、消息确认、重传、心跳,这些原本 HTTP 时代由框架代劳的东西,现在都要自己在应用层实现。这是做这个项目最容易被低估的工作量。
2.2 客户端到服务端的消息协议:一个够用不折腾的 JSON 信封
WebSocket 只负责传输字节流,不负责「这条消息是发给谁的、属于什么类型」。所以第一个要设计的就是应用层协议。我一般会先定一个 JSON 信封,统一所有消息的包装格式,而不是为文本、图片、系统通知各搞一种结构。信封里必须有类型、目标、消息 ID 三个字段,否则后面做消息确认和去重时一定会返工。
{ "type": "chat.text", "msgId": "uuid-001", "from": "user-1001", "to": "user-2002", "roomId": "room-none", "payload": { "text": "你好,这条消息能收到吗?" }, "timestamp": 1712993600 }type 字段决定服务端怎么路由这条消息,是最核心的字段。from 和 to 用于单聊路由,to 可以填用户 ID 也可以填房间 ID;roomId 在群聊或音视频房间场景下使用,单聊时置为固定值即可。msgId 用来做消息去重和 ACK 确认,必须由客户端生成,推荐直接用 uuid,不要依赖服务端返回。timestamp 用 Unix 秒级时间戳,别用字符串格式的时间,前端格式化展示就够了。
这个协议设计里有几个容易被忽略的细节。第一,payload 内部不要嵌套 type,外层 type 已经决定了业务分支,内层再做判断只会增加整理数据的成本。第二,to 字段在单聊里填「对方用户 ID」,在群聊里填「房间 ID」,服务端靠 type 字段区分场景,比如 chat.text 表示单聊、room.text 表示群聊。第三,所有字段都用字符串或整数,避免布尔值在不同语言序列化时的差异问题。
2.3 服务端最小实现(Node.js + ws 库):连接、路由、广播
服务端我用 Node.js 加 ws 库,这是从业者最常选的技术栈,生态成熟、代码量小,而且和浏览器端事件模型天然匹配。核心思路是维护一张 userId 到 WebSocket 实例的映射表,收到消息后根据 type 路由到目标连接。
const { WebSocketServer } = require('ws'); // 在 HTTP Server 上挂载 WebSocket 服务 const wss = new WebSocketServer({ port: 8080, path: '/ws' }); // 维护在线用户表:userId -> WebSocket 实例 const onlineUsers = new Map(); wss.on('connection', (socket, req) => { // 从查询参数里取 userId,例如 ws://localhost:8080/ws?userId=1001 const userId = new URL(req.url, 'http://localhost').searchParams.get('userId'); onlineUsers.set(userId, socket); socket.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch (err) { socket.send(JSON.stringify({ type: 'system.error', message: 'invalid json' })); return; } // 单聊文本消息路由 if (msg.type === 'chat.text') { const targetSocket = onlineUsers.get(msg.to); if (targetSocket && targetSocket.readyState === 1) { targetSocket.send(JSON.stringify(msg)); } else { // 目标离线,这里可选择持久化到数据库,或返回离线回执 socket.send(JSON.stringify({ type: 'chat.ack', msgId: msg.msgId, status: 'offline' })); } } }); socket.on('close', () => { onlineUsers.delete(userId); }); socket.on('error', () => { onlineUsers.delete(userId); }); });这段代码里有两个必看的点。第一,ws库的 connection 回调里,req.url是握手时的原始请求地址,通过new URL(req.url, 'http://localhost')解析出 userId,这是最省事的传参方式,不需要额外走 HTTP 接口做绑定。第二,消息解析必须包 try/catch,因为 WebSocket 的 message 事件拿到的可以是文本也可以是二进制,一旦客户端发来坏 JSON,JSON.parse 抛异常会直接打断整个连接的消息处理。readyState === 1表示 OPEN 状态,发送前检查一下可以避免往已关闭的连接上写数据。
路由策略上,单聊是精确查找,群聊是遍历 roomId 匹配的成员集合逐个发送。广播和单聊的区别只是目标集合不同,不建议把广播单独做成一种消息类型,而是用「目标列表」来抽象:单聊的目标列表只有一个元素,群聊有多个,这样服务端逻辑可以统一成「遍历目标列表发送」。
2.4 浏览器端最小实现:连接、发送与监听
浏览器端用原生 WebSocket API 就够了,不需要引入任何库。核心流程是三步:建立连接、处理消息、发送消息。这里要特别注意,WebSocket 的 onmessage 在浏览器里默认会把非字符串消息转成 Blob,所以文本消息只需要event.data直接使用即可。
class ChatClient { constructor(userId) { this.userId = userId; this.ws = null; this.reconnectTimer = null; } connect() { this.ws = new WebSocket(`ws://localhost:8080/ws?userId=${this.userId}`); this.ws.onopen = () => { console.log(`[${this.userId}] 连接已建立`); // 连接建立后立即发送一条上线通知,让服务端广播在线状态 this.send({ type: 'system.online', userId: this.userId }); }; this.ws.onmessage = (event) => { let msg; try { msg = JSON.parse(event.data); } catch (err) { console.warn('收到无法解析的消息', event.data); return; } switch (msg.type) { case 'chat.text': this.renderMessage(msg); break; case 'chat.ack': this.handleAck(msg); break; default: console.log('未知消息类型', msg.type); } }; this.ws.onclose = (event) => { console.log(`连接关闭: code=${event.code} reason=${event.reason}`); // 在这里触发重连逻辑,后续章节详述 }; this.ws.onerror = () => { // onerror 之后通常紧跟 onclose,不需要做二次重连 console.error('连接发生错误'); }; } send(message) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } else { console.warn('当前连接不可用,消息未发送'); } } sendText(toUserId, text) { this.send({ type: 'chat.text', msgId: crypto.randomUUID(), from: this.userId, to: toUserId, roomId: 'room-none', payload: { text }, timestamp: Math.floor(Date.now() / 1000) }); } }这段代码里有一个关键习惯:onerror里不做重连,因为onerror触发之后浏览器一定会接着触发onclose,如果两边都做重连会导致同时发起两个连接。crypto.randomUUID()是现代浏览器内置的 UUID 生成接口,不需要引入 uuid 库。send方法里检查readyState能避免把消息扔进一个已经关闭的连接里,减少客户端无意义的报错。
3. 给文字配上画面:WebSocket 做信令,WebRTC 传音视频
3.1 为什么音视频不走 WebSocket:媒体流走 UDP 才不卡
很多人第一次做音视频时会试图直接把摄像头数据塞进 WebSocket 发出去,这是一个必翻车的思路。WebSocket 底层是 TCP,TCP 是可靠传输协议,网络抖动时它会重传丢掉的包,而音视频恰恰不能等待重传——一帧画面等到了,声音却已经过去了,用户体验就是「视频卡顿、声音断断续续」。WebRTC 把媒体数据封装成 SRTP 包走 UDP 传输,允许少量丢包,用前向纠错和丢包隐藏来恢复质量,这才是实时音视频的正确姿势。
所以整个架构就清晰了:WebSocket 只承载信令,也就是「谁邀请谁、对方的网络地址是什么、用什么编码参数」,真正的声音和画面全部走 WebRTC 的媒体通道。信令通道的职责很窄但极其关键——它决定两个浏览器能不能成功建立 PeerConnection;一旦连接建好,WebSocket 断了,音视频通话仍然能继续,只是无法再发控制指令。
3.2 先约定调用模型:Mesh 架构下的一对一音视频流程
一对一通话最容易理解的模型是 Mesh,也就是两个浏览器之间直接建立 P2P 连接,服务器只负责转发信令,不碰媒体数据。整个流程分成三个阶段:呼叫准备、SDP 协商、ICE 候选收集与连接。顺序不能乱,乱一步都会导致音视频黑屏或无法接通。
标准流程是这样:主叫方 getUserMedia 拿到本地媒体流,创建 RTCPeerConnection,把媒体流加入连接,然后调用 createOffer 生成 SDP,接着 setLocalDescription 把本地 SDP 保存,最后把这个 offer SDP 通过 WebSocket 发给被叫方。被叫方收到 offer 后做三件事:getUserMedia、创建自己的 RTCPeerConnection、setRemoteDescription 把对方的 SDP 保存,然后 createAnswer 生成自己的 SDP 并回传。双方在 SDP 交换完成后,继续交换 ICE 候选地址,直到某个候选对能打通。
这里最容易搞错的点是时序:offer 和 answer 必须在 ICE candidate 之前完成交换,因为 ICE candidate 里携带的是网络地址信息,而 address 只在 SDP 协商完成后才有意义。我见过不少团队把 candidate 和 offer 混在一起发,导致对端在 setRemoteDescription 之前就处理 candidate,最终报错或者建连失败。
3.3 浏览器端获取麦克风与摄像头:getUserMedia 的权限与约束
调用 getUserMedia 是音视频的第一步,也是浏览器安全策略最严格的一步。页面必须在 HTTPS 环境下运行(localhost 除外),用户必须手动授权摄像头和麦克风权限。获取到 MediaStream 之后,要立刻把它赋给<video>标签做本地预览,让用户看到自己的画面。
async function getLocalStream(videoEl) { // 约束参数:优先使用 1280x720 分辨率、30 帧,回音消除和降噪必须开 const constraints = { video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30, max: 60 } }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }; const stream = await navigator.mediaDevices.getUserMedia(constraints); videoEl.srcObject = stream; return stream; }constraints 里的参数会直接影响通话质量与资源占用。width 和 height 用 ideal 而不是 exact,是让浏览器根据设备和带宽自行调节,exact 会导致不支持该分辨率的设备直接报错。frameRate 的 max 一般不超过 60,超过 30 对多数视频通话场景毫无意义,还白白增加编码压力。audio 的三个布尔值对语音清晰度至关重要,echoCancellation 不开启时,对方的声音会从自己的扬声器传回麦克风,形成尖锐的回声,这几乎是会议室场景最常见的投诉点。
3.4 建立 PeerConnection 与 SDP 交换:信令消息如何驱动状态机
RTCPeerConnection 是 WebRTC 最核心的对象。它的工作方式像一个状态机:每个方法调用都会推进本地状态,然后通过信令把信息同步给对端。这里我用一对一的呼叫为例,实现一个可运行的呼叫发起方逻辑。
async function startCallTo(remoteUserId) { const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, // 生产环境必须配置 TURN 服务器,内网穿透失败时作为中继 // { urls: 'turn:your-turn.example.com:3478', username: 'user', credential: 'pass' } ] }); // 把本地媒体流的音轨全部加入 PeerConnection localStream.getTracks().forEach((track) => { pc.addTrack(track, localStream); }); // 监听远端媒体流:对方摄像头数据到达后上屏 pc.ontrack = (event) => { remoteVideoEl.srcObject = event.streams[0]; }; // 监听 ICE 候选生成,通过 WebSocket 实时转发给对方 pc.onicecandidate = (event) => { if (event.candidate) { chatClient.send({ type: 'rtc.candidate', to: remoteUserId, candidate: event.candidate }); } }; // 发起 offer:生成 SDP,设置到本地,再发信令给远端 const offer = await pc.createOffer(); await pc.setLocalDescription(offer); chatClient.send({ type: 'rtc.offer', to: remoteUserId, sdp: pc.localDescription }); return pc; }发起方收到对方回传的 answer 后,调用pc.setRemoteDescription(answer),这时 SDP 协商完成,双方开始尝试 P2P 连接。整个流程最关键的是setLocalDescription必须在createOffer之后、发送 offer 之前调用,因为pc.localDescription只有在 setLocalDescription 之后才有效。ICE candidate 是一个持续产生的事件,不能等全部收集完再发,每产生一个就立即通过 WebSocket 推送,这样对方可以并行测试连通性。
接收方逻辑与发起方只是「创建 offer 还是 answer」的区别,其余完全对称。收到 offer 后同样创建 PeerConnection、添加音轨、配置 ontrack 和 onicecandidate,然后await pc.setRemoteDescription(offer),再createAnswer+setLocalDescription+ 回传 answer。
3.5 媒体流上屏与远端声音:attach 到 video 标签
媒体流上屏有两个容易混淆的对象:本地预览用的是 getUserMedia 返回的 MediaStream,远端画面用的是 ontrack 事件里拿到的 MediaStream。很多人会把两者搞混,把本地流赋给远端 video 标签,结果看到的是自己的画面。
// 本地预览 const localStream = await getLocalStream(localVideoEl); localVideoEl.srcObject = localStream; // 远端上屏 pc.ontrack = (event) => { // 用 event.streams[0] 而不是 event.track,因为流里可能同时有视频与音频 remoteVideoEl.srcObject = event.streams[0]; };还有个细节:Safari 的 WebRTC 实现和 Chrome 有差异,部分版本对ontrack的支持晚于onaddstream,所以我一般在代码里两个事件都监听,谁先触发就用谁。设置完 srcObject 之后,记得给 video 标签加上playsinline属性,否则 iOS 上的视频会自动全屏播放,掉进一个非常隐蔽的体验坑。
4. 从能聊到敢上线:心跳、重连与消息可靠性
4.1 连接假死是即时通讯的第一大敌:WebSocket 的沉默陷阱
WebSocket 的连接状态并不是「断开就立刻触发 onclose」这么简单。真实网络环境里,客户端从 WiFi 切到 4G、家庭路由器的 NAT 映射超时、运营商把空闲连接回收,都会导致链路被中间设备静默切断。此时 TCP 层面既没有 FIN 包也没有 RST 包,浏览器不知道连接已经失效,readyState 仍然显示 OPEN,但数据已经发不出也收不到了,这就是所谓的连接假死。
假死的可怕之处在于:它不报错,业务代码感知不到,用户看到的却是「消息发不出去、对方也不回」。解决假死只有一个办法——应用层心跳。客户端和服务端约定一个周期性探测机制,超过一定时间没有收到对方的消息,就判定连接已死,主动断开并重连。
4.2 心跳间隔选多少:30 秒是经验值,别挑战运营商 NAT 超时
心跳间隔设置是个参数选择题,间隔太短浪费流量和服务器资源,间隔太长又无法及时感知断线。我一般把客户端 ping 间隔设为 30 秒,原因很简单:主流的运营商 NAT 映射超时通常在 60 到 120 秒之间,30 秒发一次心跳,等于在 NAT 超时之前不断刷新映射表,连接就不会被中间设备回收。
// 客户端心跳:每 30 秒发一次 ping,服务端回 pong this.heartbeatTimer = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'system.ping', timestamp: Date.now() })); } }, 30000); // 收到任何消息都视为连接存活,可重置本地计时 this.ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'system.pong') { this.lastPongTime = Date.now(); } // 其他业务消息处理... };服务端配套做超时检测:每 10 秒扫描一次所有连接,如果某个连接距上次收到消息的时间超过 65 秒,就主动调用socket.terminate()断开。这里有个细节:心跳消息本身也是消息,业务消息也能起到保活作用,所以服务端只要记录「最后一次收到任何消息的时间」即可,不需要单独为心跳维护计数器。
服务端的超时阈值是 30 秒心跳间隔的两倍加 5 秒,即 65 秒,这给网络抖动留了足够的缓冲。如果把阈值设成与心跳间隔完全一致,一次偶发延迟就会误杀正常连接,造成频繁断线重连,反而影响稳定性。
4.3 断线重连:指数退避 + 消息补偿
重连策略几乎决定了一个 IM 产品的可用性口碑。无脑快速重连会在服务端抖动时形成连接风暴,加重故障;重连太慢又会让用户觉得「这软件死了」。指数退避是业界标准做法:第一次重连等 1 秒,第二次 2 秒,第三次 4 秒,逐次翻倍,封顶 30 秒,成功连接后重置退避计数。
reconnectWithBackoff() { let attempt = 0; const maxDelay = 30000; const baseDelay = 1000; const tryReconnect = () => { attempt++; // 退避延迟:1s, 2s, 4s, 8s... 封顶 30s const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay); setTimeout(() => { this.connect(); // 内部会触发 onopen -> 成功后重置 attempt=0 }, delay); }; this.ws.onclose = () => { // 如果是主动关闭(如用户退出登录),不重连 if (!this.manualClose) { tryReconnect(); } }; }重连成功后不能直接当无事发生,因为断线期间可能有消息错过了。常见做法是:重连成功后的第一条消息改为发送system.sync,携带客户端最后一条消息的 msgId,服务端根据这个 msgId 把离线期间的消息全部补推给客户端。
{ "type": "system.sync", "lastMsgId": "uuid-xxx" }消息补偿还需要配合去重。因为网络超时重发、服务端补推都可能让同一条消息到达客户端两次,所以客户端维护一个最近 1000 条 msgId 的集合,收到消息时先查重,重复的直接丢弃。
4.4 服务端也要有心跳检测:清理僵尸连接
服务端不能只依赖客户端心跳,因为它可能同时维护了几千条连接,每条连接对应着内存、Socket 文件描述符和定时器资源。如果某些断开的连接没有被及时清理,服务器内存会一点点泄漏,最终连 CPU 都被垃圾回收拖垮。
// 服务端心跳扫描 const HEARTBEAT_TIMEOUT = 65000; // 65 秒未收到任何消息则判定死亡 setInterval(() => { for (const [userId, socket] of onlineUsers.entries()) { if (!socket.isAlive) { socket.terminate(); onlineUsers.delete(userId); console.log(`已清理僵尸连接: ${userId}`); continue; } socket.isAlive = false; } }, 10000); // 收到任何消息都重置 isAlive wss.on('connection', (socket) => { socket.isAlive = true; socket.on('pong', () => { socket.isAlive = true; }); socket.on('close', () => { onlineUsers.delete(userId); }); });这里我用的是 ws 库内置的 ping/pong 机制,和上文的业务心跳是两层东西。业务心跳走应用层 JSON 消息,用来保活和探测假死;而 ws 库的 ping/pong 是 WebSocket 协议层的控制帧,专门用于检测连接是否还能通。生产环境建议两层都做:协议层保证 socket 存活,应用层保证业务消息通路正常。
5. 实战避坑:5 个把开发逼疯的高频问题
5.1 现象:内网能通、外网视频黑屏,TURN 服务器没部署
本地联调时两个浏览器在同一局域网,P2P 连接秒开;一旦部署到公网,视频就只剩黑屏,偶尔能听到声音但画面出不来。原因是 ICE 收集候选时只获得了内网地址和 STUN 反射地址,而双方所在网络的 NAT 类型都是对称型,直接打洞失败,找不到任何可用的候选对,连接建立不了。这是 WebRTC 项目里最经典的翻车场景。
解决方法是部署 TURN 中继服务器,把iceServers配置成同时包含 STUN 和 TURN。常见做法是用 coturn 开源项目搭一套 TURN 服务,然后在 RTCPeerConnection 的配置里加上 TURN 地址。TURN 会作为媒体流的中继转发节点,虽然增加了一层转发延迟,但能保证在任意复杂的 NAT 环境下都能通话。
5.2 现象:offer 发出去了,对端却一直答不上来,SDP 交换时序错误
信令消息在网络上传输存在延迟,ICE candidate 的生成是异步的,这就导致一种隐蔽的时序问题:主叫方的 onicecandidate 在被叫方还没 setRemoteDescription 时就触发了。此时被叫方的 PeerConnection 还没有本地远端描述,对 candidate 的处理直接抛异常,或者静默丢弃。如果你发现信令日志里 offer 和 answer 都正常,但连接就是建不起来,大概率是栽在这里。
解决方法是给信令加一层缓冲:在客户端收到 candidate 时,先判断是否已完成 setRemoteDescription,如果没有就先缓存进数组,等 setRemoteDescription 完成后再逐个处理。服务端也可以做类似的事情,把 candidate 先暂存在会话对象里,等收到 offer/answer 再发给对端。
5.3 现象:心跳都正常,消息还是偶尔丢了,服务端退出前最后一帧丢失
服务端进程被 kill 或部署滚动重启时,已经建立的 WebSocket 连接会被操作系统强制关闭。这个过程中,客户端可能还停留在 OPEN 状态,最后几毫秒发出去的消息直接丢进了黑洞,没有报错,也没有重传。对用户来说,自己发出去了,对方没收到,就是一次隐性的消息丢失。
解决思路是消息 ACK 机制,把「发送成功」和「送达成功」分开。客户端发送消息后,把 msgId 暂存在一个待确认队列里,服务端收到后立刻回chat.ack,客户端收到 ACK 才把消息从队列里移除。如果 10 秒内没收到 ACK,客户端自动重发。同时配合上一章说的重连补拉机制,用 msgId 去重,就能把丢失概率降到可接受范围。
5.4 现象:多人同时开视频,画面卡成幻灯片,Mesh 架构上行带宽爆掉
一对一没问题,三个人开视频就开始卡,加到四个人直接全员幻灯片。原因在 Mesh 架构的上行带宽模型:第二个人加入时,第一个人要向两个人各推一路流,上行带宽翻倍;四个人时每个人都要向另外三个人推流,上行带宽变成单聊的三倍,而普通宽带上行往往只有 10Mbps 左右,根本扛不住。
解决方向是换架构,从 Mesh 变成 SFU。SFU 模式下,每个客户端只向服务器推一路上行流,服务器负责转发给房间里的其他人。带宽消耗从 O(N²) 降到 O(N),四到八人的视频会议一台普通服务器就能扛住。知名的开源方案是 mediasoup 和 Janus,它们同时负责 SFU 转发与信令编排,适合多人场景的第二步演进。
5.5 现象:页面部署到生产环境后 WebSocket 连不上,静态资源却一切正常
页面在本地跑得好好的,一部署到线上就报连接失败。打开浏览器控制台通常会看到类似「Mixed Content: The page at https://... was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint ws://...」的报错。问题根源是浏览器安全策略:HTTPS 页面禁止主动发起不安全的 WebSocket 连接。
解决方法是生产环境统一用wss://协议,同时配好反向代理。常见做法是在 Nginx 里配置/ws路径的 Upgrade 转发,把 WebSocket 请求代理到 Node.js 服务端口。本地开发保持http+ws://没问题,但部署前一定要把new WebSocket()的 URL 改成跟随页面协议的写法,比如const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:',从根上避免环境切换时改漏。
6. 端到端验证:用两台设备确认这套即时通讯真的能上线
开发完不等于能用,我每次都会跑一轮「双机验证」:手机和电脑连同一个局域网,一个浏览器开两个标签页模拟双人,另一个浏览器用手机真机加入,专门测真实网络下的表现。验证清单很简单:文本双向收发是否在 1 秒内送达;视频画面是否流畅,声音无回声;断掉手机 WiFi 再恢复,观察 WebSocket 是否在 30 秒内重连成功、断线期间消息是否补拉;最后把手机切到 4G 网络再打一次视频,确认外网环境下 TURN 中继是否兜底接通。
如果双机验证都通过,我会再压一个低频但重要的场景:同时开三个标签页,其中两个分别模拟断开重连和长时间静默,观察第三个标签页的收发是否正常。这一步能暴露出心跳扫描和僵尸连接清理的真实效果。跑完这些,这套基于 WebSocket 的浏览器端文本、视频、语音即时通讯才敢说「能用」。
最后一个个人习惯:信令协议一定要先写成文档再动手写代码。我自己就吃过亏,先写代码后补协议,结果消息类型命名混乱、字段含义前后冲突,重构信令层比重写还痛苦。这个项目里,真正决定成败的不是 WebSocket 库用得好不好,而是消息信封、信令状态机、心跳与重连这三件事有没有在代码之外先对齐。希望帮到你,祝把这条路跑通。
本文还有配套的精品资源,点击获取