WebSocket实战:从握手到WebRTC信令,构建浏览器实时通讯
简介:基于WebSocket实现浏览器端文本、视频与语音即时通讯的完整项目资料,面向具备一定Web前后端基础的开发者,可用于课程设计、毕业设计或工程实训参考。包内包含111个文件,涵盖Java后端服务、HTML/JS前端页面、CSS样式及Docker部署配置等,代码结构清晰,便于按模块理解实时通信的实现思路。资源包仅959KB,轻量易用,可直接导入开发环境运行调试,并通过启动脚本快速启动服务。文件中提供麦克风、摄像头调用与文本收发测试页面,可帮助开发者验证音视频采集、信令交互及WebSocket消息传输效果。目前已有56人学习下载,适合希望快速掌握WebSocket实际应用、并参考前后端联调细节的开发者借鉴。
1. 为什么浏览器端实时通讯绕不开 WebSocket
在做一个网页版微信风格的即时通讯项目时,最容易被忽略的不是发消息,而是连接本身的生死。基于WebSocket实现浏览器端文本、视频、语音的即时通讯,核心难点在于全双工通道的管理和媒体协商:文本消息要广播,视频与语音要依赖WebRTC,而WebSocket恰好是两者之间唯一的信令管道。很多课程设计Demo能跑通聊天,却经不住断网重连和多人房间的考验。下面从连接管理讲起,再看消息协议、媒体协商,最后把Dockerfile和启动脚本里的坑也填上。适合正在做课程设计、毕设,或想在内部系统加一个实时通讯模块的开发者,特别是那些希望理解握手、心跳、SDP交换等底层参数的人。
2. WebSocket 连接管理与消息协议:从握手到广播
2.1 协议层为什么比 HTTP 轮询强
浏览器端做即时通讯,第一反应往往是HTTP轮询。轮询的代价是每次请求都要携带完整Header,服务端要解析、鉴权、处理重复连接,一个100人在线的聊天室如果2秒轮询一次,每秒就有100个请求,其中可能只有2条是有效消息。WebSocket用一个HTTP Upgrade请求把双向通道建立起来,之后每个数据帧头部只有2到14字节。对于文本消息,几百字节的Payload加2字节帧头,比HTTP省了一个数量级。更重要的是,浏览器原生提供 WebSocket 对象,不需要额外引入SDK。
但这个通道不是免死金牌。WebSocket服务端要自己处理心跳、死连接、未认证连接和消息边界。用Node.js的 ws 库时, connection 事件后对象就已经可以收发,框架不提供房间和会话概念,所有东西都要自己定义。这也是下面从协议层开始搭的原因。 ws 库轻量、二进制帧处理简单,能和Express/Node HTTP server复用同一个端口;如果换成Spring Boot,常见做法是 @ServerEndpoint 加 SpringConfigurator ,思路一致,只是把连接生命周期交给容器管理。
2.2 服务端广播与连接生命周期管理
项目里 server.js 的核心结构如下,我用 ws 加Express托管 public 目录下的 chat.html 、 microphoneTest2.html 和 cameraTest.html 。
const http = require('http');
const express = require('express');
const { WebSocketServer } = require('ws');
const app = express();
app.use(express.static('public'));
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
// 用Map保存在线连接,key是会话id,value是ws实例
const clients = new Map();
wss.on('connection', (ws, req) => {
const userId = new URL(req.url, 'http://localhost').searchParams.get('token')
|| `guest-${Date.now()}`;
clients.set(userId, ws);
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.send(JSON.stringify({ type: 'welcome', userId }));
ws.on('message', (buf) => {
let msg;
try {
msg = JSON.parse(buf.toString());
} catch {
ws.send(JSON.stringify({ type: 'error', code: 400, message: 'body must be json' }));
return;
}
if (msg.type === 'chat') {
const text = String(msg.text || '').slice(0, 500);
clients.forEach((client) => {
if (client.readyState === 1) {
client.send(JSON.stringify({ type: 'chat', from: userId, text, time: Date.now() }));
}
});
}
});
ws.on('close', () => clients.delete(userId));
});
setInterval(() => {
clients.forEach((ws, id) => {
if (!ws.isAlive) {
ws.terminate();
clients.delete(id);
return;
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
server.listen(3000, () => console.log('listening on 3000'));
这里 readyState === 1 表示连接处于OPEN状态,可以直接 send 。 ws.ping() 是协议层控制帧,浏览器会自动回应 pong ,30秒一次的探测能及时清掉“假死”连接。 token 从URL参数读取只是为了演示,生产环境应该放在请求头或首帧消息里,避免日志泄漏。所有业务消息统一走JSON,解析失败时返回 400 而不是直接断开,这样前端可以根据错误码提示用户,而不是眼睁睁看着连接掉线。
2.3 前端连接状态机与重连策略
浏览器端连接管理要显式处理“主动关闭”和“异常断开”。下面是一个带指数退避重连的封装:
let ws = null;
let attempt = 0;
function connect() {
ws = new WebSocket(`/ws?token=${encodeURIComponent(userId)}`);
ws.addEventListener('open', () => {
attempt = 0;
ws.send(JSON.stringify({ type: 'hello', client: 'web' }));
});
ws.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
dispatch(msg);
});
ws.addEventListener('close', (event) => {
// 1000 是主动关闭,1006 说明网络中断或对端终止
if (event.code === 1000 || event.code === 4000) return;
const timeout = Math.min(500 * 2 ** attempt, 10000);
attempt += 1;
setTimeout(connect, timeout);
});
}
connect();
Math.min(500 * 2 ** attempt, 10000) 实现指数退避,第一次1秒、第二次2秒,最多10秒。如果用固定间隔,服务端重启时会有一堆客户端同时重连造成“惊群”。 close 事件里不能只判断 code === 1000 ,因为 1006 在浏览器端是“连接异常关闭且没有关闭帧”,常见原因是NAT超时、服务端 terminate() 或代理回收空闲连接,这种情况必须重连。
| 关闭码 | 含义 | 重连策略 |
|---|---|---|
| 1000 | 正常关闭 | 不重连 |
| 1001 | 页面/服务端离开 | 按业务决定 |
| 1006 | 非正常关闭(无关闭帧) | 指数退避重连 |
| 4000-4999 | 自定义业务关闭 | 按业务码处理 |
3. 文本聊天实现:JSON 协议、心跳与消息去抖
3.1 定义统一消息协议
消息结构要能同时承载文本、信令和状态,我用一个 type 加一个 data 字段:
{
"type": "chat",
"to": "room:lobby",
"data": {
"text": "hello"
}
}
type 用可读的字符串而不是数字,方便在浏览器控制台和Nginx日志里直接排查问题。 to 字段支持 room:xxx 和 user:xxx 两种前缀,这样同一个后端既能群聊也能私聊。时间戳统一用 Date.now() 返回的毫秒,不要用秒,因为前端 new Date() 默认接收毫秒,传秒会出现1970年的诡异时间。
| type | 方向 | 用途 |
|---|---|---|
| hello | C→S | 连接后鉴权 |
| chat | C↔S | 文本消息 |
| typing | C→S | 正在输入事件 |
| ping/pong | C↔S | 业务心跳 |
| offer / answer / candidate | C↔S | WebRTC信令 |
注意业务心跳和2.2里的协议层 ping 不是一回事。协议层 ping 是为了保活TCP连接,业务心跳是为了上报“用户还在这条会话里”,前端可以每30秒发一个 ping ,服务端回 pong 。如果只靠ws协议层心跳,服务端无法区分用户是切后台还是真离线。
3.2 服务端路由与转发
在2.2的Map基础上扩展一个路由函数:
function sendTo(target, payload) {
const [type, key] = target.split(':');
if (type === 'room') {
clients.forEach((client) => {
if (client.readyState === 1) client.send(JSON.stringify(payload));
});
return;
}
const ws = clients.get(key);
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify(payload));
} else {
// 目标离线,可在这里写入Redis离线消息
console.warn(`offline: ${key}`);
}
}
ws.on('message', (buf) => {
const msg = JSON.parse(buf.toString());
if (msg.type === 'chat') {
const data = msg.data || {};
if (typeof data.text !== 'string' || data.text.length === 0) return;
sendTo(msg.to, { type: 'chat', from: userId, data, time: Date.now() });
}
});
用 split(':') 而不是正则,是因为这里只需要切割一次,正则反而增加阅读成本。 room: 前缀直接遍历所有连接广播,适合聊天室场景; user: 前缀做精确投递,适合私聊和WebRTC信令。单台服务器几千连接直接遍历Map没有性能问题,真正到十万并发再考虑Redis Pub/Sub,到时候把 clients 换成订阅关系,业务层的 sendTo 函数不用改。
3.3 前端低延迟渲染与 DTO 校验
前端拿到消息后不要无脑 appendChild ,先做轻量校验再渲染:
const list = document.getElementById('messages');
function appendMessage(msg) {
const el = document.createElement('div');
el.className = msg.from === currentUser ? 'message mine' : 'message other';
el.textContent = `${msg.from}: ${msg.data.text}`;
list.appendChild(el);
}
let lastPaint = 0;
function enqueueMessage(msg) {
const now = Date.now();
if (msg.from === currentUser && now - lastPaint < 16) {
const last = list.lastElementChild;
last.textContent += msg.data.text;
return;
}
lastPaint = now;
appendMessage(msg);
}
这里 16ms 对应60fps的渲染周期。普通文本聊天每秒几十条消息其实无所谓,但配合 typing 事件连续触发时,把相似消息合并到同一帧能明显减少DOM重排。 lastElementChild 只引用最后一个节点,避免每次从 querySelector 重新查找。Vue 3项目里“增加 websocket”的常见做法是在 onMounted 里建立连接、 onUnmounted 里关闭,然后用 reactive 数组和 watch 触发渲染,和这里的逻辑一样,只是框架替代了手动DOM操作。
4. 视频与语音通话:WebRTC 信令 + 媒体协商
4.1 为什么媒体流不走 WebSocket
反直觉的地方在于:WebSocket明明是全双工,为什么不能直接推视频帧?因为WebSocket基于TCP,TCP保证顺序和可靠,一旦丢包就要重传,视频画面会卡在等待重传的帧上。WebRTC走UDP,用SRTP加密,通过RTCP反馈丢包情况,丢包时优先恢复音频而不是重传整帧,对实时通话的容忍度完全不同。因此这个项目里WebSocket只做信令通道,负责交换SDP和ICE候选;真正的媒体流通过RTCPeerConnection点对点传输。看到 microphoneTest2.html 和 cameraTest.html ,就知道它们是媒体采集调试页,不是信令页面。
4.2 getUserMedia 与设备遍历
摄像头和麦克风要单独测试,我一般用下面这个方法:
async function startLocalStream(kind) {
const constraints = {
video: kind === 'video' ? {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user'
} : false,
audio: kind !== 'video' ? {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
} : false
};
return await navigator.mediaDevices.getUserMedia(constraints);
}
echoCancellation 回音消除必须开,否则扬声器的声音会被麦克风再次采集,形成刺耳回声; noiseSuppression 压制键盘敲击声; autoGainControl 让音量自动调整。 facingMode: 'user' 指定前置摄像头,需要后置时改成 environment 。还要注意 getUserMedia 必须在用户手势里调用,比如点击“开始通话”按钮,否则浏览器直接拒绝。在“电脑端模仿微信浏览器”场景下,修改UA不会让权限判断变宽松,部分浏览器还把 navigator.mediaDevices 隐藏到 navigator.getUserMedia 上,需要做兼容降级。
4.3 RTCPeerConnection 与 SDP 交换
采集到本地流之后,创建 RTCPeerConnection 并添加轨道:
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'candidate',
to: remoteUserId,
data: event.candidate.toJSON()
}));
}
};
pc.onnegotiationneeded = async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({
type: 'offer',
to: remoteUserId,
data: pc.localDescription.toJSON()
}));
};
远端收到 offer 后,要回一个 answer :
async function onRemoteOffer(from, data) {
await pc.setRemoteDescription(new RTCSessionDescription(data));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({
type: 'answer',
to: from,
data: pc.localDescription.toJSON()
}));
}
onnegotiationneeded 在添加轨道后自动触发,不需要手动调用 createOffer 。 event.candidate 为 null 表示ICE收集完成,这个 null 事件不需要发送给对端。要注意 toJSON() 而不是直接序列化整个对象,因为SDP和ICE对象里有浏览器内部字段,直接发送会造成对端 setRemoteDescription 解析失败。信令消息体必须带 to ,否则多人房间里所有人都去处理同一份offer,会把peer connection状态搅乱。
4.4 房间模型与参会者管理
| 消息 type | 发送方 | 接收方 | 载荷 |
|---|---|---|---|
| offer | 发起方 | 房间内目标用户 | SDP |
| answer | 应答方 | offer来源 | SDP |
| candidate | 任意一方 | 对方 | ICE候选 |
| bye | 任意一方 | 对端 | 空 |
服务端对信令只做转发,不解析SDP内容:
ws.on('message', (buf) => {
const msg = JSON.parse(buf.toString());
if (['offer', 'answer', 'candidate', 'bye'].includes(msg.type)) {
const target = msg.to;
if (target && clients.has(target)) {
clients.get(target).send(buf.toString());
} else {
ws.send(JSON.stringify({ type: 'error', code: 404, message: 'target offline' }));
}
}
});
这种“对称转发”模型省掉了服务端的媒体处理,一个Node进程可以同时支持几百路信令。多对多房间要在参与者加入时广播成员列表,并让新成员向其他成员逐个发送 offer ;超过4人视频建议改用SFU架构如mediasoup,但本项目是一对一或小房间演示,P2P足够。注意转发时直接发 buf.toString() 而不是重新 JSON.stringify(msg) ,能保持原始字段顺序,减少不必要的对象序列化开销。
5. 部署与排错:Dockerfile、批量启动和 1006 断连
5.1 容器化部署
项目根目录的 Dockerfile 可以这样写:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
启动服务.bat 里最核心的两行就是 npm install 和 node server.js ,也可以封装成:
docker build -t ws-im .
docker run -p 3000:3000 ws-im
选 node:20-alpine 而不是 node:latest ,是因为alpine镜像体积小且自带TLS CA,能保证前后端构建环境一致。 npm ci --omit=dev 按 package-lock.json 锁版本安装,避免他人克隆项目后因为依赖版本差异跑不起来。 express.static('public') 会把 microphoneTest2.html 、 cameraTest.html 一起托管,调试时直接访问 http://localhost:3000/microphoneTest2.html 就能测设备。
5.2 常见坑:1006、媒体权限与移动端置顶
| 症状 | 原因 | 处理 |
|---|---|---|
浏览器报 [websocket] onclose, code: 1006 | 心跳未回复,服务端终止 | 缩短ping间隔,检查代理超时 |
getUserMedia 返回 NotAllowedError | 非HTTPS或非localhost | 本地用localhost,公网用HTTPS/WSS |
| 移动端视频自动置顶 | 百度浏览器/微信内核默认全屏播视频 | video标签加 playsinline webkit-playsinline |
| Chrome 109 出现 wss 握手失败 | 自签名证书不受信 | 手动导入证书并重启浏览器 |
1006 不是业务错误,而是连接被强制关闭且没有关闭帧。常见于Nginx按秒断开空闲连接,或者服务端心跳间隔大于连接清理周期。解决方式是WebSocket层每25秒发一次 ping ,并把Nginx的 proxy_read_timeout 调到60秒以上。用JMeter的WebSocket Sampler压测时如果看到 stream disconnected before completion ,多半就是服务端并发连接触顶或者心跳没有响应,先查这两个点。
5.3 用一条命令验证连接闭环
不需要开浏览器,用Node脚本直接验证服务端广播逻辑:
const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:3000/ws?token=test');
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'chat', to: 'room:lobby', data: { text: 'hello' } }));
});
ws.on('message', (buf) => {
const msg = JSON.parse(buf);
console.log('received:', msg.type, msg.from);
process.exit(0);
});
如果收到 welcome 但没有 chat ,说明服务端消息路由有问题;如果两个都没有,先检查 path: '/ws' 是否和访问路径一致。 microphoneTest2.html 和 cameraTest.html 这两个调试页在生产环境也建议保留,排查“没有声音/没有图像”时,先单独测试采集设备,再走WebRTC,能把问题范围缩小一半。
更多推荐

所有评论(0)