Skip to content

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 - CLOSED

4.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 如何实现心跳保活?

  1. 客户端定时发送 Ping 消息(自定义)
  2. 服务端收到后回复 Pong
  3. 超时未收到响应则断开重连
  4. 使用协议自带的 Ping/Pong 帧(opcode 0x9/0xA)

Q3: WebSocket 断线重连如何实现?

  1. 监听 onclose 事件
  2. 非手动关闭时触发重连
  3. 指数退避延迟(避免雪崩)
  4. 限制最大重连次数
  5. 重连成功后恢复订阅

Q4: 如何保证 WebSocket 消息的顺序和可靠性?

  1. 消息编号(sequence)
  2. 确认机制(ACK)
  3. 消息队列缓存
  4. 重连后同步丢失的消息

Q5: WebSocket 如何实现鉴权?

  1. 握手时通过 URL 参数传递 Token
  2. 连接建立后首个消息进行认证
  3. Cookie 自动携带
  4. 使用子协议(Sec-WebSocket-Protocol)

前端面试知识库