简单的 socket 实现

前言

在金融项目中一般需要用到 socket 推送,比如,行情,订单,k线 等。

如果项目需要与多个不同的 Socket 服务(不同的 URL/域名/端口)通信,通常必须创建多个独立的 WebSocket 实例/连接。

因为一个标准的 HTTP/WebSocket 协议连接是基于特定的 TCP 域名和端口 建立的,无法在一个原始的 WebSocket 管道里直接穿透去连另一个服务。

这种"一个项目搞多个 WebSocket 连接"的架构在业务发展到一定阶段非常常见,但如果缺乏统一管理,会引发一系列性能与架构问题。

在实际工程中,面对"多 URL / 多服务"需求,通常采用以下优化解法:

采用网关层聚合(BFF / API Gateway)------ 最推荐的架构解法

如果这些 Socket 服务都是你们公司内部的服务(比如:消息服务、行情服务、推送服务):

  • 做法 :在服务端前面挂一层 API 网关(Gateway)BFF (Backend For Frontend)
  • 效果 :前端只保持 1 个 WebSocket 连接 连到网关。前端发送的消息带着 channeltopic 字段,由网关在内部通过高性能 RPC/微服务转发给不同的后端服务。
  • 优势:前端极简,只有 1 个心跳和重连机制;消灭了前端多连接开销。

金融行业通常有以下业务场景:要在同一个 WebSocket 连接中兼顾"无 Symbol 的普通业务(如 chatorder)"和"有 Symbol 的标的业务(如 tickerkline)" ,最核心的思路是:设计一套统一的层级化通信信封(Envelope)与路由映射机制

在通用网关设计中,symbol 本质上只是 topic(主题)或 subChannel(子通道)的一个特例。只要把协议抽象为 "一级通道 (channel) + 二级主题 (topic / symbol,可选)" ,就能用同一套代码完美兼容所有场景。

统一通信协议(Standard Envelope Protocol)

无论什么业务,前端与网关之间收发的消息格式统一规整为如下格式:

ts 复制代码
// 1. 统一前端发给网关的控制/请求指令格式
interface WSRequest {
  action: 'SUBSCRIBE' | 'UNSUBSCRIBE' | 'SEND';
  channel: string;          // 一级通道,必填(例如: 'chat', 'order', 'ticker')
  topics?: string[];        // 二级主题/标的,可选(例如: ['BTC-USDT', 'ETH-USDT'])
  event?: string;           // 具体的行为/事件名,可选(例如: 'send_message')
  data?: any;               // 携带的数据
}

// 2. 统一网关推给前端的数据包格式
interface WSResponse<T = any> {
  channel: string;          // 一级通道,必填(例如: 'chat', 'order', 'ticker')
  topic?: string;           // 二级主题/标的,可选(例如: 'BTC-USDT',有就带,没有就不带)
  event?: string;           // 业务事件名,可选(例如: 'order_status_change')
  data: T;                  // 真正的业务数据
  timestamp: number;        // 时间戳
}

实现

这里将实现一版包含心跳保活、指数退避、断线重连、发送队列暂存、连接状态感知 等强健机制,网关聚合、多业务场景(无 Topic 业务如 chat / order + 有 Topic 业务如 ticker:BTC-USDT)、按需订阅与引用计数 ,构成一个企业级、全功能的 UnifiedWSClient

ts 复制代码
// UnifiedWSClient.ts

// 1. 连接状态枚举(供外部感知)
export enum WSStatus {
  CONNECTING = 'CONNECTING',
  CONNECTED = 'CONNECTED',
  DISCONNECTED = 'DISCONNECTED',
  RECONNECTING = 'RECONNECTING',
  CLOSED = 'CLOSED', // 主动调用 close() 后的终态,不再自动重连
}

// 2. 网关标准通信协议(信封结构 Envelope)
export interface WSRequest {
  action: 'SUBSCRIBE' | 'UNSUBSCRIBE' | 'SEND' | 'PING';
  channel: string;          // 一级通道 (如 'chat', 'order', 'ticker')
  topics?: string[];        // 二级主题/标的,可选 (如 ['BTC-USDT', 'ETH-USDT'])
  event?: string;           // 业务行为/事件,可选 (如 'send_message')
  data?: any;               // 真正的业务数据
}

export interface WSResponse<T = any> {
  channel: string;          // 一级通道
  topic?: string;           // 二级主题/标的 (如果有)
  event?: string;           // 业务事件
  data: T;                  // 响应数据
  timestamp?: number;
}

// 3. 配置参数接口
export interface WSClientOptions {
  url: string | (() => string); // 支持动态 URL (解决 Token 刷新的问题)
  protocols?: string | string[];
  pingInterval?: number;        // 心跳发送间隔 (默认 15000ms)
  pongTimeout?: number;         // 心跳超时判定时间 (默认 5000ms)
  maxReconnectAttempts?: number;// 最大重连次数 (默认 10 次,-1 为无限重连)
  reconnectBaseDelay?: number;  // 重连基础延迟 (默认 1000ms)
  maxReconnectDelay?: number;   // 重连最大延迟 (默认 30000ms)
  autoConnect?: boolean;        // 是否自动连接 (默认 true)
}

export type MessageCallback = (data: any, rawMessage: WSResponse) => void;
export type EventListener = (...args: any[]) => void;

export class UnifiedWSClient {
  private urlGetter: () => string;
  private protocols?: string | string[];
  private options: Required<Omit<WSClientOptions, 'url' | 'protocols'>>;

  private ws: WebSocket | null = null;
  private status: WSStatus = WSStatus.DISCONNECTED;

  // 定时器句柄
  private pingTimer: any = null;
  private pongTimer: any = null;
  private reconnectTimer: any = null;

  // 重连控制
  private reconnectAttempts = 0;
  private isExplicitlyClosed = false;

  // 消息发送队列(连接断开或未准备好时暂存)
  private messageQueue: Array<string | ArrayBufferLike | Blob | ArrayBufferView> = [];

  // 通用事件发布订阅 (内部状态变化通知: open, close, error, statusChange)
  private eventListeners: Map<string, Set<EventListener>> = new Map();

  // 业务订阅树: RouteKey ("channel" 或 "channel:topic") -> Set<MessageCallback>
  private subscribers: Map<string, Set<MessageCallback>> = new Map();

  constructor(options: WSClientOptions) {
    this.urlGetter = typeof options.url === 'function' ? options.url : () => options.url;
    this.protocols = options.protocols;

    this.options = {
      pingInterval: options.pingInterval ?? 15000,
      pongTimeout: options.pongTimeout ?? 5000,
      maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
      reconnectBaseDelay: options.reconnectBaseDelay ?? 1000,
      maxReconnectDelay: options.maxReconnectDelay ?? 30000,
      autoConnect: options.autoConnect ?? true,
    };

    if (this.options.autoConnect) {
      this.connect();
    }
  }

  // ==================== 1. 核心连接控制与状态感知 ====================

  /**
   * 建立底层物理连接
   */
  public connect() {
    if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) {
      return;
    }

    this.isExplicitlyClosed = false;
    this.setStatus(this.reconnectAttempts > 0 ? WSStatus.RECONNECTING : WSStatus.CONNECTING);

    try {
      const url = this.urlGetter();
      this.ws = new WebSocket(url, this.protocols);
      this.initEvents();
    } catch (error) {
      this.emit('error', error);
      this.handleReconnect();
    }
  }

  /**
   * 外部判断连接是否正常
   */
  public isNormal(): boolean {
    return this.status === WSStatus.CONNECTED && this.ws?.readyState === WebSocket.OPEN;
  }

  /**
   * 获取当前详细状态
   */
  public getStatus(): WSStatus {
    return this.status;
  }

  /**
   * 主动关闭连接(不触发自动重连)
   */
  public close(code: number = 1000, reason?: string) {
    this.isExplicitlyClosed = true;
    this.clearAllTimers();
    this.messageQueue = [];

    if (this.ws) {
      this.ws.close(code, reason);
      this.ws = null;
    }
    this.setStatus(WSStatus.CLOSED);
  }

  /**
   * 监听底层状态变更事件
   */
  public on(event: 'open' | 'close' | 'error' | 'statusChange', listener: EventListener) {
    if (!this.eventListeners.has(event)) {
      this.eventListeners.set(event, new Set());
    }
    this.eventListeners.get(event)!.add(listener);
  }

  public off(event: string, listener: EventListener) {
    this.eventListeners.get(event)?.delete(listener);
  }

  // ==================== 2. 统一业务订阅与发送 API ====================

  /**
   * 融合订阅 API(支持单/多 Topic,支持分批订阅、引用计数)
   * 示例 1: ws.subscribe('chat', callback)
   * 示例 2: ws.subscribe('ticker', 'BTC-USDT', callback)
   * 示例 3: ws.subscribe('ticker', ['BTC-USDT', 'ETH-USDT'], callback)
   */
  public subscribe(channel: string, callback: MessageCallback): () => void;
  public subscribe(channel: string, topics: string | string[], callback: MessageCallback): () => void;
  public subscribe(channel: string, arg2: any, arg3?: any): () => void {
    let topics: string[] = [];
    let callback: MessageCallback;

    // 参数多态重载解析
    if (typeof arg2 === 'function') {
      callback = arg2;
    } else {
      topics = Array.isArray(arg2) ? arg2 : [arg2];
      callback = arg3;
    }

    // 1. 无 Topic 的场景 (如 chat, order)
    if (topics.length === 0) {
      const isFirst = this.addSubscription(channel, callback);
      if (isFirst) {
        this.sendGatewayAction('SUBSCRIBE', channel);
      }
    } 
    // 2. 有 Topic 的场景 (如 ticker: BTC-USDT, ETH-USDT)
    else {
      const newTopicsToSubscribe: string[] = [];
      topics.forEach((topic) => {
        const routeKey = `${channel}:${topic}`;
        const isFirst = this.addSubscription(routeKey, callback);
        if (isFirst) {
          newTopicsToSubscribe.push(topic);
        }
      });

      if (newTopicsToSubscribe.length > 0) {
        this.sendGatewayAction('SUBSCRIBE', channel, newTopicsToSubscribe);
      }
    }

    // 返回取消订阅的函数,便于组件卸载时直接调用
    return () => this.unsubscribe(channel, arg2, arg3);
  }

  /**
   * 取消订阅 API
   */
  public unsubscribe(channel: string, arg2: any, arg3?: any) {
    let topics: string[] = [];
    let callback: MessageCallback;

    if (typeof arg2 === 'function') {
      callback = arg2;
    } else {
      topics = Array.isArray(arg2) ? arg2 : [arg2];
      callback = arg3;
    }

    if (topics.length === 0) {
      const hasRemaining = this.removeSubscription(channel, callback);
      if (!hasRemaining) {
        this.sendGatewayAction('UNSUBSCRIBE', channel);
      }
    } else {
      const topicsToUnsubscribe: string[] = [];
      topics.forEach((topic) => {
        const routeKey = `${channel}:${topic}`;
        const hasRemaining = this.removeSubscription(routeKey, callback);
        if (!hasRemaining) {
          topicsToUnsubscribe.push(topic);
        }
      });

      if (topicsToUnsubscribe.length > 0) {
        this.sendGatewayAction('UNSUBSCRIBE', channel, topicsToUnsubscribe);
      }
    }
  }

  /**
   * 业务消息发送(若连接未准备好,会自动缓存进入发送队列)
   */
  public send(channel: string, data: any, event?: string, topic?: string) {
    const payload: WSRequest = {
      action: 'SEND',
      channel,
      event,
      data,
      topics: topic ? [topic] : undefined,
    };

    this.sendRaw(JSON.stringify(payload));
  }

  // ==================== 3. 内部事件绑定与分发 ====================

  private initEvents() {
    if (!this.ws) return;

    this.ws.onopen = (event) => {
      this.reconnectAttempts = 0;
      this.setStatus(WSStatus.CONNECTED);
      
      this.startHeartbeat();     // 启动心跳
      this.flushMessageQueue();  // 补发缓存消息
      this.resubscribeAll();     // 重新挂载全量业务订阅

      this.emit('open', event);
    };

    this.ws.onmessage = (event) => {
      // 收到任何服务端数据,重置心跳倒计时
      this.resetHeartbeatTimeout();

      try {
        const message: WSResponse = JSON.parse(event.data);

        // A. 心跳 Pong 回应检查
        if (message.channel === 'system' && message.event === 'pong') {
          return;
        }

        // B. 业务消息路由分发
        const { channel, topic } = message;
        const routeKey = topic ? `${channel}:${topic}` : channel;

        const callbacks = this.subscribers.get(routeKey);
        if (callbacks && callbacks.size > 0) {
          callbacks.forEach((cb) => cb(message.data, message));
        }
      } catch (err) {
        console.error('[UnifiedWS] 消息解析或路由异常:', err);
      }
    };

    this.ws.onerror = (event) => {
      this.emit('error', event);
    };

    this.ws.onclose = (event) => {
      this.clearAllTimers();
      this.ws = null;
      this.emit('close', event);

      // 非主动关闭则启动自动重连机制
      if (!this.isExplicitlyClosed) {
        this.setStatus(WSStatus.DISCONNECTED);
        this.handleReconnect();
      }
    };
  }

  // ==================== 4. 心跳保活机制 ====================

  private startHeartbeat() {
    this.stopHeartbeat();

    this.pingTimer = setInterval(() => {
      if (this.isNormal()) {
        // 向网关发送系统级心跳
        this.sendRaw(JSON.stringify({ action: 'PING', channel: 'system', event: 'ping' }));

        // 启动 Pong 超时限制
        this.pongTimer = setTimeout(() => {
          console.warn('[UnifiedWS] 心跳响应 Timeout,判定连接断开,触发重新连接');
          this.ws?.close(); // 触发 onclose 进入重连流程
        }, this.options.pongTimeout);
      }
    }, this.options.pingInterval);
  }

  private resetHeartbeatTimeout() {
    if (this.pongTimer) {
      clearTimeout(this.pongTimer);
      this.pongTimer = null;
    }
  }

  private stopHeartbeat() {
    if (this.pingTimer) clearInterval(this.pingTimer);
    if (this.pongTimer) clearTimeout(this.pongTimer);
    this.pingTimer = null;
    this.pongTimer = null;
  }

  // ==================== 5. 断线重连(指数退避算法) ====================

  private handleReconnect() {
    if (this.isExplicitlyClosed) return;

    const { maxReconnectAttempts, reconnectBaseDelay, maxReconnectDelay } = this.options;

    if (maxReconnectAttempts !== -1 && this.reconnectAttempts >= maxReconnectAttempts) {
      console.error(`[UnifiedWS] 已达最大重连次数 (${maxReconnectAttempts}),停止自动重连`);
      this.setStatus(WSStatus.DISCONNECTED);
      return;
    }

    this.reconnectAttempts++;

    // 指数退避算法: 1s, 2s, 4s, 8s ... Max 30s
    const delay = Math.min(
      reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1),
      maxReconnectDelay
    );

    console.log(`[UnifiedWS] 将在 ${delay}ms 后进行第 ${this.reconnectAttempts} 次重连...`);

    this.reconnectTimer = setTimeout(() => {
      this.connect();
    }, delay);
  }

  // 连上后,重新向服务端恢复原有的全量订阅通道
  private resubscribeAll() {
    const channelTopicMap = new Map<string, string[]>();

    this.subscribers.forEach((_, routeKey) => {
      const [channel, topic] = routeKey.split(':');
      if (!channelTopicMap.has(channel)) {
        channelTopicMap.set(channel, []);
      }
      if (topic) {
        channelTopicMap.get(channel)!.push(topic);
      }
    });

    channelTopicMap.forEach((topics, channel) => {
      this.sendGatewayAction('SUBSCRIBE', channel, topics.length > 0 ? topics : undefined);
    });
  }

  // ==================== 6. 基础设施与辅助方法 ====================

  private addSubscription(routeKey: string, cb: MessageCallback): boolean {
    if (!this.subscribers.has(routeKey)) {
      this.subscribers.set(routeKey, new Set());
    }
    const set = this.subscribers.get(routeKey)!;
    const isFirst = set.size === 0;
    set.add(cb);
    return isFirst;
  }

  private removeSubscription(routeKey: string, cb: MessageCallback): boolean {
    const set = this.subscribers.get(routeKey);
    if (set) {
      set.delete(cb);
      if (set.size === 0) {
        this.subscribers.delete(routeKey);
        return false;
      }
      return true;
    }
    return false;
  }

  private sendGatewayAction(action: 'SUBSCRIBE' | 'UNSUBSCRIBE', channel: string, topics?: string[]) {
    const payload: WSRequest = {
      action,
      channel,
      topics: topics && topics.length > 0 ? topics : undefined,
    };
    this.sendRaw(JSON.stringify(payload));
  }

  private sendRaw(data: string) {
    if (this.isNormal()) {
      this.ws?.send(data);
    } else {
      // 未连上时存入队列
      this.messageQueue.push(data);
    }
  }

  private flushMessageQueue() {
    while (this.messageQueue.length > 0 && this.isNormal()) {
      const msg = this.messageQueue.shift();
      if (msg) this.ws?.send(msg);
    }
  }

  private setStatus(nextStatus: WSStatus) {
    if (this.status !== nextStatus) {
      const prevStatus = this.status;
      this.status = nextStatus;
      this.emit('statusChange', { status: nextStatus, prevStatus });
    }
  }

  private clearAllTimers() {
    this.stopHeartbeat();
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
    this.reconnectTimer = null;
  }

  private emit(event: string, ...args: any[]) {
    this.eventListeners.get(event)?.forEach((listener) => {
      try {
        listener(...args);
      } catch (err) {
        console.error(`[UnifiedWS] Event ${event} listener error:`, err);
      }
    });
  }
}

Demo

ts 复制代码
import { UnifiedWSClient, WSStatus } from './UnifiedWSClient';

// 1. 初始化客户端单例(全局维护 1 条 WebSocket 连接)
export const wsClient = new UnifiedWSClient({
  // 支持函数动态返回 Token
  url: () => `wss://gateway.yourdomain.com/ws?token=${localStorage.getItem('token')}`,
  pingInterval: 10000,     // 10秒发一次心跳
  pongTimeout: 3000,       // 3秒无响应认定断线
  maxReconnectAttempts: 10 // 重连最多10次
});

// 2. 全局状态感知(例如显示在 Navbar 的网络状态点)
wsClient.on('statusChange', ({ status }) => {
  if (status === WSStatus.CONNECTED) {
    console.log('💚 WebSocket 已就绪,通信正常');
  } else if (status === WSStatus.RECONNECTING) {
    console.warn('💛 正在尝试自动重连网络...');
  } else if (status === WSStatus.DISCONNECTED) {
    console.error('🔴 连接断开');
  }
});

// 随时查询连接是否正常
if (wsClient.isNormal()) {
  console.log('网络正常');
}

// ----------------------------------------------------
// 3. 业务组件 A:普通聊天功能 (无 Topic)
const unsubChat = wsClient.subscribe('chat', (data, rawMsg) => {
  console.log('收到新聊天消息:', data, '事件:', rawMsg.event);
});

// 发送聊天消息(即使在重连阶段调用,也会排队待连上后补发)
wsClient.send('chat', { text: 'Hello Gateway!' }, 'send_message');


// ----------------------------------------------------
// 4. 业务组件 B:行情数据看板 (有 Topic)
// 分批订阅 BTC 和 ETH
const unsubTicker = wsClient.subscribe('ticker', ['BTC-USDT', 'ETH-USDT'], (data, rawMsg) => {
  console.log(`收到 ${rawMsg.topic} 最新价格:`, data.price);
});

// 动态追加订阅 SOL
const unsubSol = wsClient.subscribe('ticker', 'SOL-USDT', (data) => {
  console.log('收到 SOL 最新价格:', data.price);
});

// 订阅 K线:监听 SOL 的 1分钟 K线 
const unsubKline = wsClient.subscribe('kline', 'SOL-USDT:1m', (data, rawMsg) => {
  console.log(`收到 ${rawMsg.topic} K线:`, data); 
});


// ----------------------------------------------------
// 5. 页面销毁 / 组件卸载
// 调用各自返回的 unsub 函数,引用计数降为 0 时 SDK 才会向网关发退订请求
unsubChat();
unsubTicker();
unsubSol();
unsubKline();

// 用户登出或关闭应用时:主动关闭,释放所有定时器且不触发重连
// wsClient.close();

总结

以上就是一个简版的 socket 实现,可以根据公司具体的业务需求场景进行改造

相关推荐
盏灯1 小时前
nvm 无法拉取最新办法解决办法
前端
WeilinerL2 小时前
CodeGraph:让AI理解代码仓库的神器
前端·人工智能
小四的小六2 小时前
WebView到AI产品,最值得掌握的3个核心能力——从自己产品里总结的能力地图
前端·ai编程·webview
前端逗比逗2 小时前
Electron 全维度完整配置手册(最新稳定版,适配 Electron 25+)
前端·electron
xiaobaoyu2 小时前
聊天问答文字逐步显示实现
前端
随风一样自由2 小时前
【前端+项目分析】`img` vs `Image`:从两个真实项目看前端图片组件的正确选择
前端·image·img·项目对比分析
倾颜2 小时前
断线之后,不要重跑 AI:在 POST + NDJSON 中实现可恢复 Agent 流
前端·后端·agent
程序员黑豆3 小时前
鸿蒙应用开发之父子组件传参:@Prop 装饰器使用教程
前端·后端·harmonyos