Vue 3 与 WebRTC 高级应用

概述

WebRTC(Web Real-Time Communication)是一项开放的实时通信技术,允许浏览器之间直接进行音频、视频和数据传输,无需任何插件。它提供了强大的API,使开发者能够构建高质量的实时通信应用。本集将深入探讨 Vue 3 与 WebRTC 的高级集成,包括核心概念、信令服务器实现、音视频质量优化、屏幕共享、录制功能、多人视频会议、数据通道高级应用等,帮助开发者构建专业级的实时通信应用。

核心知识点

1. WebRTC 基础

1.1 设计理念

  • 开放标准 - 基于 W3C 和 IETF 标准,跨浏览器兼容
  • 点对点通信 - 浏览器之间直接通信,减少延迟
  • 实时性 - 低延迟的音视频和数据传输
  • 安全性 - 内置加密机制,确保通信安全
  • 媒体处理 - 提供强大的媒体捕获和处理能力

1.2 核心概念

  • MediaStream - 表示音频或视频流
  • RTCPeerConnection - 负责点对点连接的建立和管理
  • RTCDataChannel - 用于浏览器之间的点对点数据传输
  • 信令服务器 - 用于交换连接建立所需的元数据
  • ICE(Interactive Connectivity Establishment) - 用于在 NAT 环境中建立连接
  • STUN(Session Traversal Utilities for NAT) - 用于获取设备的公网 IP 地址
  • TURN(Traversal Using Relays around NAT) - 用于在 NAT 穿透失败时中继流量

1.3 WebRTC 工作原理

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   浏览器 A        │     │  信令服务器      │     │   浏览器 B        │
└────────┬────────┘     └────────┬────────┘     └────────┬────────┘
         │                       │                       │
         │ 1. 获取本地媒体流      │                       │
         │◄──────────────────────┘                       │
         │                                               │
         │ 2. 创建 Offer       │ 3. 发送 Offer          │
         ├───────────────────────►│───────────────────────►│
         │                       │                       │
         │                       │ 4. 获取本地媒体流      │
         │                       │◄──────────────────────┘
         │                       │                       │
         │ 6. 发送 Answer       │ 5. 创建 Answer         │
         │◄───────────────────────┤◄───────────────────────┤
         │                       │                       │
         │ 7. 建立 PeerConnection│                       │
         │◄──────────────────────┘                       │
         │                                               │
         │ 8. 直接音视频通信     │ 8. 直接音视频通信     │
         ├───────────────────────────────────────────────┤
         │                                               │
┌────────▼────────┐     ┌─────────────────┐     ┌────────▼────────┐
│   音视频播放      │     │  STUN/TURN 服务器  │     │   音视频播放      │
└─────────────────┘     └─────────────────┘     └─────────────────┘

2. 信令服务器实现

2.1 基于 WebSocket 的信令服务器

// src/server/signaling-server.js
const http = require('http');
const WebSocket = require('ws');
const express = require('express');

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

// 存储房间和连接信息
const rooms = new Map();

wss.on('connection', (ws) => {
  console.log('新连接建立');
  
  // 处理消息
  ws.on('message', (message) => {
    try {
      const data = JSON.parse(message);
      handleSignalingMessage(ws, data);
    } catch (error) {
      console.error('消息解析错误:', error);
    }
  });
  
  // 处理连接关闭
  ws.on('close', () => {
    console.log('连接关闭');
    removeConnection(ws);
  });
  
  // 处理错误
  ws.on('error', (error) => {
    console.error('WebSocket 错误:', error);
  });
});

// 处理信令消息
function handleSignalingMessage(ws, message) {
  const { type, roomId, data, userId } = message;
  
  switch (type) {
    case 'join-room':
      joinRoom(ws, roomId, userId);
      break;
    case 'offer':
    case 'answer':
    case 'ice-candidate':
      forwardSignalingMessage(ws, message);
      break;
    case 'leave-room':
      leaveRoom(ws, roomId);
      break;
    default:
      console.warn('未知消息类型:', type);
  }
}

// 加入房间
function joinRoom(ws, roomId, userId) {
  if (!rooms.has(roomId)) {
    rooms.set(roomId, new Map());
  }
  
  const room = rooms.get(roomId);
  room.set(ws, { userId });
  
  // 存储房间信息到连接
  ws.roomId = roomId;
  ws.userId = userId;
  
  // 通知房间内其他用户
  broadcastToRoom(roomId, ws, {
    type: 'user-joined',
    userId
  });
  
  // 发送房间内现有用户列表
  const userIds = Array.from(room.values()).map(user => user.userId);
  ws.send(JSON.stringify({
    type: 'room-users',
    users: userIds
  }));
  
  console.log(`用户 ${userId} 加入房间 ${roomId}`);
}

// 离开房间
function leaveRoom(ws, roomId) {
  const room = rooms.get(roomId);
  if (room) {
    room.delete(ws);
    
    // 如果房间为空,删除房间
    if (room.size === 0) {
      rooms.delete(roomId);
    } else {
      // 通知房间内其他用户
      broadcastToRoom(roomId, ws, {
        type: 'user-left',
        userId: ws.userId
      });
    }
    
    console.log(`用户 ${ws.userId} 离开房间 ${roomId}`);
  }
}

// 转发信令消息
function forwardSignalingMessage(ws, message) {
  const { roomId, targetUserId } = message;
  const room = rooms.get(roomId);
  
  if (room) {
    // 查找目标用户
    for (const [connection, user] of room.entries()) {
      if (user.userId === targetUserId) {
        connection.send(JSON.stringify(message));
        break;
      }
    }
  }
}

// 向房间内其他用户广播消息
function broadcastToRoom(roomId, senderWs, message) {
  const room = rooms.get(roomId);
  if (room) {
    for (const [connection] of room.entries()) {
      if (connection !== senderWs && connection.readyState === WebSocket.OPEN) {
        connection.send(JSON.stringify(message));
      }
    }
  }
}

// 移除连接
function removeConnection(ws) {
  if (ws.roomId) {
    leaveRoom(ws, ws.roomId);
  }
}

// 启动服务器
const PORT = process.env.PORT || 3001;
server.listen(PORT, () => {
  console.log(`信令服务器运行在端口 ${PORT}`);
});

2.2 Vue 组件中集成 WebRTC

<template>
  <div class="webrtc-app">
    <h1>WebRTC 视频通话</h1>
    
    <div class="controls">
      <input 
        v-model="roomId" 
        placeholder="房间 ID" 
        type="text"
      />
      <input 
        v-model="userId" 
        placeholder="用户 ID" 
        type="text"
      />
      <button @click="joinRoom" :disabled="isInRoom">
        加入房间
      </button>
      <button @click="leaveRoom" :disabled="!isInRoom">
        离开房间
      </button>
      <button @click="toggleVideo" :disabled="!isInRoom">
        {{ isVideoEnabled ? '关闭视频' : '开启视频' }}
      </button>
      <button @click="toggleAudio" :disabled="!isInRoom">
        {{ isAudioEnabled ? '关闭音频' : '开启音频' }}
      </button>
      <button @click="startScreenShare" :disabled="!isInRoom || isSharingScreen">
        开始屏幕共享
      </button>
      <button @click="stopScreenShare" :disabled="!isInRoom || !isSharingScreen">
        停止屏幕共享
      </button>
    </div>
    
    <div class="video-container">
      <div class="local-video-wrapper">
        <h3>本地视频</h3>
        <video 
          ref="localVideo" 
          autoplay 
          playsinline 
          muted
        ></video>
      </div>
      
      <div class="remote-videos">
        <h3>远程视频</h3>
        <div class="remote-video-grid">
          <div 
            v-for="(videoElement, remoteUserId) in remoteVideos" 
            :key="remoteUserId"
            class="remote-video-wrapper"
          >
            <h4>用户 {{ remoteUserId }}</h4>
            <video 
              :ref="el => setRemoteVideoRef(el, remoteUserId)" 
              autoplay 
              playsinline
            ></video>
          </div>
        </div>
      </div>
    </div>
    
    <div class="chat-area">
      <h3>聊天</h3>
      <div class="messages" ref="messagesContainer">
        <div 
          v-for="(message, index) in messages" 
          :key="index"
          class="message"
          :class="{ 'own-message': message.senderId === userId }"
        >
          <span class="sender">{{ message.senderId }}:</span>
          <span class="content">{{ message.content }}</span>
        </div>
      </div>
      <div class="message-input">
        <input 
          v-model="chatMessage" 
          placeholder="输入消息..." 
          @keyup.enter="sendChatMessage"
        />
        <button @click="sendChatMessage">发送</button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useWebRTC } from '../composables/useWebRTC'

const roomId = ref('room1')
const userId = ref('user' + Math.floor(Math.random() * 1000))
const chatMessage = ref('')
const messages = ref([])
const localVideo = ref(null)
const remoteVideos = ref({})
const messagesContainer = ref(null)

// 使用 WebRTC 组合式函数
const {
  isInRoom,
  isVideoEnabled,
  isAudioEnabled,
  isSharingScreen,
  joinRoom,
  leaveRoom,
  toggleVideo,
  toggleAudio,
  startScreenShare,
  stopScreenShare,
  sendChatMessage
} = useWebRTC({
  signalingServerUrl: 'ws://localhost:3001',
  localVideoRef: localVideo,
  onRemoteVideoAdded: (remoteUserId) => {
    remoteVideos.value[remoteUserId] = true
  },
  onRemoteVideoRemoved: (remoteUserId) => {
    delete remoteVideos.value[remoteUserId]
  },
  onChatMessage: (message) => {
    messages.value.push(message)
    // 滚动到底部
    setTimeout(() => {
      if (messagesContainer.value) {
        messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
      }
    }, 0)
  }
})

// 设置远程视频引用
const setRemoteVideoRef = (el, remoteUserId) => {
  if (el) {
    // 这里可以将视频元素传递给 WebRTC 组合式函数
    // 具体实现取决于 useWebRTC 的设计
  }
}

// 组件卸载前清理资源
onBeforeUnmount(() => {
  leaveRoom()
})
</script>

<style scoped>
.webrtc-app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.controls {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.controls input {
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  flex: 1;
  min-width: 150px;
}

.controls button {
  padding: 8px 16px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.controls button:hover:not(:disabled) {
  background-color: #35495e;
}

.controls button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.video-container {
  display: grid;
  grid-template-columns: 1fr 2fr;
  gap: 20px;
  margin-bottom: 20px;
}

.local-video-wrapper, .remote-video-wrapper {
  background-color: #f5f5f5;
  padding: 15px;
  border-radius: 8px;
}

.local-video-wrapper video, .remote-video-wrapper video {
  width: 100%;
  height: auto;
  border-radius: 4px;
  background-color: #000;
}

.remote-videos {
  background-color: #f5f5f5;
  padding: 15px;
  border-radius: 8px;
}

.remote-video-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 15px;
  margin-top: 15px;
}

.chat-area {
  background-color: #f5f5f5;
  border-radius: 8px;
  padding: 15px;
}

.messages {
  height: 200px;
  overflow-y: auto;
  margin-bottom: 15px;
  padding: 10px;
  background-color: white;
  border-radius: 4px;
}

.message {
  margin-bottom: 10px;
  padding: 8px 12px;
  background-color: #e9ecef;
  border-radius: 18px;
  max-width: 80%;
}

.message.own-message {
  background-color: #42b883;
  color: white;
  margin-left: auto;
}

.sender {
  font-weight: bold;
  margin-right: 5px;
}

.message-input {
  display: flex;
  gap: 10px;
}

.message-input input {
  flex: 1;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.message-input button {
  padding: 8px 16px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

3. WebRTC 组合式函数实现

// src/composables/useWebRTC.js
import { ref, watch } from 'vue'

export function useWebRTC(options) {
  const { 
    signalingServerUrl, 
    localVideoRef, 
    onRemoteVideoAdded, 
    onRemoteVideoRemoved,
    onChatMessage 
  } = options
  
  // 状态管理
  const isInRoom = ref(false)
  const isVideoEnabled = ref(true)
  const isAudioEnabled = ref(true)
  const isSharingScreen = ref(false)
  
  // WebRTC 相关对象
  let ws = null
  let localStream = null
  let screenStream = null
  let peerConnections = new Map()
  let remoteStreams = new Map()
  
  // 配置 STUN/TURN 服务器
  const iceServers = [
    {
      urls: 'stun:stun.l.google.com:19302'
    },
    // 可选:配置 TURN 服务器
    // {
    //   urls: 'turn:your-turn-server.com:3478',
    //   username: 'username',
    //   credential: 'password'
    // }
  ]
  
  // 初始化 WebSocket 连接
  const initWebSocket = () => {
    ws = new WebSocket(signalingServerUrl)
    
    ws.onopen = () => {
      console.log('WebSocket 连接已建立')
    }
    
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data)
      handleSignalingMessage(message)
    }
    
    ws.onclose = () => {
      console.log('WebSocket 连接已关闭')
      cleanup()
    }
    
    ws.onerror = (error) => {
      console.error('WebSocket 错误:', error)
    }
  }
  
  // 处理信令消息
  const handleSignalingMessage = (message) => {
    switch (message.type) {
      case 'room-users':
        handleRoomUsers(message)
        break
      case 'user-joined':
        handleUserJoined(message)
        break
      case 'user-left':
        handleUserLeft(message)
        break
      case 'offer':
        handleOffer(message)
        break
      case 'answer':
        handleAnswer(message)
        break
      case 'ice-candidate':
        handleICECandidate(message)
        break
      case 'chat-message':
        if (onChatMessage) {
          onChatMessage(message)
        }
        break
      default:
        console.warn('未知消息类型:', message.type)
    }
  }
  
  // 获取本地媒体流
  const getLocalStream = async () => {
    try {
      localStream = await navigator.mediaDevices.getUserMedia({
        video: isVideoEnabled.value,
        audio: isAudioEnabled.value
      })
      
      if (localVideoRef.value) {
        localVideoRef.value.srcObject = localStream
      }
      
      return localStream
    } catch (error) {
      console.error('获取本地媒体流失败:', error)
      throw error
    }
  }
  
  // 创建 PeerConnection
  const createPeerConnection = (remoteUserId) => {
    const pc = new RTCPeerConnection({ iceServers })
    
    // 存储 remoteUserId
    pc.remoteUserId = remoteUserId
    
    // 添加本地流
    if (localStream) {
      localStream.getTracks().forEach(track => {
        pc.addTrack(track, localStream)
      })
    }
    
    // 处理远程流
    pc.ontrack = (event) => {
      handleRemoteTrack(event, remoteUserId)
    }
    
    // 处理 ICE 候选
    pc.onicecandidate = (event) => {
      if (event.candidate) {
        sendSignalingMessage({
          type: 'ice-candidate',
          targetUserId: remoteUserId,
          data: event.candidate
        })
      }
    }
    
    // 处理连接状态变化
    pc.onconnectionstatechange = () => {
      console.log(`与 ${remoteUserId} 的连接状态:`, pc.connectionState)
      if (pc.connectionState === 'failed') {
        pc.restartIce()
      } else if (pc.connectionState === 'closed') {
        cleanupPeerConnection(remoteUserId)
      }
    }
    
    // 创建数据通道
    const dataChannel = pc.createDataChannel('chat')
    setupDataChannel(dataChannel, remoteUserId)
    
    // 处理接收到的数据通道
    pc.ondatachannel = (event) => {
      setupDataChannel(event.channel, remoteUserId)
    }
    
    peerConnections.set(remoteUserId, pc)
    return pc
  }
  
  // 设置数据通道
  const setupDataChannel = (dataChannel, remoteUserId) => {
    dataChannel.onopen = () => {
      console.log(`与 ${remoteUserId} 的数据通道已打开`)
    }
    
    dataChannel.onmessage = (event) => {
      try {
        const message = JSON.parse(event.data)
        if (message.type === 'chat' && onChatMessage) {
          onChatMessage({
            ...message.data,
            senderId: remoteUserId
          })
        }
      } catch (error) {
        console.error('解析数据通道消息失败:', error)
      }
    }
    
    dataChannel.onclose = () => {
      console.log(`与 ${remoteUserId} 的数据通道已关闭`)
    }
    
    dataChannel.onerror = (error) => {
      console.error(`与 ${remoteUserId} 的数据通道错误:`, error)
    }
    
    // 存储数据通道
    peerConnections.get(remoteUserId).dataChannel = dataChannel
  }
  
  // 处理远程流
  const handleRemoteTrack = (event, remoteUserId) => {
    const stream = event.streams[0]
    remoteStreams.set(remoteUserId, stream)
    
    if (onRemoteVideoAdded) {
      onRemoteVideoAdded(remoteUserId)
    }
  }
  
  // 发送信令消息
  const sendSignalingMessage = (message) => {
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({
        ...message,
        roomId: message.roomId || roomId.value,
        senderId: userId.value
      }))
    }
  }
  
  // 加入房间
  const joinRoom = async (roomId, userId) => {
    if (!roomId || !userId) {
      console.error('房间 ID 和用户 ID 不能为空')
      return
    }
    
    // 初始化 WebSocket
    if (!ws || ws.readyState !== WebSocket.OPEN) {
      initWebSocket()
    }
    
    // 获取本地媒体流
    await getLocalStream()
    
    // 发送加入房间请求
    sendSignalingMessage({
      type: 'join-room',
      roomId,
      userId
    })
    
    isInRoom.value = true
  }
  
  // 离开房间
  const leaveRoom = () => {
    if (isInRoom.value) {
      sendSignalingMessage({
        type: 'leave-room'
      })
      cleanup()
    }
  }
  
  // 切换视频
  const toggleVideo = async () => {
    isVideoEnabled.value = !isVideoEnabled.value
    
    if (localStream) {
      const videoTrack = localStream.getVideoTracks()[0]
      if (videoTrack) {
        videoTrack.enabled = isVideoEnabled.value
      }
    }
  }
  
  // 切换音频
  const toggleAudio = () => {
    isAudioEnabled.value = !isAudioEnabled.value
    
    if (localStream) {
      const audioTrack = localStream.getAudioTracks()[0]
      if (audioTrack) {
        audioTrack.enabled = isAudioEnabled.value
      }
    }
  }
  
  // 开始屏幕共享
  const startScreenShare = async () => {
    try {
      screenStream = await navigator.mediaDevices.getDisplayMedia({
        video: true
      })
      
      // 替换视频轨道
      if (localStream && localStream.getVideoTracks().length > 0) {
        const oldVideoTrack = localStream.getVideoTracks()[0]
        const newVideoTrack = screenStream.getVideoTracks()[0]
        
        // 替换本地流中的视频轨道
        localStream.removeTrack(oldVideoTrack)
        localStream.addTrack(newVideoTrack)
        
        // 更新所有 PeerConnection 的视频轨道
        peerConnections.forEach(pc => {
          const senders = pc.getSenders()
          const videoSender = senders.find(sender => sender.track && sender.track.kind === 'video')
          if (videoSender) {
            videoSender.replaceTrack(newVideoTrack)
          }
        })
        
        isSharingScreen.value = true
        
        // 监听屏幕共享结束
        newVideoTrack.onended = () => {
          stopScreenShare()
        }
      }
    } catch (error) {
      console.error('开始屏幕共享失败:', error)
    }
  }
  
  // 停止屏幕共享
  const stopScreenShare = async () => {
    if (screenStream) {
      // 停止屏幕共享流
      screenStream.getTracks().forEach(track => track.stop())
      screenStream = null
      
      // 恢复摄像头视频
      const newLocalStream = await navigator.mediaDevices.getUserMedia({
        video: isVideoEnabled.value,
        audio: isAudioEnabled.value
      })
      
      // 替换本地流
      localStream = newLocalStream
      if (localVideoRef.value) {
        localVideoRef.value.srcObject = localStream
      }
      
      // 更新所有 PeerConnection 的视频轨道
      peerConnections.forEach(pc => {
        const senders = pc.getSenders()
        const videoSender = senders.find(sender => sender.track && sender.track.kind === 'video')
        if (videoSender) {
          videoSender.replaceTrack(localStream.getVideoTracks()[0])
        }
      })
      
      isSharingScreen.value = false
    }
  }
  
  // 发送聊天消息
  const sendChatMessage = (content, senderId) => {
    const message = {
      type: 'chat',
      data: {
        senderId,
        content,
        timestamp: Date.now()
      }
    }
    
    // 发送到所有数据通道
    peerConnections.forEach(pc => {
      if (pc.dataChannel && pc.dataChannel.readyState === 'open') {
        pc.dataChannel.send(JSON.stringify(message))
      }
    })
    
    // 同时通过信令服务器发送
    sendSignalingMessage({
      type: 'chat-message',
      data: message.data
    })
  }
  
  // 清理资源
  const cleanup = () => {
    // 关闭所有 PeerConnection
    peerConnections.forEach((pc, remoteUserId) => {
      cleanupPeerConnection(remoteUserId)
    })
    
    // 停止本地媒体流
    if (localStream) {
      localStream.getTracks().forEach(track => track.stop())
      localStream = null
    }
    
    // 停止屏幕共享流
    if (screenStream) {
      screenStream.getTracks().forEach(track => track.stop())
      screenStream = null
      isSharingScreen.value = false
    }
    
    // 关闭 WebSocket
    if (ws) {
      ws.close()
      ws = null
    }
    
    // 重置状态
    isInRoom.value = false
    remoteStreams.clear()
    peerConnections.clear()
  }
  
  // 清理单个 PeerConnection
  const cleanupPeerConnection = (remoteUserId) => {
    const pc = peerConnections.get(remoteUserId)
    if (pc) {
      pc.close()
      peerConnections.delete(remoteUserId)
    }
    
    if (remoteStreams.has(remoteUserId)) {
      remoteStreams.delete(remoteUserId)
      if (onRemoteVideoRemoved) {
        onRemoteVideoRemoved(remoteUserId)
      }
    }
  }
  
  // 组件卸载前清理
  onBeforeUnmount(() => {
    cleanup()
  })
  
  return {
    isInRoom,
    isVideoEnabled,
    isAudioEnabled,
    isSharingScreen,
    joinRoom,
    leaveRoom,
    toggleVideo,
    toggleAudio,
    startScreenShare,
    stopScreenShare,
    sendChatMessage
  }
}

4. 高级特性

4.1 音视频质量优化

// src/utils/webrtcQuality.js

// 配置音视频编码
const getVideoConstraints = (quality = 'medium') => {
  const constraints = {
    low: {
      width: { ideal: 320 },
      height: { ideal: 240 },
      frameRate: { ideal: 15 }
    },
    medium: {
      width: { ideal: 640 },
      height: { ideal: 480 },
      frameRate: { ideal: 30 }
    },
    high: {
      width: { ideal: 1280 },
      height: { ideal: 720 },
      frameRate: { ideal: 60 }
    },
    hd: {
      width: { ideal: 1920 },
      height: { ideal: 1080 },
      frameRate: { ideal: 60 }
    }
  }
  
  return constraints[quality] || constraints.medium
}

// 配置编解码器
const getCodecPreferences = (pc) => {
  const codecs = []
  
  // 优先使用 H.264
  codecs.push('video/mp4;codecs="avc1.42E01E"')
  // 然后使用 VP8
  codecs.push('video/webm;codecs=vp8')
  // 最后使用 VP9
  codecs.push('video/webm;codecs=vp9')
  
  return codecs
}

// 动态调整视频质量
const adaptVideoQuality = (pc, bandwidth) => {
  // 根据带宽调整视频质量
  let quality = 'medium'
  if (bandwidth < 500) {
    quality = 'low'
  } else if (bandwidth > 2000) {
    quality = 'high'
  }
  
  // 更新视频发送参数
  const sender = pc.getSenders().find(s => s.track && s.track.kind === 'video')
  if (sender) {
    sender.setParameters({
      encodings: [{
        maxBitrate: bandwidth * 1000,
        scaleResolutionDownBy: quality === 'low' ? 2 : quality === 'high' ? 0.5 : 1
      }]
    })
  }
}

export {
  getVideoConstraints,
  getCodecPreferences,
  adaptVideoQuality
}

4.2 录制功能

// src/composables/useMediaRecorder.js
import { ref } from 'vue'

export function useMediaRecorder() {
  const isRecording = ref(false)
  const recordedChunks = ref([])
  const mediaRecorder = ref(null)
  const recordingTime = ref(0)
  let timer = null
  
  // 开始录制
  const startRecording = async (stream) => {
    try {
      // 检查浏览器支持
      if (!navigator.mediaDevices || !MediaRecorder) {
        throw new Error('浏览器不支持 MediaRecorder')
      }
      
      // 创建 MediaRecorder
      mediaRecorder.value = new MediaRecorder(stream, {
        mimeType: 'video/webm;codecs=vp9'
      })
      
      // 处理数据可用事件
      mediaRecorder.value.ondataavailable = (event) => {
        if (event.data.size > 0) {
          recordedChunks.value.push(event.data)
        }
      }
      
      // 处理录制结束事件
      mediaRecorder.value.onstop = () => {
        console.log('录制结束')
      }
      
      // 开始录制
      mediaRecorder.value.start(1000) // 每秒获取一个数据块
      isRecording.value = true
      
      // 开始计时
      recordingTime.value = 0
      timer = setInterval(() => {
        recordingTime.value++
      }, 1000)
      
      console.log('开始录制')
    } catch (error) {
      console.error('开始录制失败:', error)
      throw error
    }
  }
  
  // 停止录制
  const stopRecording = () => {
    if (mediaRecorder.value && isRecording.value) {
      mediaRecorder.value.stop()
      isRecording.value = false
      
      // 停止计时
      if (timer) {
        clearInterval(timer)
        timer = null
      }
      
      // 返回录制的 blob
      const blob = new Blob(recordedChunks.value, { type: 'video/webm' })
      recordedChunks.value = []
      
      return blob
    }
    
    return null
  }
  
  // 取消录制
  const cancelRecording = () => {
    if (mediaRecorder.value && isRecording.value) {
      mediaRecorder.value.stop()
      isRecording.value = false
      
      // 停止计时
      if (timer) {
        clearInterval(timer)
        timer = null
      }
      
      // 清空录制数据
      recordedChunks.value = []
    }
  }
  
  // 下载录制的视频
  const downloadRecording = (blob, filename = 'recording.webm') => {
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = filename
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    URL.revokeObjectURL(url)
  }
  
  return {
    isRecording,
    recordingTime,
    startRecording,
    stopRecording,
    cancelRecording,
    downloadRecording
  }
}

最佳实践

1. 连接管理

  • 实现可靠的信令机制 - 确保信令消息可靠传递,处理重连和消息丢失
  • 使用 TURN 服务器 - 为了在复杂网络环境下确保连接成功
  • 实现心跳机制 - 定期检查连接状态,及时发现并处理断开的连接
  • 处理连接失败 - 实现自动重连和恢复机制
  • 监控连接质量 - 定期测量带宽、延迟、丢包率等指标

2. 音视频质量

  • 动态调整编码参数 - 根据网络状况自适应调整视频分辨率、帧率和比特率
  • 优化编解码器选择 - 根据浏览器支持和网络状况选择合适的编解码器
  • 实现分层编码 - 使用 Simulcast 或 SVC 技术,适应不同设备和网络条件
  • 减少回声和噪音 - 使用音频处理技术,提高通话质量
  • 优化摄像头和麦克风设置 - 选择合适的设备和设置

3. 性能优化

  • 限制同时连接数 - 对于多人会议,考虑使用 SFU(Selective Forwarding Unit)架构
  • 优化渲染性能 - 使用硬件加速,避免主线程阻塞
  • 实现视频平铺和自适应布局 - 对于多人视频,优化布局和渲染
  • 使用 Web Workers - 将复杂计算移至 Web Workers,提高主线程性能
  • 优化数据通道使用 - 合理使用数据通道,避免过度使用

4. 安全性考虑

  • 使用加密连接 - 确保所有通信都使用 HTTPS 和 WSS
  • 验证信令消息 - 验证信令消息的来源和完整性
  • 实现访问控制 - 确保只有授权用户可以加入会议
  • 保护媒体流 - 实现媒体流加密,防止窃听
  • 处理敏感数据 - 避免在客户端存储敏感信息

常见问题与解决方案

1. 连接建立失败

问题:浏览器之间无法建立 WebRTC 连接

解决方案

  • 确保使用了正确的 STUN/TURN 服务器配置
  • 检查防火墙设置,确保允许 WebRTC 流量
  • 确保信令服务器正常工作
  • 检查浏览器控制台的错误信息
  • 实现 ICE 重启机制,处理连接失败情况

2. 音视频质量差

问题:视频卡顿、音频断断续续

解决方案

  • 优化编解码器和编码参数
  • 实现动态质量调整,根据网络状况自适应
  • 减少同时发送的流数量
  • 关闭不必要的应用程序,释放带宽
  • 考虑使用更高性能的 TURN 服务器

3. 回声和噪音问题

问题:通话中出现回声或背景噪音

解决方案

  • 使用耳机,避免麦克风和扬声器之间的声音反馈
  • 实现音频处理,包括回声消除、噪音抑制和自动增益控制
  • 选择高质量的麦克风和扬声器
  • 优化音频捕获设置

4. 浏览器兼容性问题

问题:WebRTC 功能在某些浏览器上不工作

解决方案

  • 检查浏览器 WebRTC 支持情况
  • 使用适配器库(adapter.js)处理浏览器差异
  • 实现优雅降级,为不支持的浏览器提供替代方案
  • 测试主流浏览器,确保兼容性

5. 性能问题

问题:应用运行缓慢,占用大量 CPU 资源

解决方案

  • 优化渲染性能,使用硬件加速
  • 减少同时连接数
  • 优化编解码设置
  • 使用 Web Workers 处理复杂计算
  • 定期释放不再使用的资源

进阶学习资源

  1. 官方文档

  2. 深入学习

  3. 开源项目

  4. 视频教程

实践练习

1. 一对一视频通话应用

目标:创建一个基于 Vue 3 和 WebRTC 的一对一视频通话应用

需求

  1. 实现用户认证和授权
  2. 创建和加入通话房间
  3. 实现实时视频和音频通话
  4. 支持切换摄像头和麦克风
  5. 支持屏幕共享功能
  6. 实现聊天功能
  7. 支持通话录制
  8. 实现通话质量监控
  9. 支持多浏览器兼容
  10. 实现响应式设计

技术栈

  • Vue 3 Composition API
  • WebRTC
  • WebSocket
  • Node.js 服务器
  • STUN/TURN 服务器

2. 多人视频会议应用

目标:创建一个基于 Vue 3 和 WebRTC 的多人视频会议应用

需求

  1. 支持多人同时加入会议(至少 10 人)
  2. 实现网格布局,自适应显示多个视频
  3. 支持主持人控制(静音、移除用户等)
  4. 实现屏幕共享和演示功能
  5. 支持会议录制和回放
  6. 实现聊天和文件共享功能
  7. 支持虚拟背景和美颜功能
  8. 实现 breakout rooms 功能
  9. 支持会议密码和访问控制
  10. 实现会议统计和分析

技术栈

  • Vue 3 Composition API
  • WebRTC
  • SFU(Selective Forwarding Unit)服务器
  • WebSocket
  • Node.js 服务器
  • Redis(用于会话管理)

3. 实时直播应用

目标:创建一个基于 Vue 3 和 WebRTC 的实时直播应用

需求

  1. 支持主播推流和观众观看
  2. 实现低延迟直播(延迟 < 1 秒)
  3. 支持多分辨率切换
  4. 实现弹幕功能
  5. 支持礼物和打赏功能
  6. 实现直播录制和回放
  7. 支持直播统计和分析
  8. 实现主播和观众互动
  9. 支持移动端适配
  10. 实现内容审核功能

技术栈

  • Vue 3 Composition API
  • WebRTC
  • Media Server(如 SRS、Janus 或 Mediasoup)
  • WebSocket
  • Node.js 服务器
  • CDN(用于大规模分发)

总结

WebRTC 是一项强大的实时通信技术,为 Vue 3 应用提供了构建高质量实时通信功能的能力。通过深入理解 WebRTC 的核心概念、信令机制、音视频处理和性能优化,开发者可以构建专业级的实时通信应用,包括一对一视频通话、多人视频会议、实时直播等。

本集介绍了 Vue 3 与 WebRTC 的高级集成,包括信令服务器实现、音视频质量优化、屏幕共享、录制功能、多人视频会议等高级特性,以及最佳实践、常见问题与解决方案。通过学习这些内容,开发者可以掌握 WebRTC 开发的核心技能,构建稳定、高效、高质量的实时通信应用。

WebRTC 技术仍在不断发展,新的特性和优化不断出现。开发者应该持续关注 WebRTC 的最新进展,不断优化和改进应用,提供更好的用户体验。下一集将探讨 Vue 3 与边缘计算的集成,介绍如何利用边缘计算技术提高应用性能和用户体验。

« 上一篇 Vue 3 与 WebSockets 集群应用:高可用实时通信架构 下一篇 » Vue 3 监控与告警系统:实时性能监控与问题预警