WebSocket 连接管理与重连
WebSocket 连接可能因为网络波动、服务器重启等原因意外断开。为了保证应用的可靠性,需要实现心跳检测、断线重连和错误处理等机制。本节介绍 WebSocket 连接管理的核心策略,包括心跳机制、指数退避重连算法和完整的连接管理封装。
前置知识
阅读本节前,建议先了解:WebSocket 基础
基础概念
连接管理的重要性
WebSocket 是长连接,需要应对各种网络异常:
| 场景 | 现象 | 处理策略 |
|---|---|---|
| 网络断开 | 无 close 事件触发 | 心跳超时检测 |
| 服务器重启 | 收到 close(1006) | 自动重连 |
| 网络切换(WiFi->4G) | 连接中断 | 自动重连 |
| 服务器过载 | 收到 close(1013) | 延迟重连 |
| 用户离开 | 页面卸载 | 主动关闭连接 |
语法与实现
心跳机制
javascript
class WebSocketWithHeartbeat {
constructor(url, options = {}) {
this.url = url;
this.heartbeatInterval = options.heartbeatInterval || 30000; // 30 秒
this.heartbeatTimeout = options.heartbeatTimeout || 5000; // 5 秒超时
this.reconnectInterval = options.reconnectInterval || 3000;
this.maxReconnectAttempts = options.maxReconnectAttempts || Infinity;
this.ws = null;
this.heartbeatTimer = null;
this.timeoutTimer = null;
this.reconnectAttempts = 0;
this.isManualClose = false;
}
connect() {
this.ws = new WebSocket(this.url);
this.reconnectAttempts++;
this.ws.onopen = () => {
console.log('WebSocket 连接已建立');
this.reconnectAttempts = 0;
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
if (event.data === 'pong') {
// 收到心跳响应,重置超时计时器
clearTimeout(this.timeoutTimer);
return;
}
// 处理业务消息
this.onMessage(event);
};
this.ws.onclose = (event) => {
this.stopHeartbeat();
if (!this.isManualClose) {
console.log(`连接断开 (code: ${event.code}),尝试重连...`);
this.reconnect();
}
};
this.ws.onerror = (event) => {
this.onError(event);
};
}
// 启动心跳
startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send('ping');
// 设置超时
this.timeoutTimer = setTimeout(() => {
console.warn('心跳超时,关闭连接');
this.ws.close();
}, this.heartbeatTimeout);
}
}, this.heartbeatInterval);
}
// 停止心跳
stopHeartbeat() {
clearInterval(this.heartbeatTimer);
clearTimeout(this.timeoutTimer);
}
// 重连
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('已达最大重连次数,停止重连');
return;
}
const delay = Math.min(
this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1),
30000 // 最大延迟 30 秒
);
console.log(`${delay / 1000} 秒后重连...`);
setTimeout(() => this.connect(), delay);
}
// 关闭连接
close() {
this.isManualClose = true;
this.stopHeartbeat();
if (this.ws) {
this.ws.close(1000, '用户关闭');
}
}
// 子类覆盖
onMessage(event) {}
onError(event) {}
}指数退避重连
javascript
// 指数退避重连算法
class ExponentialBackoff {
constructor(base = 1000, max = 30000, jitter = true) {
this.base = base;
this.max = max;
this.jitter = jitter;
this.attempt = 0;
}
// 获取下一次重连延迟
getDelay() {
const delay = Math.min(this.base * Math.pow(2, this.attempt), this.max);
this.attempt++;
if (this.jitter) {
// 添加随机抖动(正负 25%)
const jitter = delay * 0.25;
return delay - jitter + Math.random() * jitter * 2;
}
return delay;
}
// 重置计数
reset() {
this.attempt = 0;
}
}
// 使用示例
const backoff = new ExponentialBackoff(1000, 30000);
console.log('第 1 次:', backoff.getDelay()); // ~1000ms
console.log('第 2 次:', backoff.getDelay()); // ~2000ms
console.log('第 3 次:', backoff.getDelay()); // ~4000ms
console.log('第 4 次:', backoff.getDelay()); // ~8000ms
// ...直到 max (30000ms)详细说明
完整的 WebSocket 管理器
javascript
/**
* WebSocket 连接管理器
* 功能:自动重连、心跳检测、事件管理、消息队列
*/
class WSManager {
constructor(url, options = {}) {
this.url = url;
this.options = {
heartbeatInterval: 30000, // 心跳间隔
heartbeatTimeout: 10000, // 心跳超时
reconnect: true, // 自动重连
reconnectDelay: 1000, // 初始重连延迟
maxReconnectDelay: 30000, // 最大重连延迟
maxReconnectAttempts: 10, // 最大重连次数
...options
};
this.ws = null;
this.state = 'disconnected'; // disconnected, connecting, connected
this.reconnectAttempts = 0;
this.heartbeatTimer = null;
this.timeoutTimer = null;
this.messageQueue = []; // 离线消息队列
this.listeners = new Map(); // 事件监听器
this.isManualClose = false;
}
// 连接
connect() {
if (this.state === 'connecting' || this.state === 'connected') return;
this.state = 'connecting';
this._emit('connecting');
try {
this.ws = new WebSocket(this.url);
} catch (e) {
this.state = 'disconnected';
this._emit('error', e);
this._scheduleReconnect();
return;
}
this.ws.onopen = () => {
this.state = 'connected';
this.reconnectAttempts = 0;
this._startHeartbeat();
this._emit('connected');
this._flushQueue();
};
this.ws.onmessage = (event) => {
if (event.data === '__pong__') {
clearTimeout(this.timeoutTimer);
return;
}
try {
const data = JSON.parse(event.data);
this._emit('message', data);
// 按类型分发
if (data.type) {
this._emit(`message:${data.type}`, data);
}
} catch {
this._emit('message', event.data);
}
};
this.ws.onclose = (event) => {
this.state = 'disconnected';
this._stopHeartbeat();
this._emit('disconnected', { code: event.code, reason: event.reason });
if (!this.isManualClose && this.options.reconnect) {
this._scheduleReconnect();
}
};
this.ws.onerror = (event) => {
this._emit('error', event);
};
}
// 发送消息
send(data) {
const message = typeof data === 'string' ? data : JSON.stringify(data);
if (this.state === 'connected') {
this.ws.send(message);
} else {
// 连接未建立,加入队列
this.messageQueue.push(message);
if (this.state === 'disconnected') {
this.connect();
}
}
}
// 关闭连接
close(code = 1000, reason = '') {
this.isManualClose = true;
this._stopHeartbeat();
this.messageQueue = [];
if (this.ws) {
this.ws.close(code, reason);
}
}
// 事件监听
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
return this;
}
off(event, callback) {
const cbs = this.listeners.get(event);
if (cbs) {
const index = cbs.indexOf(callback);
if (index > -1) cbs.splice(index, 1);
}
return this;
}
// 内部方法
_emit(event, data) {
const cbs = this.listeners.get(event) || [];
cbs.forEach(cb => cb(data));
}
_startHeartbeat() {
this._stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send('__ping__');
this.timeoutTimer = setTimeout(() => {
this.ws.close();
}, this.options.heartbeatTimeout);
}
}, this.options.heartbeatInterval);
}
_stopHeartbeat() {
clearInterval(this.heartbeatTimer);
clearTimeout(this.timeoutTimer);
}
_scheduleReconnect() {
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
this._emit('reconnect-failed');
return;
}
const delay = Math.min(
this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts),
this.options.maxReconnectDelay
);
this.reconnectAttempts++;
this._emit('reconnecting', { attempt: this.reconnectAttempts, delay });
setTimeout(() => this.connect(), delay);
}
_flushQueue() {
while (this.messageQueue.length > 0) {
const msg = this.messageQueue.shift();
this.ws.send(msg);
}
}
}
// 使用示例
const ws = new WSManager('wss://example.com/ws');
ws.on('connected', () => {
console.log('已连接');
ws.send({ type: 'auth', token: 'abc123' });
});
ws.on('message', (data) => {
console.log('收到消息:', data);
});
ws.on('message:notification', (data) => {
console.log('通知:', data.content);
});
ws.on('reconnecting', ({ attempt, delay }) => {
console.log(`第 ${attempt} 次重连,${delay/1000} 秒后`);
});
ws.on('disconnected', ({ code }) => {
console.log(`断开连接: ${code}`);
});
ws.connect();页面卸载时清理
javascript
// 页面关闭时主动断开 WebSocket
window.addEventListener('beforeunload', () => {
if (ws) {
ws.close(1001, '页面卸载');
}
});
// 页面可见性变化处理
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 页面不可见时停止心跳(节省资源)
ws._stopHeartbeat();
} else {
// 页面可见时恢复心跳
if (ws.state === 'connected') {
ws._startHeartbeat();
}
}
});实战示例
带连接状态的 WebSocket 客户端
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WebSocket 连接管理</title>
<style>
body { font-family: -apple-system, sans-serif; padding: 20px; }
.card {
max-width: 500px; margin: 0 auto; background: white;
border-radius: 12px; padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h2 { text-align: center; }
.status-panel {
display: grid; grid-template-columns: 1fr 1fr 1fr;
gap: 12px; margin: 20px 0;
}
.status-item {
text-align: center; padding: 12px;
background: #f8f9fa; border-radius: 8px;
}
.status-value { font-size: 20px; font-weight: 700; color: #1a73e8; }
.status-label { font-size: 12px; color: #888; margin-top: 4px; }
.status-value.connected { color: #4CAF50; }
.status-value.disconnected { color: #F44336; }
.status-value.connecting { color: #FF9800; }
.event-log {
height: 250px; overflow-y: auto; padding: 12px;
background: #1a1a2e; border-radius: 8px;
font-family: monospace; font-size: 12px; color: #0f0;
}
.btn {
padding: 10px 20px; border: none; border-radius: 8px;
cursor: pointer; font-size: 14px; font-weight: 600;
color: white; display: block; width: 100%; margin-top: 12px;
}
</style>
</head>
<body>
<div class="card">
<h2>WebSocket 连接状态</h2>
<div class="status-panel">
<div class="status-item">
<div class="status-value disconnected" id="stateValue">断开</div>
<div class="status-label">连接状态</div>
</div>
<div class="status-item">
<div class="status-value" id="reconnectValue">0</div>
<div class="status-label">重连次数</div>
</div>
<div class="status-item">
<div class="status-value" id="msgCount">0</div>
<div class="status-label">收到消息</div>
</div>
</div>
<button class="btn" style="background:#4CAF50;" id="toggleBtn" onclick="toggleConnection()">
连接
</button>
<div class="event-log" id="eventLog">
等待操作...
</div>
</div>
<script>
let wsManager = null;
let connected = false;
let msgCount = 0;
function addLog(text, color = '#0f0') {
const log = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
log.innerHTML += `<div style="color:${color}">[${time}] ${text}</div>`;
log.scrollTop = log.scrollHeight;
}
function updateUI(state) {
const stateEl = document.getElementById('stateValue');
const states = { connected: ['已连接', 'connected'], disconnected: ['断开', 'disconnected'], connecting: ['连接中', 'connecting'] };
const [text, cls] = states[state] || ['断开', 'disconnected'];
stateEl.textContent = text;
stateEl.className = `status-value ${cls}`;
}
function toggleConnection() {
if (connected) {
wsManager.close();
connected = false;
document.getElementById('toggleBtn').textContent = '连接';
document.getElementById('toggleBtn').style.background = '#4CAF50';
} else {
startConnection();
}
}
function startConnection() {
wsManager = new WSManager('wss://echo.websocket.org', {
heartbeatInterval: 15000,
reconnect: true,
maxReconnectAttempts: 5
});
wsManager.on('connecting', () => {
addLog('正在连接...', '#ff0');
updateUI('connecting');
});
wsManager.on('connected', () => {
connected = true;
document.getElementById('toggleBtn').textContent = '断开';
document.getElementById('toggleBtn').style.background = '#F44336';
addLog('连接已建立', '#4CAF50');
updateUI('connected');
});
wsManager.on('message', (data) => {
msgCount++;
document.getElementById('msgCount').textContent = msgCount;
});
wsManager.on('reconnecting', ({ attempt, delay }) => {
document.getElementById('reconnectValue').textContent = attempt;
addLog(`第 ${attempt} 次重连,${(delay/1000).toFixed(1)}s 后`, '#FF9800');
});
wsManager.on('disconnected', ({ code }) => {
connected = false;
document.getElementById('toggleBtn').textContent = '连接';
document.getElementById('toggleBtn').style.background = '#4CAF50';
addLog(`连接断开 (code: ${code})`, '#F44336');
updateUI('disconnected');
});
wsManager.on('error', () => {
addLog('连接错误', '#F44336');
});
wsManager.on('reconnect-failed', () => {
addLog('重连失败,已达最大次数', '#F44336');
});
wsManager.connect();
}
</script>
</body>
</html>注意事项
- 避免无限重连:设置最大重连次数和最大延迟
- 指数退避:避免重连风暴导致服务器压力
- 页面卸载清理:
beforeunload时主动关闭连接 - 用户离开暂停:页面不可见时可以暂停心跳
- 区分手动关闭和异常断开:避免手动关闭后触发重连
最佳实践
- 封装完整的连接管理器,统一处理所有事件
- 使用心跳检测"假死"连接(有连接但无数据传输)
- 为离线消息建立队列,重连后自动发送
- 使用指数退避算法控制重连频率
- 提供清晰的连接状态 UI 反馈
下一节
继续学习:WebSocket 实战