WebSocket 连上了,然后呢?聊聊实时数据的"后半场"

如果你搜索"WebSocket 教程",大概率前 10 个结果都会告诉你:只需要 new WebSocket(url) 就行了。你试了一下,确实能收发消息,看起来很简单。然后你开始写真实项目,发现:

  • 网络断了怎么办?
  • 消息顺序乱了怎么办?
  • 多个组件都需要这些数据怎么办?

这时你才意识到:连接只是开始,"后半场"才是挑战。

这篇文章是我在探索 WebSocket 生产环境实践时踩过的坑和思考的总结。我想聊聊从 Demo 到生产环境这条路上,那些容易被忽视的细节。

从 3 行代码到 300 行代码

最简单的 WebSocket(3 行代码)

javascript 复制代码
// 环境:浏览器
// 场景:最基础的 WebSocket 连接

const ws = new WebSocket('wss://echo.websocket.org');

ws.onmessage = (event) => {
  console.log('收到消息:', event.data);
};

ws.send('Hello WebSocket!');

在 Demo 里确实够用了。但当我尝试把这段代码用到实际项目中时,很快就遇到了一堆问题:

问题清单

  • ❌ 网络断了,连接中断,怎么重连?
  • ❌ 消息发送失败了,要不要重试?
  • ❌ 收到的消息顺序乱了,如何处理?
  • ❌ 多个组件都需要实时数据,如何共享连接?
  • ❌ 组件卸载了,如何清理?
  • ❌ 需要在消息里区分类型(聊天、通知、系统消息),如何设计?
  • ❌ 需要心跳保活,如何实现?
  • ❌ 如何与 React 状态管理集成?

生产级别的 WebSocket(完整代码预览)

javascript 复制代码
// 环境:React + WebSocket
// 场景:生产级别的 WebSocket 管理器

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectDelay = 1000;
    this.heartbeatInterval = null;
    this.messageQueue = [];
    this.listeners = new Map();
  }
  
  connect() { /* 连接逻辑 */ }
  reconnect() { /* 重连逻辑 */ }
  send(data) { /* 发送消息(支持离线队列)*/ }
  subscribe(event, callback) { /* 订阅消息 */ }
  startHeartbeat() { /* 心跳保活 */ }
  handleMessage(event) { /* 消息分发 */ }
  close() { /* 清理资源 */ }
}

// 这才是真实项目需要的代码

代码行数对比

  • Demo:~10 行
  • 生产环境:~200-300 行

这 300 行都在干什么?让我们一步步拆解。

第一个挑战:连接管理

断线重连

网络并不总是稳定的。用户可能在地铁里,可能在切换 WiFi,也可能服务器临时重启。一个简单的做法是在连接断开时什么都不做,但这意味着用户将永远收不到新消息。

javascript 复制代码
// 环境:浏览器
// 场景:实现自动重连机制

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectDelay = 1000;
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      console.log('连接成功');
      this.reconnectAttempts = 0;  // 重置重连次数
      this.startHeartbeat();       // 开始心跳
    };
    
    this.ws.onclose = (event) => {
      console.log('连接关闭', event);
      this.stopHeartbeat();
      
      // 判断是否需要重连
      if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
        this.reconnect();
      }
    };
    
    this.ws.onerror = (error) => {
      console.error('连接错误', error);
    };
  }
  
  reconnect() {
    this.reconnectAttempts++;
    
    // 指数退避:1s, 2s, 4s, 8s, 16s
    const delay = Math.min(
      this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
      30000  // 最多等 30 秒
    );
    
    console.log(`${delay}ms 后重连(第 ${this.reconnectAttempts} 次)`);
    
    setTimeout(() => {
      console.log('尝试重连...');
      this.connect();
    }, delay);
  }
}

我采用了指数退避策略。为什么不是固定间隔重连?因为如果服务器真的挂了,成百上千的客户端同时每秒重连一次,可能会加剧服务器压力。指数退避能让重连间隔逐渐变长,给服务器喘息的时间。

重连策略对比

策略 优点 缺点 适用场景
固定间隔 简单 可能导致服务器压力 内网环境
指数退避 避免雪崩 重连时间可能过长 生产环境(推荐)
立即重连 快速恢复 可能被服务器拒绝 不推荐

心跳保活

为什么需要心跳?一个常见的场景是:用户打开页面后就不动了,20 分钟后回来发现收不到消息了。这是因为中间的代理服务器(如 Nginx)可能有超时设置,会主动关闭"看起来没有流量"的连接。

javascript 复制代码
// 环境:浏览器 + WebSocket
// 场景:实现心跳机制

class WebSocketManager {
  constructor(url) {
    // ... 其他初始化代码
    this.heartbeatInterval = null;
    this.heartbeatTimeout = null;
  }
  
  startHeartbeat() {
    // 每 30 秒发送一次心跳
    this.heartbeatInterval = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
        
        // 设置超时检测
        this.heartbeatTimeout = setTimeout(() => {
          console.warn('心跳超时,主动关闭连接');
          this.ws.close();
          // onclose 会触发重连
        }, 5000);  // 5 秒内没收到 pong 就认为超时
      }
    }, 30000);
  }
  
  stopHeartbeat() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
    }
    if (this.heartbeatTimeout) {
      clearTimeout(this.heartbeatTimeout);
    }
  }
  
  handleMessage(event) {
    const message = JSON.parse(event.data);
    
    if (message.type === 'pong') {
      // 收到 pong,清除超时
      clearTimeout(this.heartbeatTimeout);
      return;
    }
    
    // 处理其他消息...
  }
}

心跳参数的选择也有讲究。30 秒是我觉得比较平衡的值------既不会产生太多流量,又能及时发现连接问题。如果你的 Nginx 超时设置是 60 秒,那 30 秒的心跳间隔刚好够用。

连接状态管理

在 UI 中显示连接状态是个很实用的功能,能让用户知道发生了什么。

javascript 复制代码
// 环境:React + WebSocket + Zustand
// 场景:在 UI 中显示连接状态

import { create } from 'zustand';

const useWebSocketStore = create((set) => ({
  status: 'disconnected',  // disconnected | connecting | connected | reconnecting
  setStatus: (status) => set({ status }),
}));

class WebSocketManager {
  connect() {
    useWebSocketStore.getState().setStatus('connecting');
    
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      useWebSocketStore.getState().setStatus('connected');
    };
    
    this.ws.onclose = () => {
      useWebSocketStore.getState().setStatus('disconnected');
      this.reconnect();
    };
  }
  
  reconnect() {
    useWebSocketStore.getState().setStatus('reconnecting');
    // ...
  }
}

// 组件中使用
function ConnectionStatus() {
  const status = useWebSocketStore((state) => state.status);
  
  const statusConfig = {
    disconnected: { color: 'red', text: '未连接' },
    connecting: { color: 'yellow', text: '连接中...' },
    connected: { color: 'green', text: '已连接' },
    reconnecting: { color: 'orange', text: '重连中...' },
  };
  
  const config = statusConfig[status];
  
  return (
    <div style={{ color: config.color }}>
      ● {config.text}
    </div>
  );
}

第二个挑战:消息管理

消息类型区分

真实应用中,WebSocket 连接通常会传输多种类型的消息:聊天消息、系统通知、用户状态更新等。如果把所有消息混在一起处理,代码会变得很乱。

javascript 复制代码
// 环境:浏览器
// 场景:实现消息分发机制

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.listeners = new Map();  // 存储各种类型的监听器
  }
  
  // 订阅某类消息
  subscribe(type, callback) {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type).add(callback);
    
    // 返回取消订阅函数
    return () => {
      this.listeners.get(type).delete(callback);
    };
  }
  
  // 分发消息
  handleMessage(event) {
    const message = JSON.parse(event.data);
    
    // 分发给对应类型的监听器
    if (this.listeners.has(message.type)) {
      this.listeners.get(message.type).forEach(callback => {
        callback(message.data);
      });
    }
  }
}

// 使用示例
const ws = new WebSocketManager('wss://...');

// 不同组件订阅不同类型的消息
ws.subscribe('chat_message', (data) => {
  console.log('收到聊天消息:', data);
});

ws.subscribe('notification', (data) => {
  console.log('收到通知:', data);
});

ws.subscribe('system', (data) => {
  console.log('收到系统消息:', data);
});

我设计的消息格式是这样的:

javascript 复制代码
// 标准消息格式
{
  "type": "chat_message",     // 消息类型
  "id": "msg_123",            // 消息 ID(用于去重)
  "timestamp": 1234567890,    // 时间戳(用于排序)
  "data": {                   // 业务数据
    "from": "user_1",
    "content": "Hello",
    "roomId": "room_123"
  }
}

消息顺序与去重

网络抖动可能导致消息乱序或重复。特别是在聊天应用中,如果消息顺序乱了,对话就没法看了。

javascript 复制代码
// 环境:浏览器
// 场景:处理消息乱序和去重

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.messageBuffer = [];      // 消息缓冲区
    this.processedMessageIds = new Set();  // 已处理的消息 ID
    this.expectedSeq = 0;         // 期望的序列号
  }
  
  handleMessage(event) {
    const message = JSON.parse(event.data);
    
    // 1. 去重
    if (this.processedMessageIds.has(message.id)) {
      console.log('重复消息,忽略:', message.id);
      return;
    }
    
    // 2. 检查顺序
    if (message.seq === this.expectedSeq) {
      // 顺序正确,直接处理
      this.processMessage(message);
      this.processedMessageIds.add(message.id);
      this.expectedSeq++;
      
      // 3. 检查缓冲区中是否有后续消息
      this.processBufferedMessages();
    } else if (message.seq > this.expectedSeq) {
      // 顺序不对,放入缓冲区
      console.log('消息乱序,缓存:', message.seq);
      this.messageBuffer.push(message);
      this.messageBuffer.sort((a, b) => a.seq - b.seq);
    }
    // seq < expectedSeq 说明是旧消息,忽略
  }
  
  processBufferedMessages() {
    while (this.messageBuffer.length > 0) {
      const nextMessage = this.messageBuffer[0];
      
      if (nextMessage.seq === this.expectedSeq) {
        this.messageBuffer.shift();
        this.processMessage(nextMessage);
        this.processedMessageIds.add(nextMessage.id);
        this.expectedSeq++;
      } else {
        break;  // 还有消息缺失,等待
      }
    }
  }
  
  processMessage(message) {
    // 分发给监听器
    if (this.listeners.has(message.type)) {
      this.listeners.get(message.type).forEach(callback => {
        callback(message.data);
      });
    }
  }
}

这个方案的核心思路是:维护一个期望序列号,收到消息时检查序列号是否匹配。如果不匹配就先缓存起来,等缺失的消息到了再按顺序处理。

离线消息队列

如果用户在断网状态下发送消息,该怎么办?一种做法是直接丢弃,但这对用户体验不太好。更好的方案是把消息暂存在队列里,等连接恢复后再发送。

javascript 复制代码
// 环境:浏览器
// 场景:发送消息时连接断开的处理

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.messageQueue = [];  // 待发送的消息队列
  }
  
  send(data) {
    const message = typeof data === 'string' ? data : JSON.stringify(data);
    
    if (this.ws.readyState === WebSocket.OPEN) {
      // 连接正常,直接发送
      this.ws.send(message);
    } else {
      // 连接断开,加入队列
      console.log('连接未就绪,消息加入队列');
      this.messageQueue.push(message);
    }
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      console.log('连接成功');
      
      // 发送队列中的消息
      while (this.messageQueue.length > 0) {
        const message = this.messageQueue.shift();
        this.ws.send(message);
      }
    };
  }
}

消息确认机制

在一些重要场景(比如支付、订单),我们需要确保消息真的送达了。可以实现一个类似 TCP 的 ACK 机制。

javascript 复制代码
// 环境:浏览器
// 场景:确保消息送达

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.pendingMessages = new Map();  // 待确认的消息
    this.messageTimeout = 5000;        // 超时时间
  }
  
  sendWithAck(data) {
    return new Promise((resolve, reject) => {
      const messageId = `msg_${Date.now()}_${Math.random()}`;
      const message = {
        id: messageId,
        ...data,
      };
      
      // 设置超时
      const timeoutId = setTimeout(() => {
        this.pendingMessages.delete(messageId);
        reject(new Error('消息发送超时'));
      }, this.messageTimeout);
      
      // 存储待确认消息
      this.pendingMessages.set(messageId, {
        resolve,
        reject,
        timeoutId,
      });
      
      // 发送消息
      this.ws.send(JSON.stringify(message));
    });
  }
  
  handleMessage(event) {
    const message = JSON.parse(event.data);
    
    // 处理确认消息
    if (message.type === 'ack') {
      const pending = this.pendingMessages.get(message.messageId);
      if (pending) {
        clearTimeout(pending.timeoutId);
        pending.resolve(message);
        this.pendingMessages.delete(message.messageId);
      }
      return;
    }
    
    // 处理其他消息...
  }
}

// 使用示例
try {
  await ws.sendWithAck({
    type: 'chat_message',
    content: 'Hello',
  });
  console.log('消息发送成功');
} catch (error) {
  console.error('消息发送失败:', error);
}

第三个挑战:与 React 集成

自定义 Hook 封装

把 WebSocket 逻辑封装成 React Hook,可以让代码更清晰,也更容易在不同组件间复用。

javascript 复制代码
// 环境:React + WebSocket
// 场景:封装可复用的 WebSocket Hook
// 依赖:react

import { useEffect, useRef, useState } from 'react';

function useWebSocket(url) {
  const wsRef = useRef(null);
  const [status, setStatus] = useState('disconnected');
  const [lastMessage, setLastMessage] = useState(null);
  
  useEffect(() => {
    const ws = new WebSocketManager(url);
    wsRef.current = ws;
    
    ws.connect();
    
    // 订阅状态变化
    const unsubscribeStatus = useWebSocketStore.subscribe(
      (state) => state.status,
      setStatus
    );
    
    // 清理
    return () => {
      unsubscribeStatus();
      ws.close();
    };
  }, [url]);
  
  const sendMessage = (data) => {
    wsRef.current?.send(data);
  };
  
  const subscribe = (type, callback) => {
    return wsRef.current?.subscribe(type, callback);
  };
  
  return {
    status,
    sendMessage,
    subscribe,
    lastMessage,
  };
}

// 使用示例
function ChatRoom() {
  const { status, sendMessage, subscribe } = useWebSocket('wss://...');
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    const unsubscribe = subscribe('chat_message', (data) => {
      setMessages(prev => [...prev, data]);
    });
    
    return unsubscribe;
  }, [subscribe]);
  
  return (
    <div>
      <ConnectionStatus status={status} />
      {messages.map(msg => <Message key={msg.id} data={msg} />)}
      <button onClick={() => sendMessage({ type: 'chat_message', content: 'Hi' })}>
        发送
      </button>
    </div>
  );
}

与 React Query 集成

WebSocket 更适合推送实时数据,而 React Query 更适合管理缓存数据。如果能把两者结合起来,就能实现"REST API 获取历史数据 + WebSocket 推送增量更新"的模式。

javascript 复制代码
// 环境:React + WebSocket + React Query
// 场景:WebSocket 消息触发缓存更新
// 依赖:@tanstack/react-query

import { useQueryClient } from '@tanstack/react-query';

function ChatRoom({ roomId }) {
  const queryClient = useQueryClient();
  const { subscribe } = useWebSocket('wss://...');
  
  // REST API 获取历史消息
  const { data: messages } = useQuery({
    queryKey: ['messages', roomId],
    queryFn: () => fetchMessages(roomId),
  });
  
  useEffect(() => {
    // 收到新消息时,更新 React Query 缓存
    const unsubscribe = subscribe('chat_message', (newMessage) => {
      queryClient.setQueryData(['messages', roomId], (old) => {
        return [...(old || []), newMessage];
      });
    });
    
    return unsubscribe;
  }, [subscribe, queryClient, roomId]);
  
  return (
    <div>
      {messages?.map(msg => <Message key={msg.id} data={msg} />)}
    </div>
  );
}

多组件共享 WebSocket 连接

如果每个组件都创建一个 WebSocket 连接,会浪费资源。更好的做法是用 Context 在全局共享一个连接。

javascript 复制代码
// 环境:React Context + WebSocket
// 场景:全局共享 WebSocket 实例
// 依赖:react

import { createContext, useContext, useEffect, useRef } from 'react';

const WebSocketContext = createContext(null);

export function WebSocketProvider({ url, children }) {
  const wsRef = useRef(null);
  
  useEffect(() => {
    wsRef.current = new WebSocketManager(url);
    wsRef.current.connect();
    
    return () => {
      wsRef.current.close();
    };
  }, [url]);
  
  return (
    <WebSocketContext.Provider value={wsRef.current}>
      {children}
    </WebSocketContext.Provider>
  );
}

export function useWS() {
  const ws = useContext(WebSocketContext);
  if (!ws) {
    throw new Error('useWS must be used within WebSocketProvider');
  }
  return ws;
}

// 使用
function App() {
  return (
    <WebSocketProvider url="wss://...">
      <ChatRoom />
      <NotificationPanel />
      <UserList />
    </WebSocketProvider>
  );
}

function ChatRoom() {
  const ws = useWS();
  
  useEffect(() => {
    return ws.subscribe('chat_message', handleMessage);
  }, [ws]);
  
  // ...
}

实战场景思考

场景 1:聊天应用

聊天是 WebSocket 最常见的应用场景。这里有个有意思的问题:如何处理"消息正在发送"的状态?

javascript 复制代码
// 环境:React + WebSocket
// 场景:完整的聊天室实现
// 依赖:react

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [inputValue, setInputValue] = useState('');
  const ws = useWS();
  const messagesEndRef = useRef(null);
  
  // 获取历史消息(REST API)
  useEffect(() => {
    fetchHistoryMessages(roomId).then(setMessages);
  }, [roomId]);
  
  // 监听新消息(WebSocket)
  useEffect(() => {
    const unsubscribe = ws.subscribe('chat_message', (message) => {
      if (message.roomId === roomId) {
        setMessages(prev => {
          // 去重
          if (prev.some(m => m.id === message.id)) {
            return prev;
          }
          return [...prev, message];
        });
        
        // 自动滚动到底部
        messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
      }
    });
    
    return unsubscribe;
  }, [ws, roomId]);
  
  // 发送消息
  const sendMessage = async () => {
    if (!inputValue.trim()) return;
    
    const tempId = `temp_${Date.now()}`;
    const newMessage = {
      id: tempId,
      roomId,
      content: inputValue,
      sender: 'me',
      timestamp: Date.now(),
      status: 'sending',  // sending | sent | failed
    };
    
    // 乐观更新
    setMessages(prev => [...prev, newMessage]);
    setInputValue('');
    
    try {
      // 发送到服务器
      await ws.sendWithAck({
        type: 'chat_message',
        roomId,
        content: inputValue,
      });
      
      // 更新状态为已发送
      setMessages(prev =>
        prev.map(m => m.id === tempId ? { ...m, status: 'sent' } : m)
      );
    } catch (error) {
      // 发送失败
      setMessages(prev =>
        prev.map(m => m.id === tempId ? { ...m, status: 'failed' } : m)
      );
    }
  };
  
  return (
    <div className="chat-room">
      <div className="messages">
        {messages.map(msg => (
          <MessageBubble key={msg.id} message={msg} />
        ))}
        <div ref={messagesEndRef} />
      </div>
      
      <div className="input-area">
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
          placeholder="输入消息..."
        />
        <button onClick={sendMessage}>发送</button>
      </div>
    </div>
  );
}

// 消息气泡组件
function MessageBubble({ message }) {
  const statusIcon = {
    sending: '⏱',
    sent: '✓',
    failed: '✗',
  };
  
  return (
    <div className={`message ${message.sender === 'me' ? 'mine' : 'theirs'}`}>
      <div className="content">{message.content}</div>
      <div className="meta">
        <span className="time">{formatTime(message.timestamp)}</span>
        {message.sender === 'me' && (
          <span className="status">{statusIcon[message.status]}</span>
        )}
      </div>
    </div>
  );
}

我采用的是乐观更新策略:先在本地立即显示消息(状态为"发送中"),等服务器确认后再更新状态。这样用户体验会更流畅。

场景 2:协作编辑

协作编辑比聊天更复杂,因为需要处理冲突。比如两个人同时编辑同一段文字,该怎么办?

javascript 复制代码
// 环境:React + WebSocket
// 场景:多人实时协作编辑
// 依赖:react
// 注意:这里简化了 OT 算法的实现

function CollaborativeEditor({ documentId }) {
  const [content, setContent] = useState('');
  const [cursors, setCursors] = useState({});  // 其他用户的光标位置
  const editorRef = useRef(null);
  const ws = useWS();
  
  // 获取文档内容
  useEffect(() => {
    fetchDocument(documentId).then(doc => {
      setContent(doc.content);
    });
  }, [documentId]);
  
  // 监听其他用户的编辑
  useEffect(() => {
    const unsubscribeEdit = ws.subscribe('document_edit', (operation) => {
      if (operation.documentId === documentId) {
        // 应用操作(这里需要 OT 或 CRDT 算法)
        setContent(prev => applyOperation(prev, operation));
      }
    });
    
    const unsubscribeCursor = ws.subscribe('cursor_move', (data) => {
      if (data.documentId === documentId) {
        setCursors(prev => ({
          ...prev,
          [data.userId]: data.position,
        }));
      }
    });
    
    return () => {
      unsubscribeEdit();
      unsubscribeCursor();
    };
  }, [ws, documentId]);
  
  // 本地编辑
  const handleChange = (e) => {
    const newContent = e.target.value;
    const operation = generateOperation(content, newContent);
    
    // 本地立即应用
    setContent(newContent);
    
    // 广播给其他用户
    ws.send({
      type: 'document_edit',
      documentId,
      operation,
    });
  };
  
  // 光标移动
  const handleCursorMove = (position) => {
    ws.send({
      type: 'cursor_move',
      documentId,
      position,
    });
  };
  
  return (
    <div className="editor">
      <textarea
        ref={editorRef}
        value={content}
        onChange={handleChange}
        onSelect={(e) => handleCursorMove(e.target.selectionStart)}
      />
      
      {/* 显示其他用户的光标 */}
      {Object.entries(cursors).map(([userId, position]) => (
        <Cursor key={userId} userId={userId} position={position} />
      ))}
    </div>
  );
}

协作编辑的核心是 OT(Operational Transformation)或 CRDT(Conflict-free Replicated Data Type)算法。这超出了本文的范围,但知道 WebSocket 只是传输层,真正的难点在算法设计,这一点很重要。

场景 3:股票行情推送

股票价格是典型的高频实时数据。这个场景的特点是:数据更新频繁,但不需要每条数据都处理。

javascript 复制代码
// 环境:React + WebSocket
// 场景:实时股票价格
// 依赖:react

function StockPriceWidget({ symbols }) {
  const [prices, setPrices] = useState({});
  const ws = useWS();
  
  useEffect(() => {
    // 订阅股票
    ws.send({
      type: 'subscribe',
      symbols: symbols,
    });
    
    // 监听价格更新
    const unsubscribe = ws.subscribe('price_update', (update) => {
      setPrices(prev => ({
        ...prev,
        [update.symbol]: {
          price: update.price,
          change: update.change,
          timestamp: update.timestamp,
        },
      }));
    });
    
    return () => {
      // 取消订阅
      ws.send({
        type: 'unsubscribe',
        symbols: symbols,
      });
      unsubscribe();
    };
  }, [ws, symbols]);
  
  return (
    <div className="stock-widget">
      {symbols.map(symbol => {
        const data = prices[symbol];
        const changeColor = data?.change >= 0 ? 'green' : 'red';
        
        return (
          <div key={symbol} className="stock-item">
            <span className="symbol">{symbol}</span>
            <span className="price">${data?.price?.toFixed(2)}</span>
            <span className="change" style={{ color: changeColor }}>
              {data?.change >= 0 ? '+' : ''}{data?.change?.toFixed(2)}%
            </span>
          </div>
        );
      })}
    </div>
  );
}

部署方案选择

WebSocket 的部署和普通 HTTP 服务不太一样,主要区别在于需要保持长连接。

云平台方案对比

平台 优点 缺点 适用场景 定价
AWS API Gateway + Lambda 托管服务,自动扩展 冷启动延迟,15分钟连接限制 中小型应用 按连接时长
AWS EC2 + Socket.IO 完全控制,无连接限制 需要自己维护 大型应用 按实例
Vercel + Pusher 简单易用 依赖第三方,价格较贵 快速原型 按连接数
Railway / Render 部署简单,支持 WebSocket 免费版有限制 个人项目 免费/付费
Cloudflare Workers 边缘计算,延迟低 Durable Objects 较新 全球分布式 按请求
自建 VPS (DigitalOcean) 最灵活,成本可控 需要运维经验 有技术团队 固定费用

我的建议是:

  • 小型项目(<1000 并发):Railway / Render,部署简单,成本低
  • 中型项目(1000-10000 并发):AWS EC2 + Socket.IO,可控性强
  • 大型项目(10000+ 并发):自建集群 + Redis,需要专业团队

部署到 Railway(示例)

bash 复制代码
# 1. 创建项目
npm init -y
npm install express socket.io

# 2. server.js
javascript 复制代码
// 环境:Node.js 18+
// 场景:WebSocket 服务器
// 依赖:express, socket.io

const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: { origin: '*' }
});

io.on('connection', (socket) => {
  console.log('Client connected');
  
  socket.on('message', (data) => {
    io.emit('message', data);  // 广播
  });
  
  socket.on('disconnect', () => {
    console.log('Client disconnected');
  });
});

const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
bash 复制代码
# 3. 部署
railway login
railway init
railway up

Nginx 配置(反向代理)

如果你自己部署服务器,需要配置 Nginx 支持 WebSocket:

nginx 复制代码
# /etc/nginx/sites-available/websocket

upstream websocket_backend {
    # 使用 ip_hash 实现 sticky session
    ip_hash;
    
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
}

server {
    listen 80;
    server_name ws.example.com;
    
    location / {
        proxy_pass http://websocket_backend;
        
        # WebSocket 必需的 headers
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        
        # 其他 headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        
        # 超时设置
        proxy_connect_timeout 7d;
        proxy_send_timeout 7d;
        proxy_read_timeout 7d;
    }
}

关键点是 proxy_set_header Upgradeproxy_set_header Connection "upgrade",这告诉 Nginx 这是个 WebSocket 连接,需要特殊处理。

性能优化与监控

消息压缩

对于高频通信,消息压缩能显著减少流量。

javascript 复制代码
// 环境:浏览器
// 场景:使用 MessagePack 压缩消息
// 依赖:msgpack-lite

import msgpack from 'msgpack-lite';

class WebSocketManager {
  send(data) {
    // 压缩后发送
    const compressed = msgpack.encode(data);
    this.ws.send(compressed);
  }
  
  handleMessage(event) {
    // 解压
    const data = msgpack.decode(new Uint8Array(event.data));
    this.processMessage(data);
  }
}

// 对比:
// JSON: { "type": "message", "content": "Hello" }  // ~40 bytes
// MessagePack: 更小的二进制格式                    // ~20 bytes

批量发送

如果消息发送频率很高,可以考虑批量发送:

javascript 复制代码
// 环境:浏览器
// 场景:批量发送消息以减少网络开销

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.messageBuffer = [];
    this.flushInterval = null;
  }
  
  send(data) {
    this.messageBuffer.push(data);
    
    // 累积 100ms 或 10 条消息后批量发送
    if (!this.flushInterval) {
      this.flushInterval = setTimeout(() => {
        this.flush();
      }, 100);
    }
    
    if (this.messageBuffer.length >= 10) {
      this.flush();
    }
  }
  
  flush() {
    if (this.messageBuffer.length === 0) return;
    
    const batch = this.messageBuffer.splice(0);
    this.ws.send(JSON.stringify({ type: 'batch', messages: batch }));
    
    clearTimeout(this.flushInterval);
    this.flushInterval = null;
  }
}

监控

在生产环境中,监控 WebSocket 的运行状态很重要:

javascript 复制代码
// 环境:浏览器
// 场景:添加监控埋点

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.metrics = {
      messagesReceived: 0,
      messagesSent: 0,
      reconnectCount: 0,
      errors: 0,
      averageLatency: 0,
    };
  }
  
  send(data) {
    const startTime = Date.now();
    const message = {
      ...data,
      clientTimestamp: startTime,
    };
    
    this.ws.send(JSON.stringify(message));
    this.metrics.messagesSent++;
  }
  
  handleMessage(event) {
    this.metrics.messagesReceived++;
    
    const message = JSON.parse(event.data);
    
    // 计算延迟
    if (message.clientTimestamp) {
      const latency = Date.now() - message.clientTimestamp;
      this.metrics.averageLatency = 
        (this.metrics.averageLatency + latency) / 2;
    }
    
    // 上报监控数据
    if (this.metrics.messagesReceived % 100 === 0) {
      this.reportMetrics();
    }
  }
  
  reportMetrics() {
    console.log('WebSocket Metrics:', this.metrics);
    
    // 发送到监控平台(如 Sentry, DataDog)
    // analytics.track('websocket_metrics', this.metrics);
  }
}

延伸与发散

WebSocket vs SSE vs Long Polling

在研究 WebSocket 时,我发现还有其他实现实时通信的方案。它们各有优劣:

特性 WebSocket SSE Long Polling
方向 双向 单向(服务器→客户端) 单向
协议 独立协议 HTTP HTTP
浏览器支持 现代浏览器 现代浏览器(IE 不支持) 所有浏览器
自动重连 需手动实现 浏览器自动 需手动实现
适用场景 聊天、游戏 通知、推送 兼容性要求高

如果只需要服务器推送数据(比如通知),SSE 可能是更简单的选择。

AI 应用中的 WebSocket

最近在做 AI 相关的项目时,发现 WebSocket 很适合流式返回 AI 生成的内容:

javascript 复制代码
// 环境:React
// 场景:AI 流式生成内容
// 依赖:react

function AIChat() {
  const [messages, setMessages] = useState([]);
  const [currentResponse, setCurrentResponse] = useState('');
  const ws = useWS();
  
  useEffect(() => {
    const unsubscribe = ws.subscribe('ai_stream', (chunk) => {
      // AI 逐字返回
      setCurrentResponse(prev => prev + chunk.content);
    });
    
    const unsubscribeEnd = ws.subscribe('ai_stream_end', () => {
      // 流结束,保存完整消息
      setMessages(prev => [...prev, { role: 'ai', content: currentResponse }]);
      setCurrentResponse('');
    });
    
    return () => {
      unsubscribe();
      unsubscribeEnd();
    };
  }, [ws]);
  
  return (
    <div>
      {messages.map((msg, i) => <Message key={i} data={msg} />)}
      {currentResponse && <Message data={{ role: 'ai', content: currentResponse }} streaming />}
    </div>
  );
}

这种逐字显示的效果,用户体验比等待整段文本生成完再显示要好很多。

一些待探索的问题

在写这篇文章的过程中,我发现还有很多值得深入的方向:

  1. 端到端加密:如何在 WebSocket 中实现端到端加密?是在应用层做还是传输层做?
  2. 海量并发:如果要支持百万级并发连接,架构该如何设计?
  3. 微服务集成:在微服务架构中,WebSocket 该如何与服务发现、负载均衡集成?
  4. 边缘计算:Cloudflare Workers 这类边缘计算平台能否优化 WebSocket 延迟?

这些问题我还没有很深入的实践经验,欢迎有经验的朋友交流。

小结

从"连上 WebSocket"到真正在生产环境用好它,中间还有很长的路要走。这篇文章是我个人的学习总结,梳理了连接管理、消息管理、React 集成、部署方案等几个关键环节。

核心要点

  • WebSocket 的"后半场":断线重连、消息管理、状态同步
  • 与 React 的集成:自定义 Hook、Context 共享、React Query 协作
  • 实战场景:聊天、协作编辑、实时推送各有特点
  • 部署方案:根据规模选择合适的平台

我的理解是,WebSocket 本身的 API 很简单,但要在生产环境用好它,需要考虑很多工程问题。这些问题没有标准答案,需要根据具体场景权衡。

如果你也在使用 WebSocket,欢迎分享你的经验和踩过的坑。下一步我想探索的是认证状态的全局管理,特别是在多标签页同步和 SSO 场景下如何处理。

参考资料

相关推荐
清粥油条可乐炸鸡1 小时前
tailwind-merge的基本使用
前端
wuhen_n1 小时前
reactive 工具函数集
前端·javascript·vue.js
monkey011271 小时前
webSocket Demo1
网络·websocket·网络协议
wuhen_n1 小时前
effect的调度与清理:深入Vue3响应式系统的进阶特性
前端·javascript·vue.js
yinmaisoft1 小时前
开箱即用!国产化全兼容,信创生态适配 + 高效开发
前端·低代码·开发工具
wuhen_n1 小时前
响应式系统核心难题:数组与集合
前端·javascript·vue.js
We་ct2 小时前
LeetCode 199. 二叉树的右视图:层序遍历解题详解
前端·算法·leetcode·typescript·广度优先
晴殇i2 小时前
深入浅出 XSS:原理、危害与全方位防御指南
前端·面试