WebSocket 深度解析
🎯 握手协议、帧格式、心跳保活、生产级实现
1. WebSocket 基础
1.1 WebSocket vs HTTP
┌─────────────────────────────────────────────────────────────────────────┐
│ WebSocket vs HTTP │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ HTTP (请求-响应模式): │
│ ───────────────────── │
│ Client ──────▶ Server │
│ ◀────── │
│ Client ──────▶ Server │
│ ◀────── │
│ 每次请求需要建立连接,服务端无法主动推送 │
│ │
│ WebSocket (全双工): │
│ ───────────────────── │
│ Client ══════════════════ Server │
│ ◀──────────────▶ │
│ ◀──────────────▶ │
│ 一次握手,持久连接,双向实时通信 │
│ │
│ 对比: │
│ ────── │
│ 维度 │ HTTP │ WebSocket │
│ ─────────────┼────────────────────┼──────────────────────────────── │
│ 连接 │ 短连接 (或 Keep-Alive) │ 持久连接 │
│ 通信方向 │ 单向 (请求-响应) │ 双向 │
│ 实时性 │ 轮询实现 │ 原生支持 │
│ 头部开销 │ 每次都有完整头部 │ 仅握手时有头部 │
│ 协议 │ http:// https:// │ ws:// wss:// │
│ │
└─────────────────────────────────────────────────────────────────────────┘1.2 适用场景
✅ 适合 WebSocket:
• 实时聊天
• 在线协作 (文档、白板)
• 实时游戏
• 股票行情
• 消息推送
• IoT 设备通信
❌ 不适合 WebSocket:
• 简单的 CRUD 操作
• 文件上传下载
• 单向数据推送 (用 SSE 更简单)
• 低频更新的数据2. WebSocket 握手协议
2.1 握手流程
┌─────────────────────────────────────────────────────────────────────────┐
│ WebSocket 握手流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Client Server │
│ │ │ │
│ │─────── HTTP GET Upgrade Request ────────────▶│ │
│ │ │ │
│ │ GET /chat HTTP/1.1 │ │
│ │ Host: server.example.com │ │
│ │ Upgrade: websocket │ │
│ │ Connection: Upgrade │ │
│ │ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== │ │
│ │ Sec-WebSocket-Version: 13 │ │
│ │ Sec-WebSocket-Protocol: chat, superchat │ │
│ │ │ │
│ │◀────── HTTP 101 Switching Protocols ─────────│ │
│ │ │ │
│ │ HTTP/1.1 101 Switching Protocols │ │
│ │ Upgrade: websocket │ │
│ │ Connection: Upgrade │ │
│ │ Sec-WebSocket-Accept: s3pPLMBiTxaQ9... │ │
│ │ Sec-WebSocket-Protocol: chat │ │
│ │ │ │
│ │═══════════ WebSocket 连接建立 ═══════════════│ │
│ │ │ │
│ │◀──────────── 双向数据帧 ─────────────────────▶│ │
│ │
└─────────────────────────────────────────────────────────────────────────┘2.2 Sec-WebSocket-Accept 计算
javascript
// 服务端计算 Sec-WebSocket-Accept
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function calculateAccept(key) {
const crypto = require('crypto');
return crypto
.createHash('sha1')
.update(key + GUID)
.digest('base64');
}
// 示例
// Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
// Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=3. WebSocket 帧格式
3.1 帧结构
┌─────────────────────────────────────────────────────────────────────────┐
│ WebSocket 帧格式 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 0 1 2 3 │
│ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 │
│ +-+-+-+-+-------+-+-------------+-------------------------------+ │
│ |F|R|R|R| opcode|M| Payload len | Extended payload length | │
│ |I|S|S|S| (4) |A| (7) | (16/64) | │
│ |N|V|V|V| |S| | (if payload len==126/127) | │
│ | |1|2|3| |K| | | │
│ +-+-+-+-+-------+-+-------------+-------------------------------+ │
│ | Extended payload length continued, if payload len == 127 | │
│ +-------------------------------+-------------------------------+ │
│ | Masking-key, if MASK set to 1 | │
│ +-------------------------------+-------------------------------+ │
│ | Payload Data | │
│ +---------------------------------------------------------------+ │
│ │
│ 字段说明: │
│ ───────── │
│ FIN (1 bit) - 是否是消息的最后一帧 │
│ RSV1-3 (3 bits) - 保留位,用于扩展 │
│ Opcode (4 bits) - 操作码 │
│ MASK (1 bit) - 是否有掩码 (客户端→服务端必须有) │
│ Payload len - 数据长度 (7/7+16/7+64 bits) │
│ Masking-key - 掩码密钥 (4 bytes, 如果 MASK=1) │
│ Payload Data - 实际数据 │
│ │
│ Opcode 值: │
│ ─────────── │
│ 0x0 - 延续帧 (continuation) │
│ 0x1 - 文本帧 (text) │
│ 0x2 - 二进制帧 (binary) │
│ 0x8 - 连接关闭 (close) │
│ 0x9 - Ping │
│ 0xA - Pong │
│ │
└─────────────────────────────────────────────────────────────────────────┘3.2 数据分片
javascript
// 大数据会被分成多个帧发送
// 帧 1: FIN=0, opcode=0x1 (text), payload="Hello "
// 帧 2: FIN=0, opcode=0x0 (continuation), payload="World"
// 帧 3: FIN=1, opcode=0x0 (continuation), payload="!"
// 接收端需要缓存并组合这些帧4. 前端 WebSocket API
4.1 基础用法
javascript
// 创建连接
const ws = new WebSocket('wss://example.com/socket');
// 连接打开
ws.onopen = (event) => {
console.log('WebSocket connected');
ws.send('Hello Server!');
};
// 接收消息
ws.onmessage = (event) => {
console.log('Received:', event.data);
// 如果是二进制数据
if (event.data instanceof Blob) {
// 处理 Blob
}
};
// 连接关闭
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
};
// 错误处理
ws.onerror = (event) => {
console.error('WebSocket error:', event);
};
// 发送数据
ws.send('text message');
ws.send(new Blob(['binary data']));
ws.send(new ArrayBuffer(8));
// 关闭连接
ws.close(1000, 'Normal closure');
// 连接状态
// ws.readyState:
// 0 - CONNECTING
// 1 - OPEN
// 2 - CLOSING
// 3 - CLOSED4.2 生产级封装
typescript
// WebSocketClient.ts
interface WebSocketClientOptions {
url: string;
protocols?: string[];
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
heartbeatInterval?: number;
onOpen?: () => void;
onClose?: (event: CloseEvent) => void;
onError?: (error: Event) => void;
onMessage?: (data: any) => void;
}
type MessageHandler = (data: any) => void;
export class WebSocketClient {
private ws: WebSocket | null = null;
private options: Required<WebSocketClientOptions>;
private reconnectAttempts = 0;
private heartbeatTimer: number | null = null;
private reconnectTimer: number | null = null;
private messageHandlers: Map<string, Set<MessageHandler>> = new Map();
private isManualClose = false;
constructor(options: WebSocketClientOptions) {
this.options = {
protocols: [],
reconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 10,
heartbeatInterval: 30000,
onOpen: () => {},
onClose: () => {},
onError: () => {},
onMessage: () => {},
...options,
};
}
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) {
return;
}
this.isManualClose = false;
this.ws = new WebSocket(this.options.url, this.options.protocols);
this.ws.onopen = () => {
console.log('[WebSocket] Connected');
this.reconnectAttempts = 0;
this.startHeartbeat();
this.options.onOpen();
};
this.ws.onmessage = (event) => {
const data = this.parseMessage(event.data);
// 心跳响应
if (data.type === 'pong') {
return;
}
// 触发全局回调
this.options.onMessage(data);
// 触发特定类型的处理器
if (data.type) {
const handlers = this.messageHandlers.get(data.type);
handlers?.forEach((handler) => handler(data));
}
};
this.ws.onclose = (event) => {
console.log('[WebSocket] Closed:', event.code, event.reason);
this.stopHeartbeat();
this.options.onClose(event);
if (!this.isManualClose && this.options.reconnect) {
this.scheduleReconnect();
}
};
this.ws.onerror = (error) => {
console.error('[WebSocket] Error:', error);
this.options.onError(error);
};
}
disconnect(code = 1000, reason = 'Normal closure'): void {
this.isManualClose = true;
this.stopHeartbeat();
this.clearReconnectTimer();
this.ws?.close(code, reason);
}
send(data: any): void {
if (this.ws?.readyState !== WebSocket.OPEN) {
console.warn('[WebSocket] Not connected, message dropped');
return;
}
const message = typeof data === 'string' ? data : JSON.stringify(data);
this.ws.send(message);
}
// 订阅特定类型的消息
on(type: string, handler: MessageHandler): () => void {
if (!this.messageHandlers.has(type)) {
this.messageHandlers.set(type, new Set());
}
this.messageHandlers.get(type)!.add(handler);
// 返回取消订阅函数
return () => {
this.messageHandlers.get(type)?.delete(handler);
};
}
private parseMessage(data: string | Blob | ArrayBuffer): any {
if (typeof data === 'string') {
try {
return JSON.parse(data);
} catch {
return { type: 'text', data };
}
}
return { type: 'binary', data };
}
private startHeartbeat(): void {
this.heartbeatTimer = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ type: 'ping', timestamp: Date.now() });
}
}, this.options.heartbeatInterval);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
console.error('[WebSocket] Max reconnect attempts reached');
return;
}
this.reconnectAttempts++;
const delay = this.options.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1);
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = window.setTimeout(() => {
this.connect();
}, delay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
get readyState(): number {
return this.ws?.readyState ?? WebSocket.CLOSED;
}
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
}4.3 React Hook
typescript
// useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient';
interface UseWebSocketReturn {
isConnected: boolean;
send: (data: any) => void;
lastMessage: any;
}
export function useWebSocket(
url: string,
options?: Partial<WebSocketClientOptions>
): UseWebSocketReturn {
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<any>(null);
const clientRef = useRef<WebSocketClient | null>(null);
useEffect(() => {
const client = new WebSocketClient({
url,
...options,
onOpen: () => {
setIsConnected(true);
options?.onOpen?.();
},
onClose: (event) => {
setIsConnected(false);
options?.onClose?.(event);
},
onMessage: (data) => {
setLastMessage(data);
options?.onMessage?.(data);
},
});
clientRef.current = client;
client.connect();
return () => {
client.disconnect();
};
}, [url]);
const send = useCallback((data: any) => {
clientRef.current?.send(data);
}, []);
return { isConnected, send, lastMessage };
}
// 使用
function ChatRoom() {
const { isConnected, send, lastMessage } = useWebSocket('wss://api.example.com/chat');
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
if (lastMessage?.type === 'chat') {
setMessages((prev) => [...prev, lastMessage.content]);
}
}, [lastMessage]);
const sendMessage = (content: string) => {
send({ type: 'chat', content });
};
return (
<div>
<div>Status: {isConnected ? '🟢 Connected' : '🔴 Disconnected'}</div>
<ul>
{messages.map((msg, i) => <li key={i}>{msg}</li>)}
</ul>
</div>
);
}5. 服务端实现
5.1 Node.js + ws
javascript
// server.js
const WebSocket = require('ws');
const http = require('http');
const server = http.createServer();
const wss = new WebSocket.Server({ server });
// 连接管理
const clients = new Map();
wss.on('connection', (ws, req) => {
const clientId = generateId();
clients.set(clientId, ws);
console.log(`Client ${clientId} connected`);
// 发送欢迎消息
ws.send(JSON.stringify({ type: 'welcome', clientId }));
// 接收消息
ws.on('message', (data) => {
try {
const message = JSON.parse(data);
handleMessage(ws, clientId, message);
} catch (e) {
console.error('Invalid message:', data);
}
});
// 心跳
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
// 断开连接
ws.on('close', () => {
console.log(`Client ${clientId} disconnected`);
clients.delete(clientId);
});
});
// 消息处理
function handleMessage(ws, clientId, message) {
switch (message.type) {
case 'ping':
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
break;
case 'chat':
// 广播消息
broadcast({
type: 'chat',
from: clientId,
content: message.content,
timestamp: Date.now(),
});
break;
case 'private':
// 私聊
const targetWs = clients.get(message.to);
if (targetWs) {
targetWs.send(JSON.stringify({
type: 'private',
from: clientId,
content: message.content,
}));
}
break;
}
}
// 广播
function broadcast(data, exclude = null) {
const message = JSON.stringify(data);
clients.forEach((ws, id) => {
if (id !== exclude && ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
});
}
// 心跳检测
setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) {
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
function generateId() {
return Math.random().toString(36).substr(2, 9);
}
server.listen(8080, () => {
console.log('WebSocket server running on port 8080');
});5.2 房间/频道管理
javascript
// 房间管理
class RoomManager {
constructor() {
this.rooms = new Map(); // roomId -> Set<clientId>
this.clientRooms = new Map(); // clientId -> Set<roomId>
}
join(clientId, roomId) {
// 添加到房间
if (!this.rooms.has(roomId)) {
this.rooms.set(roomId, new Set());
}
this.rooms.get(roomId).add(clientId);
// 记录客户端所在房间
if (!this.clientRooms.has(clientId)) {
this.clientRooms.set(clientId, new Set());
}
this.clientRooms.get(clientId).add(roomId);
return this.rooms.get(roomId).size;
}
leave(clientId, roomId) {
this.rooms.get(roomId)?.delete(clientId);
this.clientRooms.get(clientId)?.delete(roomId);
// 清理空房间
if (this.rooms.get(roomId)?.size === 0) {
this.rooms.delete(roomId);
}
}
leaveAll(clientId) {
const rooms = this.clientRooms.get(clientId) || new Set();
rooms.forEach((roomId) => this.leave(clientId, roomId));
this.clientRooms.delete(clientId);
}
getRoomMembers(roomId) {
return this.rooms.get(roomId) || new Set();
}
broadcastToRoom(roomId, data, clients, exclude = null) {
const members = this.getRoomMembers(roomId);
const message = JSON.stringify(data);
members.forEach((clientId) => {
if (clientId !== exclude) {
const ws = clients.get(clientId);
if (ws?.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
});
}
}6. 面试高频问题
Q1: WebSocket 和 HTTP 长轮询的区别?
| 维度 | WebSocket | 长轮询 |
|---|---|---|
| 连接 | 持久连接 | 反复建立 |
| 实时性 | 即时 | 有延迟 |
| 开销 | 低 | 高(头部开销) |
| 复杂度 | 需要专门服务 | HTTP 服务即可 |
Q2: WebSocket 如何实现心跳保活?
- 客户端定时发送 Ping 消息(自定义)
- 服务端收到后回复 Pong
- 超时未收到响应则断开重连
- 使用协议自带的 Ping/Pong 帧(opcode 0x9/0xA)
Q3: WebSocket 断线重连如何实现?
- 监听 onclose 事件
- 非手动关闭时触发重连
- 指数退避延迟(避免雪崩)
- 限制最大重连次数
- 重连成功后恢复订阅
Q4: 如何保证 WebSocket 消息的顺序和可靠性?
- 消息编号(sequence)
- 确认机制(ACK)
- 消息队列缓存
- 重连后同步丢失的消息
Q5: WebSocket 如何实现鉴权?
- 握手时通过 URL 参数传递 Token
- 连接建立后首个消息进行认证
- Cookie 自动携带
- 使用子协议(Sec-WebSocket-Protocol)