每天一个高级前端知识 - Day 20

0 阅读3分钟

每天一个高级前端知识 - Day 20

今日主题:WebSocket 高级应用 - 实时协作、游戏同步与大规模消息推送

核心概念:WebSocket不仅是通信协议,更是实时应用的基石

从简单的聊天室到复杂的多人游戏,WebSocket让真正的实时交互成为可能。

🎮 WebSocket 架构模式

┌─────────────────────────────────────────────────────────┐
│                      客户端架构                          │
├─────────────┬─────────────┬─────────────┬───────────────┤
│  连接管理   │  心跳保活   │  消息队列   │  断线重连     │
├─────────────┴─────────────┴─────────────┴───────────────┤
│                   业务逻辑层                             │
│  游戏同步  │  协作编辑  │  聊天室  │  实时看板  │  推送    │
└─────────────────────────────────────────────────────────┘
                              ↕ WebSocket/WSS
┌─────────────────────────────────────────────────────────┐
│                      服务端架构                          │
├─────────────┬─────────────┬─────────────┬───────────────┤
│  连接管理   │  房间管理   │  消息路由   │  负载均衡     │
├─────────────┴─────────────┴─────────────┴───────────────┤
│                   业务逻辑层                             │
│  状态同步  │  冲突解决  │  广播  │  持久化  │  认证授权   │
└─────────────────────────────────────────────────────────┘

🔌 WebSocket 高级客户端封装

// ============ WebSocket 高级客户端 ============
class AdvancedWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.options = {
      reconnect: true,
      reconnectInterval: 1000,
      maxReconnectAttempts: 10,
      heartbeatInterval: 30000,
      heartbeatMessage: 'ping',
      timeout: 5000,
      ...options
    };
    
    this.ws = null;
    this.isConnected = false;
    this.reconnectAttempts = 0;
    this.messageQueue = [];
    this.handlers = new Map();
    this.messageId = 0;
    this.pendingRequests = new Map();
    this.heartbeatTimer = null;
    this.reconnectTimer = null;
    
    this.init();
  }
  
  init() {
    this.connect();
  }
  
  connect() {
    try {
      this.ws = new WebSocket(this.url);
      this.setupEventHandlers();
    } catch (error) {
      console.error('WebSocket 连接失败:', error);
      this.handleReconnect();
    }
  }
  
  setupEventHandlers() {
    this.ws.onopen = () => {
      console.log('WebSocket 已连接');
      this.isConnected = true;
      this.reconnectAttempts = 0;
      this.startHeartbeat();
      this.flushMessageQueue();
      this.emit('connect');
    };
    
    this.ws.onmessage = (event) => {
      this.handleMessage(event.data);
    };
    
    this.ws.onclose = (event) => {
      console.log('WebSocket 已断开:', event.code, event.reason);
      this.isConnected = false;
      this.stopHeartbeat();
      this.emit('disconnect', { code: event.code, reason: event.reason });
      this.handleReconnect();
    };
    
    this.ws.onerror = (error) => {
      console.error('WebSocket 错误:', error);
      this.emit('error', error);
    };
  }
  
  handleMessage(data) {
    try {
      const message = JSON.parse(data);
      
      // 处理心跳响应
      if (message.type === 'pong') {
        return;
      }
      
      // 处理请求响应
      if (message.id && this.pendingRequests.has(message.id)) {
        const { resolve, reject } = this.pendingRequests.get(message.id);
        this.pendingRequests.delete(message.id);
        
        if (message.error) {
          reject(new Error(message.error));
        } else {
          resolve(message.data);
        }
        return;
      }
      
      // 触发事件处理器
      const handlers = this.handlers.get(message.type) || [];
      handlers.forEach(handler => handler(message.data, message));
      
    } catch (error) {
      console.error('消息解析失败:', error);
    }
  }
  
  // 发送消息(支持请求-响应模式)
  send(type, data, options = {}) {
    const message = {
      type,
      data,
      timestamp: Date.now()
    };
    
    // 请求-响应模式
    if (options.expectResponse) {
      return new Promise((resolve, reject) => {
        const id = ++this.messageId;
        message.id = id;
        
        this.pendingRequests.set(id, { resolve, reject });
        
        // 超时处理
        const timeout = setTimeout(() => {
          if (this.pendingRequests.has(id)) {
            this.pendingRequests.delete(id);
            reject(new Error('请求超时'));
          }
        }, options.timeout || 5000);
        
        this.pendingRequests.get(id).timeout = timeout;
        this.doSend(message);
      });
    }
    
    this.doSend(message);
  }
  
  doSend(message) {
    if (this.isConnected && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    } else {
      this.messageQueue.push(message);
    }
  }
  
  flushMessageQueue() {
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift();
      this.doSend(message);
    }
  }
  
  // 心跳保活
  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.isConnected) {
        this.send('ping', null, { expectResponse: true })
          .catch(() => {
            console.warn('心跳失败,尝试重连');
            this.reconnect();
          });
      }
    }, this.options.heartbeatInterval);
  }
  
  stopHeartbeat() {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }
  
  // 断线重连
  handleReconnect() {
    if (!this.options.reconnect) return;
    
    if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
      console.error('达到最大重连次数,停止重连');
      this.emit('reconnect_failed');
      return;
    }
    
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
    
    const delay = this.options.reconnectInterval * Math.pow(1.5, this.reconnectAttempts);
    console.log(`${delay}ms 后进行第 ${this.reconnectAttempts + 1} 次重连`);
    
    this.reconnectTimer = setTimeout(() => {
      this.reconnectAttempts++;
      this.connect();
    }, delay);
  }
  
  reconnect() {
    if (this.ws) {
      this.ws.close();
    }
    this.connect();
  }
  
  // 事件订阅
  on(type, handler) {
    if (!this.handlers.has(type)) {
      this.handlers.set(type, []);
    }
    this.handlers.get(type).push(handler);
  }
  
  off(type, handler) {
    if (!this.handlers.has(type)) return;
    
    const handlers = this.handlers.get(type);
    const index = handlers.indexOf(handler);
    if (index !== -1) handlers.splice(index, 1);
  }
  
  emit(type, data) {
    const handlers = this.handlers.get(type) || [];
    handlers.forEach(handler => handler(data));
  }
  
  // 连接状态
  getState() {
    if (!this.ws) return 'closed';
    switch (this.ws.readyState) {
      case WebSocket.CONNECTING: return 'connecting';
      case WebSocket.OPEN: return 'open';
      case WebSocket.CLOSING: return 'closing';
      case WebSocket.CLOSED: return 'closed';
      default: return 'unknown';
    }
  }
  
  // 关闭连接
  close() {
    this.options.reconnect = false;
    this.stopHeartbeat();
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
    if (this.ws) this.ws.close();
  }
}

🎮 实时游戏同步(帧同步)

// ============ 帧同步游戏客户端 ============
class FrameSyncGame {
  constructor(roomId, playerId) {
    this.roomId = roomId;
    this.playerId = playerId;
    this.ws = null;
    
    this.frameRate = 60; // 60帧
    this.frameInterval = 1000 / this.frameRate;
    this.currentFrame = 0;
    this.inputQueue = [];
    this.frameBuffer = new Map(); // 缓存帧数据
    this.localInputs = new Map(); // 本地输入历史
    
    this.gameState = null;
    this.players = new Map();
    this.isRunning = false;
    this.isLockstep = true; // 锁步模式
    
    this.init();
  }
  
  init() {
    this.ws = new AdvancedWebSocket(`wss://game.example.com/ws/${this.roomId}`, {
      heartbeatInterval: 15000
    });
    
    this.setupHandlers();
  }
  
  setupHandlers() {
    this.ws.on('connect', () => {
      console.log('游戏服务器已连接');
      this.joinRoom();
    });
    
    this.ws.on('frame_data', (data) => {
      this.handleFrameData(data);
    });
    
    this.ws.on('game_state', (state) => {
      this.gameState = state;
    });
    
    this.ws.on('player_joined', (player) => {
      this.players.set(player.id, player);
      this.onPlayerJoined?.(player);
    });
    
    this.ws.on('player_left', (playerId) => {
      this.players.delete(playerId);
      this.onPlayerLeft?.(playerId);
    });
  }
  
  joinRoom() {
    this.ws.send('join_game', {
      playerId: this.playerId,
      roomId: this.roomId
    });
  }
  
  // 发送本地输入
  sendInput(input) {
    const frameInput = {
      frame: this.currentFrame,
      playerId: this.playerId,
      input,
      timestamp: Date.now()
    };
    
    this.localInputs.set(this.currentFrame, input);
    this.ws.send('player_input', frameInput);
  }
  
  // 处理服务器下发的帧数据
  handleFrameData(data) {
    const { frame, inputs } = data;
    
    // 缓存帧数据
    this.frameBuffer.set(frame, inputs);
    
    // 如果当前帧的输入已就绪,可以提前计算
    if (frame === this.currentFrame + 1 && this.isAllInputsReady(frame)) {
      this.executeFrame(frame);
    }
  }
  
  // 检查所有玩家的输入是否都已收到
  isAllInputsReady(frame) {
    const frameInputs = this.frameBuffer.get(frame);
    if (!frameInputs) return false;
    
    const expectedPlayers = Array.from(this.players.keys());
    const receivedPlayers = Object.keys(frameInputs);
    
    return expectedPlayers.every(p => receivedPlayers.includes(p));
  }
  
  // 执行帧逻辑
  executeFrame(frame) {
    const frameInputs = this.frameBuffer.get(frame);
    if (!frameInputs) return;
    
    // 根据所有玩家的输入更新游戏状态
    this.gameState = this.updateGameState(this.gameState, frameInputs);
    
    // 渲染
    this.render();
    
    // 清理已执行的帧
    this.frameBuffer.delete(frame);
    this.currentFrame = frame;
    
    // 预判执行下一帧
    const nextFrame = frame + 1;
    if (this.frameBuffer.has(nextFrame) && this.isAllInputsReady(nextFrame)) {
      requestAnimationFrame(() => this.executeFrame(nextFrame));
    }
  }
  
  updateGameState(state, inputs) {
    // 深度克隆状态
    const newState = JSON.parse(JSON.stringify(state));
    
    // 应用每个玩家的输入
    for (const [playerId, input] of Object.entries(inputs)) {
      const player = newState.players.find(p => p.id === playerId);
      if (player) {
        // 移动逻辑
        player.x += input.dx * player.speed;
        player.y += input.dy * player.speed;
        
        // 边界检测
        player.x = Math.max(0, Math.min(1000, player.x));
        player.y = Math.max(0, Math.min(600, player.y));
        
        // 攻击逻辑
        if (input.attack) {
          this.handleAttack(newState, player);
        }
      }
    }
    
    // 碰撞检测
    this.handleCollisions(newState);
    
    return newState;
  }
  
  handleAttack(state, attacker) {
    // 检测攻击范围内的其他玩家
    for (const player of state.players) {
      if (player.id !== attacker.id) {
        const dx = player.x - attacker.x;
        const dy = player.y - attacker.y;
        const distance = Math.sqrt(dx * dx + dy * dy);
        
        if (distance < attacker.attackRange) {
          player.health -= attacker.damage;
          
          if (player.health <= 0) {
            // 玩家死亡逻辑
            this.onPlayerDeath?.(player.id, attacker.id);
          }
        }
      }
    }
  }
  
  handleCollisions(state) {
    // 简单的碰撞检测
    for (let i = 0; i < state.players.length; i++) {
      for (let j = i + 1; j < state.players.length; j++) {
        const p1 = state.players[i];
        const p2 = state.players[j];
        
        const dx = p1.x - p2.x;
        const dy = p1.y - p2.y;
        const distance = Math.sqrt(dx * dx + dy * dy);
        const minDistance = p1.radius + p2.radius;
        
        if (distance < minDistance) {
          // 推开
          const angle = Math.atan2(dy, dx);
          const overlap = minDistance - distance;
          const moveX = Math.cos(angle) * overlap / 2;
          const moveY = Math.sin(angle) * overlap / 2;
          
          p1.x += moveX;
          p1.y += moveY;
          p2.x -= moveX;
          p2.y -= moveY;
        }
      }
    }
  }
  
  render() {
    // Canvas 渲染逻辑
    if (!this.canvas) return;
    
    const ctx = this.canvas.getContext('2d');
    ctx.clearRect(0, 0, 1000, 600);
    
    // 渲染所有玩家
    for (const player of this.gameState.players) {
      ctx.fillStyle = player.id === this.playerId ? '#00ff00' : '#ff0000';
      ctx.beginPath();
      ctx.arc(player.x, player.y, player.radius, 0, Math.PI * 2);
      ctx.fill();
      
      // 渲染血条
      ctx.fillStyle = '#ff0000';
      ctx.fillRect(player.x - 25, player.y - 20, 50, 5);
      ctx.fillStyle = '#00ff00';
      ctx.fillRect(player.x - 25, player.y - 20, 50 * (player.health / 100), 5);
    }
  }
  
  start() {
    this.isRunning = true;
    this.gameLoop();
  }
  
  gameLoop() {
    if (!this.isRunning) return;
    
    const now = Date.now();
    const elapsed = now - this.lastFrameTime;
    
    if (elapsed >= this.frameInterval) {
      this.lastFrameTime = now;
      
      // 如果当前帧的输入已就绪,执行
      if (this.isAllInputsReady(this.currentFrame + 1)) {
        this.executeFrame(this.currentFrame + 1);
      } else {
        // 等待更多输入,或进行预测
        this.predictAndRender();
      }
    }
    
    requestAnimationFrame(() => this.gameLoop());
  }
  
  predictAndRender() {
    // 简单预测:基于上一帧的状态和本地输入
    const lastInput = this.localInputs.get(this.currentFrame);
    if (lastInput && this.gameState) {
      const predictedState = this.updateGameState(
        JSON.parse(JSON.stringify(this.gameState)),
        { [this.playerId]: lastInput }
      );
      this.renderPredicted(predictedState);
    }
  }
  
  onPlayerDeath(playerId, killerId) {
    console.log(`玩家 ${playerId}${killerId} 击杀`);
    // 更新分数,播放特效等
  }
}

✏️ 实时协作编辑(CRDT)

// ============ CRDT 实时协作编辑器 ============
class CollaborativeEditor {
  constructor(docId, userId, userName) {
    this.docId = docId;
    this.userId = userId;
    this.userName = userName;
    
    this.ws = null;
    this.characters = []; // 字符数组
    this.pendingOps = []; // 待同步的操作
    this.remoteOps = []; // 远程操作队列
    this.snapshot = null; // 文档快照
    
    this.selection = { start: 0, end: 0 };
    this.users = new Map(); // 在线用户及光标位置
    
    this.init();
  }
  
  async init() {
    this.ws = new AdvancedWebSocket(`wss://collab.example.com/ws/${this.docId}`);
    this.setupHandlers();
    await this.loadSnapshot();
  }
  
  setupHandlers() {
    this.ws.on('connect', () => {
      this.joinDocument();
    });
    
    this.ws.on('operation', (op) => {
      this.applyRemoteOperation(op);
    });
    
    this.ws.on('cursor', (cursorData) => {
      this.updateRemoteCursor(cursorData);
    });
    
    this.ws.on('user_joined', (user) => {
      this.users.set(user.id, user);
      this.onUserJoined?.(user);
    });
    
    this.ws.on('user_left', (userId) => {
      this.users.delete(userId);
      this.onUserLeft?.(userId);
    });
  }
  
  async loadSnapshot() {
    const response = await fetch(`/api/docs/${this.docId}/snapshot`);
    this.snapshot = await response.json();
    this.characters = this.snapshot.characters;
    this.render();
  }
  
  // 插入字符(带位置标识符)
  insert(position, text) {
    const op = {
      type: 'insert',
      position: this.generatePosition(position),
      text,
      userId: this.userId,
      timestamp: Date.now()
    };
    
    this.applyLocalOperation(op);
    this.sendOperation(op);
  }
  
  delete(position, length) {
    const op = {
      type: 'delete',
      position: this.characters[position].id,
      length,
      userId: this.userId,
      timestamp: Date.now()
    };
    
    this.applyLocalOperation(op);
    this.sendOperation(op);
  }
  
  // 生成位置标识符(用于 CRDT)
  generatePosition(index) {
    if (this.characters.length === 0) {
      return { base: 'root', offset: 0 };
    }
    
    if (index === 0) {
      return { base: this.characters[0].id, side: 'left' };
    }
    
    if (index >= this.characters.length) {
      return { base: this.characters[this.characters.length - 1].id, side: 'right' };
    }
    
    const left = this.characters[index - 1].id;
    const right = this.characters[index].id;
    
    return { left, right };
  }
  
  // 应用本地操作
  applyLocalOperation(op) {
    if (op.type === 'insert') {
      const char = {
        id: this.generateCharId(),
        value: op.text,
        userId: op.userId,
        timestamp: op.timestamp
      };
      
      const index = this.findPositionIndex(op.position);
      this.characters.splice(index, 0, char);
      
    } else if (op.type === 'delete') {
      const index = this.findCharIndex(op.position);
      if (index !== -1) {
        this.characters.splice(index, op.length);
      }
    }
    
    this.render();
    this.onDocumentChange?.(this.getText());
  }
  
  // 应用远程操作
  applyRemoteOperation(op) {
    // CRDT 冲突解决:基于时间戳和用户ID
    if (this.hasConflict(op)) {
      this.resolveConflict(op);
    }
    
    this.applyLocalOperation(op);
  }
  
  hasConflict(op) {
    // 检查是否有并发冲突
    // 简化实现:检查操作位置是否被其他操作影响
    return false;
  }
  
  resolveConflict(op) {
    // CRDT 冲突解决算法
    // 基于用户ID的时间戳排序
    console.log('解决冲突:', op);
  }
  
  // 发送光标位置
  updateCursor(position) {
    this.selection = position;
    this.ws.send('cursor', {
      userId: this.userId,
      userName: this.userName,
      position: this.selection,
      timestamp: Date.now()
    });
  }
  
  updateRemoteCursor(cursorData) {
    const user = this.users.get(cursorData.userId);
    if (user) {
      user.cursor = cursorData.position;
      user.lastActive = cursorData.timestamp;
      this.renderCursor(user);
    }
  }
  
  render() {
    const text = this.characters.map(c => c.value).join('');
    this.editorElement.value = text;
  }
  
  renderCursor(user) {
    // 在编辑器中渲染其他用户的光标
    const cursorElement = document.createElement('div');
    cursorElement.className = 'remote-cursor';
    cursorElement.style.left = `${this.getCursorX(user.cursor.start)}px`;
    cursorElement.style.top = `${this.getCursorY(user.cursor.start)}px`;
    cursorElement.textContent = user.userName;
    cursorElement.style.backgroundColor = user.color;
    
    this.cursorContainer.appendChild(cursorElement);
  }
  
  getText() {
    return this.characters.map(c => c.value).join('');
  }
  
  findPositionIndex(position) {
    if (position.base === 'root') return position.offset;
    
    for (let i = 0; i < this.characters.length; i++) {
      if (this.characters[i].id === position.base) {
        return position.side === 'left' ? i : i + 1;
      }
    }
    
    return this.characters.length;
  }
  
  findCharIndex(charId) {
    return this.characters.findIndex(c => c.id === charId);
  }
  
  generateCharId() {
    return `${this.userId}_${Date.now()}_${Math.random()}`;
  }
  
  joinDocument() {
    this.ws.send('join_doc', {
      userId: this.userId,
      userName: this.userName,
      snapshotVersion: this.snapshot.version
    });
  }
  
  sendOperation(op) {
    this.ws.send('operation', op);
  }
}

🎯 今日挑战

实现一个实时协作白板,要求:

  1. WebSocket 连接管理和断线重连
  2. 多人实时绘图同步
  3. 支持多种工具(画笔、矩形、圆形、文字)
  4. 实现撤销/重做功能(需同步到所有用户)
  5. 用户光标实时显示
  6. 房间管理和权限控制
// 使用示例
const whiteboard = new CollaborativeWhiteboard({
  roomId: 'room-123',
  userId: 'user-456',
  userName: 'Alice',
  tools: ['pen', 'rect', 'circle', 'text']
});

whiteboard.on('draw', (data) => {
  console.log('绘图数据:', data);
});

whiteboard.on('user_joined', (user) => {
  console.log(`${user.name} 加入了房间`);
});

whiteboard.start();

明日预告:跨端开发 - React Native 与 Flutter 的深度对比与实践

💡 实时应用箴言:"延迟是永恒的,用户体验是判断延迟是否可接受的唯一标准"——用预测和插值让用户感知不到延迟!