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

3 阅读5分钟

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

今日主题:WebRTC 深度实战 - 1对1视频通话与白板协作

核心概念:WebRTC让浏览器拥有实时通信能力

WebRTC(Web Real-Time Communication)支持直接在浏览器之间传输音视频和数据,无需中间服务器转发。

┌─────────┐         信令服务器         ┌─────────┐
│  Peer A │ ←─── WebSocket ───→ │  Peer B │
└─────────┘     (交换SDP/ICE)      └─────────┘
     ↓                                    ↓
   STUN服务器                          STUN服务器
   (获取公网IP)                         (获取公网IP)
     ↓                                    ↓
   TURN中继 ←─── P2P失败时中继 ───→ TURN中继    
   (备选通道)                          (备选通道)

🚀 完整的WebRTC视频通话实现

// ============ 1. 信令服务器端 (Node.js + Socket.io) ============
// server.js
const express = require('express');
const http = require('http');
const socketIO = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = socketIO(server, {
  cors: { origin: "*" }
});

// 房间管理
const rooms = new Map(); // roomId -> { users: Set, offer: null, answers: [] }

io.on('connection', (socket) => {
  console.log('用户连接:', socket.id);
  
  // 创建或加入房间
  socket.on('join-room', (roomId, userId) => {
    socket.join(roomId);
    
    if (!rooms.has(roomId)) {
      rooms.set(roomId, { users: new Set(), offer: null, answers: [] });
    }
    
    const room = rooms.get(roomId);
    room.users.add(userId);
    
    // 通知房间内其他用户
    socket.to(roomId).emit('user-joined', userId);
    
    // 如果是第二个用户且已有offer,发送offer
    if (room.offer && room.users.size > 1) {
      socket.emit('receive-offer', room.offer);
    }
    
    socket.emit('room-joined', { 
      roomId, 
      participants: Array.from(room.users) 
    });
  });
  
  // 处理Offer(发起呼叫)
  socket.on('send-offer', (roomId, offer) => {
    const room = rooms.get(roomId);
    if (room) {
      room.offer = offer;
      socket.to(roomId).emit('receive-offer', offer);
    }
  });
  
  // 处理Answer(应答)
  socket.on('send-answer', (roomId, answer) => {
    const room = rooms.get(roomId);
    if (room) {
      room.answers.push(answer);
      socket.to(roomId).emit('receive-answer', answer);
    }
  });
  
  // 处理ICE Candidate
  socket.on('send-ice', (roomId, iceCandidate) => {
    socket.to(roomId).emit('receive-ice', iceCandidate);
  });
  
  // 处理挂断
  socket.on('hangup', (roomId, userId) => {
    const room = rooms.get(roomId);
    if (room) {
      room.users.delete(userId);
      socket.to(roomId).emit('user-left', userId);
      
      if (room.users.size === 0) {
        rooms.delete(roomId);
      }
    }
    socket.leave(roomId);
  });
  
  socket.on('disconnect', () => {
    console.log('用户断开:', socket.id);
    // 清理用户所在的所有房间
    for (const [roomId, room] of rooms) {
      if (room.users.has(socket.id)) {
        room.users.delete(socket.id);
        socket.to(roomId).emit('user-left', socket.id);
      }
    }
  });
});

server.listen(3001, () => {
  console.log('信令服务器运行在 http://localhost:3001');
});
// ============ 2. 前端视频通话客户端 ============
// webrtc-client.js
class WebRTCVideoCall {
  constructor(config = {}) {
    this.config = {
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },  // Google STUN
        { urls: 'stun:stun1.l.google.com:19302' },
        {
          urls: 'turn:turn.anyfirewall.com:443?transport=tcp',
          username: 'webrtc',
          credential: 'webrtc'
        }
      ],
      iceCandidatePoolSize: 10,
      ...config
    };
    
    this.peerConnection = null;
    this.localStream = null;
    this.remoteStream = null;
    this.roomId = null;
    this.userId = null;
    this.socket = null;
    this.dataChannel = null;
    
    // 回调函数
    this.onLocalStream = null;
    this.onRemoteStream = null;
    this.onParticipantsChange = null;
    this.onDataChannelMessage = null;
    this.onError = null;
  }
  
  // 初始化信令连接
  async init(signalServerUrl, roomId, userId) {
    this.roomId = roomId;
    this.userId = userId;
    
    // 连接信令服务器
    this.socket = io(signalServerUrl, {
      transports: ['websocket'],
      upgrade: false
    });
    
    this.setupSignalHandlers();
    
    return new Promise((resolve) => {
      this.socket.emit('join-room', roomId, userId);
      this.socket.once('room-joined', (data) => {
        resolve(data);
      });
    });
  }
  
  setupSignalHandlers() {
    this.socket.on('user-joined', async (userId) => {
      console.log('用户加入:', userId);
      // 如果是第一个用户,创建Offer
      if (!this.peerConnection) {
        await this.createPeerConnection();
        await this.createOffer();
      }
      this.onParticipantsChange?.(userId, 'joined');
    });
    
    this.socket.on('receive-offer', async (offer) => {
      if (!this.peerConnection) {
        await this.createPeerConnection();
      }
      await this.handleOffer(offer);
    });
    
    this.socket.on('receive-answer', async (answer) => {
      await this.peerConnection.setRemoteDescription(answer);
    });
    
    this.socket.on('receive-ice', async (iceCandidate) => {
      try {
        await this.peerConnection.addIceCandidate(iceCandidate);
      } catch (e) {
        console.error('添加ICE Candidate失败:', e);
      }
    });
    
    this.socket.on('user-left', (userId) => {
      console.log('用户离开:', userId);
      if (this.peerConnection) {
        this.peerConnection.close();
        this.peerConnection = null;
      }
      this.onParticipantsChange?.(userId, 'left');
    });
  }
  
  // 获取用户媒体设备
  async getUserMedia(constraints = { video: true, audio: true }) {
    try {
      this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
      
      // 枚举设备
      const devices = await navigator.mediaDevices.enumerateDevices();
      console.log('可用设备:', devices);
      
      this.onLocalStream?.(this.localStream);
      return this.localStream;
    } catch (error) {
      console.error('获取媒体失败:', error);
      this.onError?.(error);
      throw error;
    }
  }
  
  // 创建PeerConnection
  async createPeerConnection() {
    this.peerConnection = new RTCPeerConnection(this.config);
    
    // 添加本地流
    if (this.localStream) {
      this.localStream.getTracks().forEach(track => {
        this.peerConnection.addTrack(track, this.localStream);
      });
    }
    
    // 监听ICE Candidate
    this.peerConnection.onicecandidate = (event) => {
      if (event.candidate) {
        this.socket.emit('send-ice', this.roomId, event.candidate);
      }
    };
    
    // 监听远程流
    this.peerConnection.ontrack = (event) => {
      if (!this.remoteStream) {
        this.remoteStream = new MediaStream();
      }
      this.remoteStream.addTrack(event.track);
      this.onRemoteStream?.(this.remoteStream);
    };
    
    // 监听连接状态
    this.peerConnection.onconnectionstatechange = () => {
      console.log('连接状态:', this.peerConnection.connectionState);
      if (this.peerConnection.connectionState === 'failed') {
        this.onError?.(new Error('连接失败'));
      }
    };
    
    // 创建数据通道(用于白板协作)
    this.dataChannel = this.peerConnection.createDataChannel('whiteboard', {
      ordered: true,      // 保证顺序
      maxRetransmits: 3   // 最多重传3次
    });
    
    this.setupDataChannel();
    
    return this.peerConnection;
  }
  
  setupDataChannel() {
    this.dataChannel.onopen = () => {
      console.log('数据通道已打开');
    };
    
    this.dataChannel.onclose = () => {
      console.log('数据通道已关闭');
    };
    
    this.dataChannel.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.onDataChannelMessage?.(data);
    };
    
    this.dataChannel.onerror = (error) => {
      console.error('数据通道错误:', error);
    };
  }
  
  // 发送数据通道消息
  sendDataChannelMessage(type, payload) {
    if (this.dataChannel?.readyState === 'open') {
      this.dataChannel.send(JSON.stringify({ type, payload, timestamp: Date.now() }));
    } else {
      console.warn('数据通道未就绪');
    }
  }
  
  // 创建Offer
  async createOffer() {
    const offer = await this.peerConnection.createOffer({
      offerToReceiveAudio: true,
      offerToReceiveVideo: true
    });
    await this.peerConnection.setLocalDescription(offer);
    this.socket.emit('send-offer', this.roomId, offer);
  }
  
  // 处理Offer
  async handleOffer(offer) {
    await this.peerConnection.setRemoteDescription(offer);
    const answer = await this.peerConnection.createAnswer();
    await this.peerConnection.setLocalDescription(answer);
    this.socket.emit('send-answer', this.roomId, answer);
  }
  
  // 挂断
  hangup() {
    if (this.peerConnection) {
      this.peerConnection.close();
      this.peerConnection = null;
    }
    
    if (this.localStream) {
      this.localStream.getTracks().forEach(track => track.stop());
      this.localStream = null;
    }
    
    this.remoteStream = null;
    this.socket?.emit('hangup', this.roomId, this.userId);
  }
  
  // 切换摄像头
  async switchCamera() {
    // 获取当前视频轨道
    const videoTrack = this.localStream?.getVideoTracks()[0];
    if (!videoTrack) return;
    
    // 获取新设备
    const devices = await navigator.mediaDevices.enumerateDevices();
    const cameras = devices.filter(device => device.kind === 'videoinput');
    const currentDeviceId = videoTrack.getSettings().deviceId;
    const currentIndex = cameras.findIndex(c => c.deviceId === currentDeviceId);
    const nextCamera = cameras[(currentIndex + 1) % cameras.length];
    
    if (nextCamera) {
      const newStream = await navigator.mediaDevices.getUserMedia({
        video: { deviceId: nextCamera.deviceId },
        audio: true
      });
      
      // 替换轨道
      const newVideoTrack = newStream.getVideoTracks()[0];
      const sender = this.peerConnection.getSenders().find(s => s.track?.kind === 'video');
      await sender.replaceTrack(newVideoTrack);
      
      // 更新本地流
      const oldVideoTrack = this.localStream.getVideoTracks()[0];
      this.localStream.removeTrack(oldVideoTrack);
      this.localStream.addTrack(newVideoTrack);
      oldVideoTrack.stop();
      
      this.onLocalStream?.(this.localStream);
    }
  }
  
  // 静音/取消静音
  toggleMute() {
    const audioTrack = this.localStream?.getAudioTracks()[0];
    if (audioTrack) {
      audioTrack.enabled = !audioTrack.enabled;
      return audioTrack.enabled;
    }
    return false;
  }
  
  // 开启/关闭视频
  toggleVideo() {
    const videoTrack = this.localStream?.getVideoTracks()[0];
    if (videoTrack) {
      videoTrack.enabled = !videoTrack.enabled;
      return videoTrack.enabled;
    }
    return false;
  }
  
  // 录制屏幕
  async startScreenShare() {
    try {
      const screenStream = await navigator.mediaDevices.getDisplayMedia({
        video: true,
        audio: false
      });
      
      // 替换视频轨道
      const screenTrack = screenStream.getVideoTracks()[0];
      const sender = this.peerConnection.getSenders().find(s => s.track?.kind === 'video');
      await sender.replaceTrack(screenTrack);
      
      // 监听屏幕共享结束
      screenTrack.onended = () => {
        this.stopScreenShare();
      };
      
      return true;
    } catch (error) {
      console.error('屏幕共享失败:', error);
      return false;
    }
  }
  
  async stopScreenShare() {
    // 切换回摄像头
    await this.switchCamera();
  }
}

🎨 白板协作实现

// whiteboard.js
class CollaborativeWhiteboard {
  constructor(canvas, webrtcClient) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.webrtcClient = webrtcClient;
    this.isDrawing = false;
    this.lastX = 0;
    this.lastY = 0;
    this.color = '#000000';
    this.lineWidth = 2;
    this.tool = 'pen'; // pen, eraser, rect, circle, line
    
    this.setupCanvasEvents();
    this.setupDataChannel();
    this.initCanvas();
  }
  
  setupCanvasEvents() {
    // 绘图事件
    this.canvas.addEventListener('mousedown', this.startDrawing.bind(this));
    this.canvas.addEventListener('mousemove', this.draw.bind(this));
    this.canvas.addEventListener('mouseup', this.stopDrawing.bind(this));
    this.canvas.addEventListener('mouseleave', this.stopDrawing.bind(this));
    
    // 触摸事件(移动端)
    this.canvas.addEventListener('touchstart', this.startDrawingTouch.bind(this));
    this.canvas.addEventListener('touchmove', this.drawTouch.bind(this));
    this.canvas.addEventListener('touchend', this.stopDrawing.bind(this));
    
    // 调整画布大小
    window.addEventListener('resize', this.resizeCanvas.bind(this));
    this.resizeCanvas();
  }
  
  setupDataChannel() {
    this.webrtcClient.onDataChannelMessage = (data) => {
      if (data.type === 'draw') {
        this.drawRemote(data.payload);
      } else if (data.type === 'clear') {
        this.clearCanvas();
      } else if (data.type === 'undo') {
        this.undo();
      }
    };
  }
  
  initCanvas() {
    this.ctx.lineCap = 'round';
    this.ctx.lineJoin = 'round';
    this.ctx.fillStyle = '#ffffff';
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
    
    // 保存历史记录(用于撤销)
    this.history = [];
    this.saveState();
  }
  
  resizeCanvas() {
    const rect = this.canvas.parentElement.getBoundingClientRect();
    const oldDrawing = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
    
    this.canvas.width = rect.width;
    this.canvas.height = rect.height;
    
    this.ctx.putImageData(oldDrawing, 0, 0);
    this.ctx.lineCap = 'round';
    this.ctx.lineJoin = 'round';
  }
  
  startDrawing(e) {
    this.isDrawing = true;
    const pos = this.getCanvasCoordinates(e);
    this.lastX = pos.x;
    this.lastY = pos.y;
    
    if (this.tool === 'pen' || this.tool === 'eraser') {
      this.ctx.beginPath();
      this.ctx.moveTo(this.lastX, this.lastY);
      
      // 发送绘图事件
      this.sendDrawEvent('begin', this.lastX, this.lastY, this.lastX, this.lastY);
    }
  }
  
  draw(e) {
    if (!this.isDrawing) return;
    
    const pos = this.getCanvasCoordinates(e);
    const currentX = pos.x;
    const currentY = pos.y;
    
    if (this.tool === 'pen') {
      this.ctx.globalCompositeOperation = 'source-over';
      this.ctx.strokeStyle = this.color;
      this.ctx.lineWidth = this.lineWidth;
      this.ctx.lineTo(currentX, currentY);
      this.ctx.stroke();
      this.ctx.beginPath();
      this.ctx.moveTo(currentX, currentY);
      
    } else if (this.tool === 'eraser') {
      this.ctx.globalCompositeOperation = 'destination-out';
      this.ctx.strokeStyle = 'rgba(0,0,0,1)';
      this.ctx.lineWidth = this.lineWidth * 2;
      this.ctx.lineTo(currentX, currentY);
      this.ctx.stroke();
      this.ctx.beginPath();
      this.ctx.moveTo(currentX, currentY);
    }
    
    // 发送绘图数据
    this.sendDrawEvent('draw', this.lastX, this.lastY, currentX, currentY);
    
    this.lastX = currentX;
    this.lastY = currentY;
  }
  
  drawRemote(payload) {
    if (payload.action === 'begin') {
      this.ctx.beginPath();
      this.ctx.moveTo(payload.x, payload.y);
    } else if (payload.action === 'draw') {
      this.ctx.globalCompositeOperation = 
        payload.tool === 'eraser' ? 'destination-out' : 'source-over';
      this.ctx.strokeStyle = payload.color;
      this.ctx.lineWidth = payload.lineWidth;
      this.ctx.lineTo(payload.x2, payload.y2);
      this.ctx.stroke();
      this.ctx.beginPath();
      this.ctx.moveTo(payload.x2, payload.y2);
    }
  }
  
  sendDrawEvent(action, x, y, x2, y2) {
    this.webrtcClient.sendDataChannelMessage('draw', {
      action,
      tool: this.tool,
      color: this.color,
      lineWidth: this.lineWidth,
      x, y, x2, y2
    });
  }
  
  stopDrawing() {
    this.isDrawing = false;
    this.ctx.beginPath();
    this.saveState();
  }
  
  startDrawingTouch(e) {
    e.preventDefault();
    const touch = e.touches[0];
    this.startDrawing(touch);
  }
  
  drawTouch(e) {
    e.preventDefault();
    const touch = e.touches[0];
    this.draw(touch);
  }
  
  clearCanvas() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.ctx.fillStyle = '#ffffff';
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
    this.webrtcClient.sendDataChannelMessage('clear', {});
    this.saveState();
  }
  
  saveState() {
    this.history.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
    if (this.history.length > 50) {
      this.history.shift();
    }
  }
  
  undo() {
    if (this.history.length > 1) {
      this.history.pop();
      const lastState = this.history[this.history.length - 1];
      this.ctx.putImageData(lastState, 0, 0);
      this.webrtcClient.sendDataChannelMessage('undo', {});
    }
  }
  
  getCanvasCoordinates(e) {
    const rect = this.canvas.getBoundingClientRect();
    const scaleX = this.canvas.width / rect.width;
    const scaleY = this.canvas.height / rect.height;
    
    let clientX, clientY;
    
    if (e.touches) {
      clientX = e.touches[0].clientX;
      clientY = e.touches[0].clientY;
    } else {
      clientX = e.clientX;
      clientY = e.clientY;
    }
    
    return {
      x: (clientX - rect.left) * scaleX,
      y: (clientY - rect.top) * scaleY
    };
  }
  
  setTool(tool) {
    this.tool = tool;
  }
  
  setColor(color) {
    this.color = color;
  }
  
  setLineWidth(width) {
    this.lineWidth = width;
  }
  
  downloadImage() {
    const link = document.createElement('a');
    link.download = 'whiteboard.png';
    link.href = this.canvas.toDataURL();
    link.click();
  }
}

🎯 今日挑战

实现增强版WebRTC应用,要求:

  1. 支持多人视频会议(>3人)
  2. 实现动态带宽自适应(根据网络质量调整视频码率)
  3. 实现美颜滤镜(使用Canvas API)
  4. 实现虚拟背景(使用MediaPipe)
  5. 录制通话并保存为WebM文件
  6. 实时字幕(Web Speech API)
// 带宽自适应
class AdaptiveBitrateController {
  constructor(peerConnection) {
    this.pc = peerConnection;
    this.lastBytesReceived = 0;
    this.lastTimestamp = 0;
  }
  
  async monitorBandwidth() {
    const stats = await this.pc.getStats();
    let bytesReceived = 0;
    let timestamp = 0;
    
    stats.forEach(stat => {
      if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
        bytesReceived = stat.bytesReceived;
        timestamp = stat.timestamp;
      }
    });
    
    if (this.lastBytesReceived > 0) {
      const bitrate = (bytesReceived - this.lastBytesReceived) * 8 / 
                      (timestamp - this.lastTimestamp);
      
      // 根据带宽调整视频码率
      if (bitrate < 500000) { // 低于500kbps
        this.setVideoBitrate(300000); // 降低到300kbps
      } else if (bitrate > 2000000) { // 高于2Mbps
        this.setVideoBitrate(1500000); // 提高到1.5Mbps
      }
    }
    
    this.lastBytesReceived = bytesReceived;
    this.lastTimestamp = timestamp;
  }
  
  setVideoBitrate(bitrate) {
    const sender = this.pc.getSenders().find(s => s.track?.kind === 'video');
    if (sender) {
      const parameters = sender.getParameters();
      if (!parameters.encodings) {
        parameters.encodings = [{}];
      }
      parameters.encodings[0].maxBitrate = bitrate;
      sender.setParameters(parameters);
    }
  }
}

📊 WebRTC性能优化

优化项方法效果
分辨率适配动态调整视频分辨率减少50%带宽
Simulcast同时发送多路码流提升弱网体验
SVC可伸缩视频编码降低30%延迟
ICE重启网络切换时重连提升连接稳定性

明日预告:前端测试策略 - 单元测试、组件测试、E2E测试的完整实践

💡 WebRTC核心:P2P不是终点,混合架构才是大规模应用的答案!