HiHuo
首页
博客
手册
工具
首页
博客
手册
工具
  • Vue & CSS 进阶

    • 第0章:工具链与开发体验
    • 第1章:TypeScript 核心
    • 第2章:CSS 现代能力
    • 第3章:Vue3 核心与原理
    • 第4章:路由与状态管理
    • 第5章:工程化与性能
    • 第6章:测试与质量
    • 第7章:CSS 进阶专题
    • 第8章:项目实战A - SaaS仪表盘
    • 第9章:项目实战B - 内容社区
    • 第10章:Vue 内核深入
    • 第11章:微前端与部署
    • CSS 变量体系与主题系统深度指南
    • 前端安全与防护专题
    • CSS 专题:动效优化与微交互
    • CSS 专题:图片与图形处理
    • 实时通信与WebSocket专题
    • 开发工具与调试技巧专题
    • 新兴技术与未来趋势专题
    • 移动端开发与PWA专题
    • 附录A:代码片段库
    • 附录B:练习题集

实时通信与WebSocket专题

掌握现代实时通信技术,构建高性能的实时应用

📚 专题目标

通过本专题学习,你将掌握:

  • WebSocket 连接管理与消息处理
  • Server-Sent Events (SSE) 实时数据推送
  • Socket.io 高级应用
  • WebRTC 音视频通信
  • 实时应用架构设计

🔌 WebSocket 基础应用

WebSocket 连接管理

// websocket.ts
export class WebSocketManager {
  private ws: WebSocket | null = null
  private url: string
  private reconnectAttempts = 0
  private maxReconnectAttempts = 5
  private reconnectInterval = 1000
  private messageQueue: string[] = []
  private isConnecting = false

  constructor(url: string) {
    this.url = url
  }

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      if (this.isConnecting) return
      
      this.isConnecting = true
      
      try {
        this.ws = new WebSocket(this.url)
        
        this.ws.onopen = () => {
          console.log('WebSocket connected')
          this.isConnecting = false
          this.reconnectAttempts = 0
          
          // 发送队列中的消息
          this.flushMessageQueue()
          resolve()
        }
        
        this.ws.onmessage = (event) => {
          this.handleMessage(event.data)
        }
        
        this.ws.onclose = (event) => {
          console.log('WebSocket closed:', event.code, event.reason)
          this.isConnecting = false
          this.handleReconnect()
        }
        
        this.ws.onerror = (error) => {
          console.error('WebSocket error:', error)
          this.isConnecting = false
          reject(error)
        }
        
      } catch (error) {
        this.isConnecting = false
        reject(error)
      }
    })
  }

  send(message: string | object): void {
    const data = typeof message === 'string' ? message : JSON.stringify(message)
    
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(data)
    } else {
      // 连接未就绪,加入队列
      this.messageQueue.push(data)
    }
  }

  private handleMessage(data: string): void {
    try {
      const message = JSON.parse(data)
      this.emit('message', message)
    } catch {
      this.emit('message', data)
    }
  }

  private handleReconnect(): void {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++
      const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1)
      
      setTimeout(() => {
        console.log(`Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
        this.connect().catch(console.error)
      }, delay)
    }
  }

  private flushMessageQueue(): void {
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift()
      if (message && this.ws?.readyState === WebSocket.OPEN) {
        this.ws.send(message)
      }
    }
  }

  disconnect(): void {
    if (this.ws) {
      this.ws.close(1000, 'Client disconnect')
      this.ws = null
    }
  }

  private emit(event: string, data: any): void {
    // 事件发射器逻辑
    window.dispatchEvent(new CustomEvent(`websocket:${event}`, { detail: data }))
  }
}

Vue3 WebSocket Composable

// useWebSocket.ts
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { WebSocketManager } from './websocket'

export function useWebSocket(url: string) {
  const isConnected = ref(false)
  const messages = ref<any[]>([])
  const error = ref<string | null>(null)
  const wsManager = new WebSocketManager(url)

  const connect = async () => {
    try {
      await wsManager.connect()
      isConnected.value = true
      error.value = null
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Connection failed'
    }
  }

  const disconnect = () => {
    wsManager.disconnect()
    isConnected.value = false
  }

  const send = (message: string | object) => {
    if (isConnected.value) {
      wsManager.send(message)
    }
  }

  const handleMessage = (event: CustomEvent) => {
    messages.value.push({
      data: event.detail,
      timestamp: new Date()
    })
  }

  onMounted(() => {
    window.addEventListener('websocket:message', handleMessage as EventListener)
    connect()
  })

  onUnmounted(() => {
    window.removeEventListener('websocket:message', handleMessage as EventListener)
    disconnect()
  })

  return {
    isConnected,
    messages,
    error,
    connect,
    disconnect,
    send
  }
}

实时聊天组件

<template>
  <div class="chat-container">
    <div class="chat-header">
      <h3>实时聊天</h3>
      <div class="connection-status" :class="{ 'connected': isConnected }">
        {{ isConnected ? '已连接' : '连接中...' }}
      </div>
    </div>
    
    <div class="chat-messages" ref="messagesContainer">
      <div
        v-for="message in messages"
        :key="message.id"
        class="message"
        :class="{ 'own-message': message.isOwn }"
      >
        <div class="message-avatar">
          <img :src="message.avatar" :alt="message.username" />
        </div>
        <div class="message-content">
          <div class="message-header">
            <span class="username">{{ message.username }}</span>
            <span class="timestamp">{{ formatTime(message.timestamp) }}</span>
          </div>
          <div class="message-text">{{ message.text }}</div>
        </div>
      </div>
    </div>
    
    <div class="chat-input">
      <input
        v-model="inputMessage"
        type="text"
        placeholder="输入消息..."
        @keyup.enter="sendMessage"
        :disabled="!isConnected"
      />
      <button @click="sendMessage" :disabled="!isConnected || !inputMessage.trim()">
        发送
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
interface ChatMessage {
  id: string
  username: string
  avatar: string
  text: string
  timestamp: Date
  isOwn: boolean
}

const { isConnected, messages, send } = useWebSocket('ws://localhost:8080/chat')

const inputMessage = ref('')
const messagesContainer = ref<HTMLElement>()

const sendMessage = () => {
  if (inputMessage.value.trim() && isConnected.value) {
    const message = {
      type: 'chat',
      text: inputMessage.value.trim(),
      username: '当前用户',
      timestamp: new Date()
    }
    
    send(message)
    inputMessage.value = ''
  }
}

const formatTime = (date: Date) => {
  return date.toLocaleTimeString('zh-CN', { 
    hour: '2-digit', 
    minute: '2-digit' 
  })
}

// 自动滚动到底部
watch(messages, () => {
  nextTick(() => {
    if (messagesContainer.value) {
      messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
    }
  })
}, { deep: true })
</script>

<style scoped>
.chat-container {
  display: flex;
  flex-direction: column;
  height: 500px;
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  overflow: hidden;
}

.chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  background: #f8f9fa;
  border-bottom: 1px solid #e5e5e5;
}

.connection-status {
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 12px;
  background: #ffc107;
  color: #000;
}

.connection-status.connected {
  background: #28a745;
  color: #fff;
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
}

.message {
  display: flex;
  margin-bottom: 16px;
  gap: 12px;
}

.message.own-message {
  flex-direction: row-reverse;
}

.message-avatar img {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  object-fit: cover;
}

.message-content {
  flex: 1;
  max-width: 70%;
}

.message-header {
  display: flex;
  gap: 8px;
  margin-bottom: 4px;
}

.username {
  font-weight: 600;
  font-size: 14px;
}

.timestamp {
  font-size: 12px;
  color: #666;
}

.message-text {
  background: #f1f3f4;
  padding: 8px 12px;
  border-radius: 12px;
  word-wrap: break-word;
}

.own-message .message-text {
  background: #007bff;
  color: white;
}

.chat-input {
  display: flex;
  padding: 16px;
  border-top: 1px solid #e5e5e5;
  gap: 8px;
}

.chat-input input {
  flex: 1;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 20px;
  outline: none;
}

.chat-input input:focus {
  border-color: #007bff;
}

.chat-input button {
  padding: 8px 16px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 20px;
  cursor: pointer;
}

.chat-input button:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

📡 Server-Sent Events (SSE)

SSE 连接管理

// sse.ts
export class SSEManager {
  private eventSource: EventSource | null = null
  private url: string
  private reconnectInterval = 3000
  private isConnecting = false

  constructor(url: string) {
    this.url = url
  }

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      if (this.isConnecting) return
      
      this.isConnecting = true
      
      try {
        this.eventSource = new EventSource(this.url)
        
        this.eventSource.onopen = () => {
          console.log('SSE connected')
          this.isConnecting = false
          resolve()
        }
        
        this.eventSource.onmessage = (event) => {
          this.handleMessage(event.data)
        }
        
        this.eventSource.onerror = (error) => {
          console.error('SSE error:', error)
          this.isConnecting = false
          reject(error)
        }
        
        // 监听自定义事件
        this.eventSource.addEventListener('notification', (event) => {
          this.handleNotification(JSON.parse(event.data))
        })
        
        this.eventSource.addEventListener('update', (event) => {
          this.handleUpdate(JSON.parse(event.data))
        })
        
      } catch (error) {
        this.isConnecting = false
        reject(error)
      }
    })
  }

  private handleMessage(data: string): void {
    try {
      const message = JSON.parse(data)
      this.emit('message', message)
    } catch {
      this.emit('message', data)
    }
  }

  private handleNotification(data: any): void {
    this.emit('notification', data)
  }

  private handleUpdate(data: any): void {
    this.emit('update', data)
  }

  disconnect(): void {
    if (this.eventSource) {
      this.eventSource.close()
      this.eventSource = null
    }
  }

  private emit(event: string, data: any): void {
    window.dispatchEvent(new CustomEvent(`sse:${event}`, { detail: data }))
  }
}

实时通知组件

<template>
  <div class="notification-container">
    <div class="notification-header">
      <h3>实时通知</h3>
      <button @click="clearAll" class="clear-btn">清空</button>
    </div>
    
    <div class="notification-list">
      <div
        v-for="notification in notifications"
        :key="notification.id"
        class="notification-item"
        :class="`notification-${notification.type}`"
      >
        <div class="notification-icon">
          <Icon :name="getIcon(notification.type)" />
        </div>
        <div class="notification-content">
          <div class="notification-title">{{ notification.title }}</div>
          <div class="notification-message">{{ notification.message }}</div>
          <div class="notification-time">{{ formatTime(notification.timestamp) }}</div>
        </div>
        <button @click="removeNotification(notification.id)" class="close-btn">
          <Icon name="x" />
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
interface Notification {
  id: string
  type: 'info' | 'success' | 'warning' | 'error'
  title: string
  message: string
  timestamp: Date
}

const notifications = ref<Notification[]>([])

const { connect, disconnect } = useSSE('http://localhost:8080/events')

const handleNotification = (event: CustomEvent) => {
  const notification: Notification = {
    id: Date.now().toString(),
    ...event.detail,
    timestamp: new Date()
  }
  
  notifications.value.unshift(notification)
  
  // 自动移除通知
  setTimeout(() => {
    removeNotification(notification.id)
  }, 5000)
}

const removeNotification = (id: string) => {
  const index = notifications.value.findIndex(n => n.id === id)
  if (index > -1) {
    notifications.value.splice(index, 1)
  }
}

const clearAll = () => {
  notifications.value = []
}

const getIcon = (type: string) => {
  const icons = {
    info: 'info',
    success: 'check-circle',
    warning: 'alert-triangle',
    error: 'x-circle'
  }
  return icons[type] || 'info'
}

const formatTime = (date: Date) => {
  return date.toLocaleTimeString('zh-CN', { 
    hour: '2-digit', 
    minute: '2-digit',
    second: '2-digit'
  })
}

onMounted(() => {
  window.addEventListener('sse:notification', handleNotification as EventListener)
  connect()
})

onUnmounted(() => {
  window.removeEventListener('sse:notification', handleNotification as EventListener)
  disconnect()
})
</script>

<style scoped>
.notification-container {
  width: 400px;
  height: 500px;
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  overflow: hidden;
  background: white;
}

.notification-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  background: #f8f9fa;
  border-bottom: 1px solid #e5e5e5;
}

.clear-btn {
  padding: 4px 8px;
  background: #6c757d;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
}

.notification-list {
  height: calc(100% - 60px);
  overflow-y: auto;
}

.notification-item {
  display: flex;
  padding: 12px;
  border-bottom: 1px solid #f0f0f0;
  gap: 12px;
  transition: background-color 0.2s ease;
}

.notification-item:hover {
  background: #f8f9fa;
}

.notification-icon {
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.notification-info .notification-icon {
  color: #007bff;
}

.notification-success .notification-icon {
  color: #28a745;
}

.notification-warning .notification-icon {
  color: #ffc107;
}

.notification-error .notification-icon {
  color: #dc3545;
}

.notification-content {
  flex: 1;
}

.notification-title {
  font-weight: 600;
  font-size: 14px;
  margin-bottom: 4px;
}

.notification-message {
  font-size: 13px;
  color: #666;
  margin-bottom: 4px;
}

.notification-time {
  font-size: 11px;
  color: #999;
}

.close-btn {
  width: 20px;
  height: 20px;
  border: none;
  background: none;
  cursor: pointer;
  color: #999;
  display: flex;
  align-items: center;
  justify-content: center;
}

.close-btn:hover {
  color: #666;
}
</style>

🎮 Socket.io 高级应用

Socket.io 客户端管理

// socket.ts
import { io, Socket } from 'socket.io-client'

export class SocketManager {
  private socket: Socket | null = null
  private url: string
  private options: any

  constructor(url: string, options: any = {}) {
    this.url = url
    this.options = {
      autoConnect: false,
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
      ...options
    }
  }

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      if (this.socket?.connected) {
        resolve()
        return
      }

      this.socket = io(this.url, this.options)

      this.socket.on('connect', () => {
        console.log('Socket.io connected')
        resolve()
      })

      this.socket.on('connect_error', (error) => {
        console.error('Socket.io connection error:', error)
        reject(error)
      })

      this.socket.on('disconnect', (reason) => {
        console.log('Socket.io disconnected:', reason)
      })

      this.socket.on('reconnect', (attemptNumber) => {
        console.log('Socket.io reconnected after', attemptNumber, 'attempts')
      })
    })
  }

  emit(event: string, data?: any): void {
    if (this.socket?.connected) {
      this.socket.emit(event, data)
    }
  }

  on(event: string, callback: (data: any) => void): void {
    if (this.socket) {
      this.socket.on(event, callback)
    }
  }

  off(event: string, callback?: (data: any) => void): void {
    if (this.socket) {
      this.socket.off(event, callback)
    }
  }

  disconnect(): void {
    if (this.socket) {
      this.socket.disconnect()
      this.socket = null
    }
  }

  get connected(): boolean {
    return this.socket?.connected || false
  }
}

实时协作编辑器

<template>
  <div class="collaborative-editor">
    <div class="editor-header">
      <h3>协作编辑器</h3>
      <div class="collaborators">
        <div
          v-for="user in collaborators"
          :key="user.id"
          class="collaborator"
          :style="{ backgroundColor: user.color }"
        >
          {{ user.name }}
        </div>
      </div>
    </div>
    
    <div class="editor-content">
      <textarea
        v-model="content"
        @input="handleInput"
        @selectionchange="handleSelectionChange"
        class="editor-textarea"
        placeholder="开始协作编辑..."
      />
    </div>
    
    <div class="editor-status">
      <span>连接状态: {{ isConnected ? '已连接' : '连接中...' }}</span>
      <span>在线用户: {{ collaborators.length }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
interface Collaborator {
  id: string
  name: string
  color: string
  cursor?: { start: number; end: number }
}

const content = ref('')
const collaborators = ref<Collaborator[]>([])
const isConnected = ref(false)

const socketManager = new SocketManager('http://localhost:8080')

const handleInput = (event: Event) => {
  const target = event.target as HTMLTextAreaElement
  const newContent = target.value
  
  // 发送内容更新
  socketManager.emit('content-change', {
    content: newContent,
    timestamp: Date.now()
  })
}

const handleSelectionChange = () => {
  const textarea = document.querySelector('.editor-textarea') as HTMLTextAreaElement
  if (textarea) {
    socketManager.emit('cursor-change', {
      start: textarea.selectionStart,
      end: textarea.selectionEnd
    })
  }
}

const handleContentChange = (data: any) => {
  if (data.content !== content.value) {
    content.value = data.content
  }
}

const handleUserJoin = (data: any) => {
  collaborators.value.push(data.user)
}

const handleUserLeave = (data: any) => {
  const index = collaborators.value.findIndex(u => u.id === data.userId)
  if (index > -1) {
    collaborators.value.splice(index, 1)
  }
}

const handleCursorChange = (data: any) => {
  const user = collaborators.value.find(u => u.id === data.userId)
  if (user) {
    user.cursor = { start: data.start, end: data.end }
  }
}

onMounted(async () => {
  try {
    await socketManager.connect()
    isConnected.value = true
    
    // 监听事件
    socketManager.on('content-change', handleContentChange)
    socketManager.on('user-join', handleUserJoin)
    socketManager.on('user-leave', handleUserLeave)
    socketManager.on('cursor-change', handleCursorChange)
    
    // 加入房间
    socketManager.emit('join-room', { roomId: 'editor-1' })
    
  } catch (error) {
    console.error('Failed to connect:', error)
  }
})

onUnmounted(() => {
  socketManager.disconnect()
})
</script>

<style scoped>
.collaborative-editor {
  width: 100%;
  height: 500px;
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.editor-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  background: #f8f9fa;
  border-bottom: 1px solid #e5e5e5;
}

.collaborators {
  display: flex;
  gap: 8px;
}

.collaborator {
  padding: 4px 8px;
  border-radius: 12px;
  color: white;
  font-size: 12px;
  font-weight: 500;
}

.editor-content {
  flex: 1;
  position: relative;
}

.editor-textarea {
  width: 100%;
  height: 100%;
  border: none;
  outline: none;
  padding: 16px;
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
  font-size: 14px;
  line-height: 1.5;
  resize: none;
}

.editor-status {
  display: flex;
  justify-content: space-between;
  padding: 8px 16px;
  background: #f8f9fa;
  border-top: 1px solid #e5e5e5;
  font-size: 12px;
  color: #666;
}
</style>

🎥 WebRTC 音视频通信

WebRTC 连接管理

// webrtc.ts
export class WebRTCManager {
  private localStream: MediaStream | null = null
  private peerConnection: RTCPeerConnection | null = null
  private dataChannel: RTCDataChannel | null = null

  private iceServers = [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'stun:stun1.l.google.com:19302' }
  ]

  async initializeLocalStream(): Promise<MediaStream> {
    try {
      this.localStream = await navigator.mediaDevices.getUserMedia({
        video: true,
        audio: true
      })
      return this.localStream
    } catch (error) {
      console.error('Error accessing media devices:', error)
      throw error
    }
  }

  createPeerConnection(): RTCPeerConnection {
    this.peerConnection = new RTCPeerConnection({
      iceServers: this.iceServers
    })

    // 添加本地流
    if (this.localStream) {
      this.localStream.getTracks().forEach(track => {
        this.peerConnection!.addTrack(track, this.localStream!)
      })
    }

    // 创建数据通道
    this.dataChannel = this.peerConnection.createDataChannel('chat', {
      ordered: true
    })

    // 监听ICE候选
    this.peerConnection.onicecandidate = (event) => {
      if (event.candidate) {
        this.emit('ice-candidate', event.candidate)
      }
    }

    // 监听远程流
    this.peerConnection.ontrack = (event) => {
      this.emit('remote-stream', event.streams[0])
    }

    // 监听数据通道
    this.peerConnection.ondatachannel = (event) => {
      const channel = event.channel
      channel.onmessage = (event) => {
        this.emit('data-message', JSON.parse(event.data))
      }
    }

    return this.peerConnection
  }

  async createOffer(): Promise<RTCSessionDescriptionInit> {
    if (!this.peerConnection) {
      throw new Error('Peer connection not initialized')
    }

    const offer = await this.peerConnection.createOffer()
    await this.peerConnection.setLocalDescription(offer)
    return offer
  }

  async createAnswer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
    if (!this.peerConnection) {
      throw new Error('Peer connection not initialized')
    }

    await this.peerConnection.setRemoteDescription(offer)
    const answer = await this.peerConnection.createAnswer()
    await this.peerConnection.setLocalDescription(answer)
    return answer
  }

  async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
    if (!this.peerConnection) {
      throw new Error('Peer connection not initialized')
    }

    await this.peerConnection.setRemoteDescription(description)
  }

  async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
    if (!this.peerConnection) {
      throw new Error('Peer connection not initialized')
    }

    await this.peerConnection.addIceCandidate(candidate)
  }

  sendData(data: any): void {
    if (this.dataChannel && this.dataChannel.readyState === 'open') {
      this.dataChannel.send(JSON.stringify(data))
    }
  }

  close(): void {
    if (this.localStream) {
      this.localStream.getTracks().forEach(track => track.stop())
      this.localStream = null
    }

    if (this.peerConnection) {
      this.peerConnection.close()
      this.peerConnection = null
    }

    if (this.dataChannel) {
      this.dataChannel.close()
      this.dataChannel = null
    }
  }

  private emit(event: string, data: any): void {
    window.dispatchEvent(new CustomEvent(`webrtc:${event}`, { detail: data }))
  }
}

视频通话组件

<template>
  <div class="video-call">
    <div class="video-container">
      <div class="local-video">
        <video ref="localVideoRef" autoplay muted playsinline />
        <div class="video-controls">
          <button @click="toggleMute" :class="{ active: !isMuted }">
            <Icon :name="isMuted ? 'mic-off' : 'mic'" />
          </button>
          <button @click="toggleVideo" :class="{ active: !isVideoOff }">
            <Icon :name="isVideoOff ? 'video-off' : 'video'" />
          </button>
          <button @click="endCall" class="end-call">
            <Icon name="phone" />
          </button>
        </div>
      </div>
      
      <div class="remote-video">
        <video ref="remoteVideoRef" autoplay playsinline />
        <div v-if="!remoteStream" class="waiting">
          <Icon name="user" />
          <p>等待对方加入...</p>
        </div>
      </div>
    </div>
    
    <div class="call-info">
      <div class="connection-status" :class="{ connected: isConnected }">
        {{ isConnected ? '已连接' : '连接中...' }}
      </div>
      <div class="call-duration">{{ formatDuration(callDuration) }}</div>
    </div>
  </div>
</template>

<script setup lang="ts">
const localVideoRef = ref<HTMLVideoElement>()
const remoteVideoRef = ref<HTMLVideoElement>()

const isConnected = ref(false)
const isMuted = ref(false)
const isVideoOff = ref(false)
const remoteStream = ref<MediaStream | null>(null)
const callDuration = ref(0)

const webrtcManager = new WebRTCManager()
let callTimer: NodeJS.Timeout | null = null

const initializeCall = async () => {
  try {
    // 获取本地流
    const stream = await webrtcManager.initializeLocalStream()
    if (localVideoRef.value) {
      localVideoRef.value.srcObject = stream
    }

    // 创建对等连接
    webrtcManager.createPeerConnection()

    // 监听事件
    window.addEventListener('webrtc:remote-stream', handleRemoteStream)
    window.addEventListener('webrtc:ice-candidate', handleIceCandidate)

    // 开始计时
    startCallTimer()

  } catch (error) {
    console.error('Failed to initialize call:', error)
  }
}

const handleRemoteStream = (event: CustomEvent) => {
  remoteStream.value = event.detail
  if (remoteVideoRef.value) {
    remoteVideoRef.value.srcObject = event.detail
  }
  isConnected.value = true
}

const handleIceCandidate = (event: CustomEvent) => {
  // 发送ICE候选到对方
  console.log('ICE candidate:', event.detail)
}

const toggleMute = () => {
  isMuted.value = !isMuted.value
  if (webrtcManager.localStream) {
    webrtcManager.localStream.getAudioTracks().forEach(track => {
      track.enabled = !isMuted.value
    })
  }
}

const toggleVideo = () => {
  isVideoOff.value = !isVideoOff.value
  if (webrtcManager.localStream) {
    webrtcManager.localStream.getVideoTracks().forEach(track => {
      track.enabled = !isVideoOff.value
    })
  }
}

const endCall = () => {
  webrtcManager.close()
  if (callTimer) {
    clearInterval(callTimer)
  }
  // 返回上一页或关闭弹窗
}

const startCallTimer = () => {
  callTimer = setInterval(() => {
    callDuration.value++
  }, 1000)
}

const formatDuration = (seconds: number) => {
  const mins = Math.floor(seconds / 60)
  const secs = seconds % 60
  return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}

onMounted(() => {
  initializeCall()
})

onUnmounted(() => {
  webrtcManager.close()
  if (callTimer) {
    clearInterval(callTimer)
  }
})
</script>

<style scoped>
.video-call {
  width: 100%;
  height: 100vh;
  background: #000;
  display: flex;
  flex-direction: column;
}

.video-container {
  flex: 1;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.local-video {
  position: absolute;
  top: 20px;
  right: 20px;
  width: 200px;
  height: 150px;
  border-radius: 8px;
  overflow: hidden;
  z-index: 10;
}

.remote-video {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.remote-video video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.local-video video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.video-controls {
  position: absolute;
  bottom: 10px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 10px;
}

.video-controls button {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.2);
  color: white;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.2s ease;
}

.video-controls button:hover {
  background: rgba(255, 255, 255, 0.3);
}

.video-controls button.active {
  background: #28a745;
}

.video-controls .end-call {
  background: #dc3545;
}

.waiting {
  display: flex;
  flex-direction: column;
  align-items: center;
  color: white;
  gap: 16px;
}

.waiting .icon {
  width: 64px;
  height: 64px;
  opacity: 0.5;
}

.call-info {
  position: absolute;
  top: 20px;
  left: 20px;
  display: flex;
  gap: 20px;
  color: white;
  z-index: 10;
}

.connection-status {
  padding: 4px 8px;
  border-radius: 4px;
  background: rgba(255, 193, 7, 0.8);
  font-size: 12px;
}

.connection-status.connected {
  background: rgba(40, 167, 69, 0.8);
}

.call-duration {
  padding: 4px 8px;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.5);
  font-size: 12px;
  font-family: monospace;
}
</style>

🎯 专题总结

通过本专题学习,你掌握了:

  1. WebSocket 应用:连接管理、消息处理、重连机制
  2. SSE 实时推送:事件流、通知系统、自动重连
  3. Socket.io 高级功能:房间管理、协作编辑、实时同步
  4. WebRTC 音视频:媒体流、对等连接、数据通道
  5. 实时应用架构:状态管理、错误处理、性能优化

📝 练习题

  1. 实现一个完整的实时聊天应用
  2. 开发一个协作文档编辑器
  3. 创建一个视频会议系统
  4. 构建实时数据监控面板
  5. 实现实时游戏功能

🔗 相关资源

  • WebSocket API
  • Server-Sent Events
  • Socket.io 文档
  • WebRTC API
  • 实时通信最佳实践
Prev
CSS 专题:图片与图形处理
Next
开发工具与调试技巧专题