6. WebSocket 客户端实现:浏览器端建立连接、发送消息、接收消息、处理断开重连
好,咱们今天来聊聊浏览器端的 WebSocket 客户端。说实话,这部分是我个人觉得整个信令链路里最「接地气」的一环。为什么?因为服务器写得再漂亮,最终还是要跟浏览器里的 JavaScript 打交道。你想想看,用户打开一个网页,点一下「加入房间」,背后就是这一套逻辑在跑。
我刚开始做 WebRTC 项目时,总觉得客户端就是 new 一个 WebSocket 对象,然后 onmessage 里处理一下就行了。结果呢?线上用户反馈说「视频连不上」,排查了半天,发现是 WebSocket 断连后没有自动重连,信令丢了。嗯,从那以后,我再也不敢小看客户端实现了。
6.1 建立连接:从 URL 到 readyState
浏览器端建立 WebSocket 连接,核心就一行代码:
const ws = new WebSocket('ws://localhost:8080/ws');
但这里有几个坑,我一个个说。
第一,协议选择。 开发环境用 ws://,生产环境一定要用 wss://(加密)。我曾经见过一个团队,上线忘了改协议,结果所有信令都被中间人截获了。WebRTC 的媒体流虽然是加密的,但信令里携带的 SDP 和 ICE 候选信息一旦泄露,攻击者就能伪造连接。
第二,连接状态。 WebSocket 对象有一个 readyState 属性,取值如下:
| 常量 | 值 | 含义 |
|---|---|---|
| CONNECTING | 0 | 正在建立连接 |
| OPEN | 1 | 连接已建立,可以通信 |
| CLOSING | 2 | 正在关闭连接 |
| CLOSED | 3 | 连接已关闭或无法建立 |
我个人习惯在发送消息前先检查 readyState:
function sendMessage(ws, msg) {
if (ws.readyState !== WebSocket.OPEN) {
console.warn('连接未就绪,消息已缓存');
// 这里可以推入队列,等重连后发送
return false;
}
ws.send(JSON.stringify(msg));
return true;
}
第三,连接超时。 原生 WebSocket 没有内置超时机制。我建议自己加一个定时器:
function connectWithTimeout(url, timeout = 5000) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
ws.close();
reject(new Error('连接超时'));
}, timeout);
ws.onopen = () => {
clearTimeout(timer);
resolve(ws);
};
ws.onerror = (err) => {
clearTimeout(timer);
reject(err);
};
});
}
6.2 发送消息:序列化与队列管理
WebSocket 发送的数据必须是字符串或二进制数据。对于信令,我们通常用 JSON 格式。这里有个细节:一定要先序列化再发送。
// 正确做法
ws.send(JSON.stringify({ type: 'join', roomId: '123' }));
// 错误做法——发送了对象,浏览器会报错
ws.send({ type: 'join', roomId: '123' });
为什么强调这个?因为我在 code review 时见过有人直接传对象,浏览器默默把它转成了 [object Object] 字符串,服务器收到后解析失败,整个信令流程卡住了。
消息队列 是另一个容易被忽略的点。连接还没建立时,用户可能已经触发了发送操作。比如用户快速点击「加入房间」按钮,而 WebSocket 还在 CONNECTING 状态。这时候如果不做队列,消息就丢了。
class SignalingClient {
constructor(url) {
this.ws = null;
this.url = url;
this.pendingMessages = [];
this.isConnected = false;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.isConnected = true;
// 发送积压的消息
while (this.pendingMessages.length) {
this.ws.send(this.pendingMessages.shift());
}
};
}
send(msg) {
const data = JSON.stringify(msg);
if (this.isConnected) {
this.ws.send(data);
} else {
this.pendingMessages.push(data);
}
}
}
6.3 接收消息:onmessage 与协议解析
接收消息用 onmessage 回调。但这里有个容易被忽视的问题:消息可能分片到达。虽然 WebSocket 底层已经帮我们做了重组,但如果你用了一些代理或中间件,还是有可能收到不完整的消息。
我建议在接收端做一层简单的校验:
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
// 检查消息结构是否完整
if (!msg.type) {
console.warn('收到无效消息,缺少 type 字段', msg);
return;
}
handleMessage(msg);
} catch (e) {
console.error('消息解析失败', e);
// 这里可以触发重连或上报异常
}
};
实际项目中,我习惯把消息处理做成一个分发器:
const handlers = {
'join': (msg) => { /* 处理加入房间 */ },
'offer': (msg) => { /* 处理 SDP offer */ },
'ice-candidate': (msg) => { /* 处理 ICE 候选 */ },
'error': (msg) => { /* 显示错误提示 */ },
};
function handleMessage(msg) {
const handler = handlers[msg.type];
if (handler) {
handler(msg);
} else {
console.warn('未知消息类型:', msg.type);
}
}
这样做的好处是,新增一种信令类型时,只需要加一个 handler 函数,不用改主逻辑。我在一个中型项目中用了这种模式,后来加「屏幕共享」信令时,只花了 10 分钟就搞定了。
6.4 断开重连:心跳与指数退避
这是整个客户端实现里最考验工程能力的部分。网络不稳定、服务器重启、用户切换网络……任何情况都可能导致 WebSocket 断开。如果不做重连,用户就得刷新页面,体验极差。
第一步:检测断开。 WebSocket 提供了 onclose 和 onerror 事件:
ws.onclose = (event) => {
console.log('连接关闭,code:', event.code, 'reason:', event.reason);
// 1000 是正常关闭,其他 code 需要重连
if (event.code !== 1000) {
reconnect();
}
};
ws.onerror = (err) => {
console.error('WebSocket 错误', err);
// onerror 之后通常会触发 onclose,所以重连逻辑放在 onclose 里
};
第二步:心跳机制。 为什么需要心跳?因为有些代理或防火墙会在一段时间无数据后自动断开连接。我见过最坑的情况是——用户开着页面去吃饭,回来发现视频断了,但 WebSocket 的 onclose 根本没触发(因为连接被静默掐断了)。
// 每隔 30 秒发送一次心跳
const HEARTBEAT_INTERVAL = 30000;
let heartbeatTimer = null;
function startHeartbeat(ws) {
heartbeatTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() {
clearInterval(heartbeatTimer);
}
服务器收到 ping 后,应该回复 pong。如果客户端连续几次没收到 pong,就可以判定连接已断开,主动触发重连。
第三步:指数退避重连。 不要一断开就立即重连,更不要用固定间隔重连。如果服务器挂了,所有客户端同时重连会造成「惊群效应」。指数退避是标准做法:
class ReconnectManager {
constructor(maxRetries = 10, baseDelay = 1000) {
this.maxRetries = maxRetries;
this.baseDelay = baseDelay;
this.retryCount = 0;
this.timer = null;
}
reset() {
this.retryCount = 0;
clearTimeout(this.timer);
}
attempt(callback) {
if (this.retryCount >= this.maxRetries) {
console.error('重连次数已达上限,请手动刷新');
return;
}
const delay = this.baseDelay * Math.pow(2, this.retryCount) + Math.random() * 1000;
console.log(`第 ${this.retryCount + 1} 次重连,等待 ${Math.round(delay)}ms`);
this.timer = setTimeout(() => {
this.retryCount++;
callback();
}, delay);
}
}
这里我加了一个随机因子(Math.random() * 1000),防止多个客户端同时重连。实际项目中,我还见过更复杂的策略——根据网络类型(WiFi/4G)动态调整重连间隔。
6.5 完整客户端示例
把上面所有内容整合起来,一个生产可用的 WebSocket 客户端大概长这样:
class WebSocketClient {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectManager = new ReconnectManager();
this.messageQueue = [];
this.handlers = {};
this.heartbeatTimer = null;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('连接成功');
this.reconnectManager.reset();
this.startHeartbeat();
this.flushQueue();
};
this.ws.onmessage = (event) => this.dispatch(event);
this.ws.onclose = (event) => {
this.stopHeartbeat();
if (event.code !== 1000) {
this.reconnectManager.attempt(() => this.connect());
}
};
this.ws.onerror = () => {
// onclose 会处理重连
};
}
send(type, payload) {
const msg = JSON.stringify({ type, payload, timestamp: Date.now() });
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(msg);
} else {
this.messageQueue.push(msg);
}
}
flushQueue() {
while (this.messageQueue.length) {
this.ws.send(this.messageQueue.shift());
}
}
dispatch(event) {
try {
const msg = JSON.parse(event.data);
const handler = this.handlers[msg.type];
if (handler) handler(msg.payload);
} catch (e) {
console.error('消息处理异常', e);
}
}
on(type, callback) {
this.handlers[type] = callback;
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
this.send('ping');
}, 30000);
}
stopHeartbeat() {
clearInterval(this.heartbeatTimer);
}
close() {
this.reconnectManager.reset();
this.stopHeartbeat();
this.ws.close(1000, '用户主动关闭');
}
}
使用方式也很直观:
const client = new WebSocketClient('wss://signaling.example.com/ws');
client.on('join', (payload) => { /* 处理加入 */ });
client.on('offer', (payload) => { /* 处理 offer */ });
client.connect();
6.6 知识体系总览
下面这张图总结了浏览器端 WebSocket 客户端的核心模块和流程:
从图上可以看得很清楚:连接管理、消息收发、重连机制是三个并行的核心模块。它们各自有子模块,最终汇聚成稳定的信令通信能力。我个人觉得,重连机制是最能体现工程水平的——写一个能用的 WebSocket 客户端不难,但写一个能在弱网环境下稳定工作的客户端,需要花不少心思。
好了,浏览器端的 WebSocket 客户端就讲到这里。记住一句话:信令是 WebRTC 的骨架,而客户端是实现骨架血肉的关键。下一节我们会把服务器和客户端串起来,做一个完整的信令交互演示。
公众号:蓝海资料掘金营,微信deep3321