实时聊天应用

实时聊天应用是现代Web应用的重要组成部分,它允许用户实时发送和接收消息。本章将介绍如何使用Vue.js 3构建一个完整的实时聊天应用,包括WebSocket集成、文件上传处理、移动端适配和性能监控。

14.37.1 WebSocket集成

WebSocket基础

WebSocket是一种双向通信协议,它允许服务器主动向客户端推送数据,而不需要客户端频繁请求。与HTTP相比,WebSocket具有更低的延迟和更高的性能,非常适合实时聊天应用。

WebSocket客户端实现

1. 创建WebSocket连接

// src/utils/websocket.ts
class WebSocketClient {
  private ws: WebSocket | null = null
  private url: string
  private reconnectAttempts: number = 0
  private maxReconnectAttempts: number = 5
  private reconnectDelay: number = 1000
  private messageHandlers: Map<string, Array<(data: any) => void>> = new Map()
  private isConnecting: boolean = false

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

  // 连接WebSocket
  connect() {
    if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
      return
    }

    this.isConnecting = true
    this.ws = new WebSocket(this.url)

    // 连接打开
    this.ws.onopen = () => {
      console.log('WebSocket连接已打开')
      this.reconnectAttempts = 0
      this.isConnecting = false
      this.emit('connect')
    }

    // 接收消息
    this.ws.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data)
        this.handleMessage(data)
      } catch (error) {
        console.error('解析WebSocket消息失败:', error)
      }
    }

    // 连接关闭
    this.ws.onclose = () => {
      console.log('WebSocket连接已关闭')
      this.isConnecting = false
      this.emit('disconnect')
      this.attemptReconnect()
    }

    // 连接错误
    this.ws.onerror = (error) => {
      console.error('WebSocket连接错误:', error)
      this.isConnecting = false
      this.emit('error', error)
    }
  }

  // 处理消息
  private handleMessage(data: any) {
    const { type, payload } = data
    if (this.messageHandlers.has(type)) {
      const handlers = this.messageHandlers.get(type)!
      handlers.forEach(handler => handler(payload))
    }
  }

  // 发送消息
  send(type: string, payload: any) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type, payload }))
      return true
    }
    console.error('WebSocket未连接,无法发送消息')
    return false
  }

  // 注册消息处理器
  on(type: string, handler: (data: any) => void) {
    if (!this.messageHandlers.has(type)) {
      this.messageHandlers.set(type, [])
    }
    this.messageHandlers.get(type)!.push(handler)
  }

  // 取消注册消息处理器
  off(type: string, handler: (data: any) => void) {
    if (this.messageHandlers.has(type)) {
      const handlers = this.messageHandlers.get(type)!
      const index = handlers.indexOf(handler)
      if (index > -1) {
        handlers.splice(index, 1)
      }
      if (handlers.length === 0) {
        this.messageHandlers.delete(type)
      }
    }
  }

  // 断开连接
  disconnect() {
    if (this.ws) {
      this.ws.close()
      this.ws = null
    }
    this.reconnectAttempts = this.maxReconnectAttempts
  }

  // 尝试重连
  private attemptReconnect() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++
      const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
      console.log(`尝试重连WebSocket (${this.reconnectAttempts}/${this.maxReconnectAttempts}),延迟 ${delay}ms`)
      setTimeout(() => {
        this.connect()
      }, delay)
    }
  }

  // 触发事件
  private emit(type: string, data?: any) {
    if (this.messageHandlers.has(type)) {
      const handlers = this.messageHandlers.get(type)!
      handlers.forEach(handler => handler(data))
    }
  }
}

// 创建WebSocket实例
export const wsClient = new WebSocketClient(import.meta.env.VITE_WEBSOCKET_URL || 'ws://localhost:3001')

2. 消息类型定义

// src/enums/messageType.ts
export enum MessageType {
  // 连接相关
  CONNECT = 'connect',
  DISCONNECT = 'disconnect',
  ERROR = 'error',
  
  // 认证相关
  AUTH = 'auth',
  AUTH_SUCCESS = 'auth_success',
  AUTH_FAILED = 'auth_failed',
  
  // 消息相关
  SEND_MESSAGE = 'send_message',
  RECEIVE_MESSAGE = 'receive_message',
  MESSAGE_READ = 'message_read',
  MESSAGE_DELETED = 'message_deleted',
  
  // 房间相关
  JOIN_ROOM = 'join_room',
  LEAVE_ROOM = 'leave_room',
  USER_JOINED = 'user_joined',
  USER_LEFT = 'user_left',
  
  // 状态相关
  USER_STATUS_CHANGE = 'user_status_change',
  TYPING = 'typing',
  STOP_TYPING = 'stop_typing'
}

3. 在组件中使用WebSocket

<!-- src/components/chat/ChatRoom.vue -->
<template>
  <div class="chat-room">
    <div class="chat-header">
      <h3>{{ room.name }}</h3>
      <div class="user-status" :class="{ online: isOnline }">{{ isOnline ? '在线' : '离线' }}</div>
    </div>
    
    <div class="chat-messages">
      <div 
        v-for="message in messages" 
        :key="message.id" 
        class="message" 
        :class="{ 'my-message': message.senderId === currentUser.id }"
      >
        <div class="message-content">{{ message.content }}</div>
        <div class="message-time">{{ formatTime(message.createdAt) }}</div>
      </div>
    </div>
    
    <div class="chat-input">
      <el-input
        v-model="inputMessage"
        placeholder="输入消息..."
        @keyup.enter="sendMessage"
        @input="handleTyping"
        clearable
      >
        <template #append>
          <el-button type="primary" @click="sendMessage">发送</el-button>
        </template>
      </el-input>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAuthStore } from '@/stores/modules/auth'
import { wsClient } from '@/utils/websocket'
import { MessageType } from '@/enums/messageType'
import dayjs from 'dayjs'

interface Props {
  room: {
    id: string
    name: string
  }
}

const props = defineProps<Props>()
const authStore = useAuthStore()
const currentUser = computed(() => authStore.userInfo)

const messages = ref<any[]>([])
const inputMessage = ref('')
const isOnline = ref(true)
const typingTimeout = ref<number | null>(null)

// 发送消息
const sendMessage = () => {
  if (!inputMessage.value.trim()) return

  const message = {
    id: Date.now().toString(),
    content: inputMessage.value,
    senderId: currentUser.value?.id,
    senderName: currentUser.value?.name,
    roomId: props.room.id,
    createdAt: new Date().toISOString()
  }

  // 发送WebSocket消息
  wsClient.send(MessageType.SEND_MESSAGE, message)
  
  // 清空输入框
  inputMessage.value = ''
}

// 处理输入(打字状态)
const handleTyping = () => {
  // 发送打字状态
  wsClient.send(MessageType.TYPING, {
    roomId: props.room.id,
    userId: currentUser.value?.id
  })

  // 清除之前的定时器
  if (typingTimeout.value) {
    clearTimeout(typingTimeout.value)
  }

  // 设置新的定时器,3秒后发送停止打字状态
  typingTimeout.value = window.setTimeout(() => {
    wsClient.send(MessageType.STOP_TYPING, {
      roomId: props.room.id,
      userId: currentUser.value?.id
    })
  }, 3000)
}

// 格式化时间
const formatTime = (time: string) => {
  return dayjs(time).format('HH:mm')
}

onMounted(() => {
  // 连接WebSocket
  wsClient.connect()

  // 监听接收消息
  wsClient.on(MessageType.RECEIVE_MESSAGE, (message) => {
    messages.value.push(message)
  })

  // 监听用户加入
  wsClient.on(MessageType.USER_JOINED, (data) => {
    console.log(`${data.userName}加入了房间`)
  })

  // 监听用户离开
  wsClient.on(MessageType.USER_LEFT, (data) => {
    console.log(`${data.userName}离开了房间`)
  })

  // 监听用户状态变化
  wsClient.on(MessageType.USER_STATUS_CHANGE, (data) => {
    isOnline.value = data.status === 'online'
  })

  // 加入房间
  wsClient.send(MessageType.JOIN_ROOM, {
    roomId: props.room.id,
    userId: currentUser.value?.id,
    userName: currentUser.value?.name
  })
})

onUnmounted(() => {
  // 离开房间
  wsClient.send(MessageType.LEAVE_ROOM, {
    roomId: props.room.id,
    userId: currentUser.value?.id
  })

  // 清除定时器
  if (typingTimeout.value) {
    clearTimeout(typingTimeout.value)
  }
})
</script>

WebSocket状态管理

// src/stores/modules/websocket.ts
import { defineStore } from 'pinia'
import { wsClient } from '@/utils/websocket'
import { MessageType } from '@/enums/messageType'

export const useWebSocketStore = defineStore('websocket', {
  state: () => ({
    connected: false,
    reconnecting: false,
    rooms: [] as Array<{
      id: string
      name: string
      users: Array<{
        id: string
        name: string
        status: 'online' | 'offline' | 'typing'
      }>
    }>
  }),
  actions: {
    // 初始化WebSocket
    init() {
      // 监听连接状态
      wsClient.on('connect', () => {
        this.connected = true
        this.reconnecting = false
      })

      wsClient.on('disconnect', () => {
        this.connected = false
      })

      wsClient.on('error', () => {
        this.connected = false
      })

      // 连接WebSocket
      wsClient.connect()
    },

    // 加入房间
    joinRoom(roomId: string, roomName: string) {
      // 检查房间是否已存在
      const existingRoom = this.rooms.find(room => room.id === roomId)
      if (!existingRoom) {
        this.rooms.push({
          id: roomId,
          name: roomName,
          users: []
        })
      }
    },

    // 离开房间
    leaveRoom(roomId: string) {
      const index = this.rooms.findIndex(room => room.id === roomId)
      if (index !== -1) {
        this.rooms.splice(index, 1)
      }
    },

    // 更新用户状态
    updateUserStatus(roomId: string, userId: string, status: 'online' | 'offline' | 'typing') {
      const room = this.rooms.find(room => room.id === roomId)
      if (room) {
        const userIndex = room.users.findIndex(user => user.id === userId)
        if (userIndex !== -1) {
          room.users[userIndex].status = status
        }
      }
    }
  }
})

14.37.2 文件上传处理

1. 单文件上传

<!-- src/components/common/FileUpload.vue -->
<template>
  <div class="file-upload">
    <el-upload
      :action="uploadUrl"
      :headers="headers"
      :before-upload="beforeUpload"
      :on-success="onSuccess"
      :on-error="onError"
      :file-list="fileList"
      :multiple="false"
      :show-file-list="false"
      :drag="true"
    >
      <el-icon class="el-icon--upload"><upload-filled /></el-icon>
      <div class="el-upload__text">
        拖放文件到此处,或<em>点击上传</em>
      </div>
      <template #tip>
        <div class="el-upload__tip">
          只能上传图片文件,且不超过 2MB
        </div>
      </template>
    </el-upload>

    <!-- 上传进度 -->
    <el-progress
      v-if="uploading"
      type="circle"
      :percentage="uploadProgress"
      :width="80"
      :show-text="true"
    />
    
    <!-- 预览 -->
    <el-image
      v-if="previewUrl"
      :src="previewUrl"
      :fit="'cover'"
      style="width: 200px; height: 200px; margin-top: 10px"
      @click="handlePreview"
    >
      <template #error>
        <div class="image-slot">
          <el-icon><picture /></el-icon>
        </div>
      </template>
    </el-image>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { UploadFilled, Picture } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/modules/auth'

interface Props {
  uploadUrl?: string
  accept?: string
  maxSize?: number
}

const props = withDefaults(defineProps<Props>(), {
  uploadUrl: import.meta.env.VITE_UPLOAD_URL || '/api/upload',
  accept: 'image/*',
  maxSize: 2 // MB
})

const emit = defineEmits<{
  (e: 'success', fileUrl: string): void
  (e: 'error', error: any): void
  (e: 'progress', progress: number): void
}>()

const authStore = useAuthStore()
const uploading = ref(false)
const uploadProgress = ref(0)
const fileList = ref<any[]>([])
const previewUrl = ref('')

const headers = computed(() => {
  return {
    Authorization: `Bearer ${authStore.token}`
  }
})

// 上传前校验
const beforeUpload = (file: File) => {
  // 检查文件类型
  const isAccept = props.accept ? new RegExp(props.accept.replace(/\*/g, '.+')).test(file.type) : true
  if (!isAccept) {
    ElMessage.error(`只能上传${props.accept}类型的文件`)
    return false
  }

  // 检查文件大小
  const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
  if (!isLtMaxSize) {
    ElMessage.error(`文件大小不能超过${props.maxSize}MB`)
    return false
  }

  uploading.value = true
  uploadProgress.value = 0
  return true
}

// 上传成功
const onSuccess = (response: any) => {
  uploading.value = false
  uploadProgress.value = 100
  previewUrl.value = response.data.url
  emit('success', response.data.url)
  ElMessage.success('文件上传成功')
}

// 上传失败
const onError = (error: any) => {
  uploading.value = false
  uploadProgress.value = 0
  emit('error', error)
  ElMessage.error('文件上传失败')
}

// 处理预览
const handlePreview = () => {
  // 可以实现预览功能
}
</script>

2. 多文件上传

<!-- src/components/common/MultiFileUpload.vue -->
<template>
  <div class="multi-file-upload">
    <el-upload
      :action="uploadUrl"
      :headers="headers"
      :before-upload="beforeUpload"
      :on-success="onSuccess"
      :on-error="onError"
      :file-list="fileList"
      :multiple="true"
      :limit="limit"
      :on-exceed="onExceed"
      :on-remove="onRemove"
      list-type="picture-card"
    >
      <el-icon class="el-icon--plus"><plus /></el-icon>
      <template #tip>
        <div class="el-upload__tip">
          只能上传图片文件,且不超过 2MB,最多上传 {{ limit }} 个文件
        </div>
      </template>
      <template #file="{ file }">
        <el-image
          :src="file.url || file.raw"
          :fit="'cover'"
        >
          <template #error>
            <div class="image-slot">
              <el-icon><picture /></el-icon>
            </div>
          </template>
        </el-image>
        <span class="el-upload-list__item-actions">
          <span
            class="el-upload-list__item-preview"
            @click="handlePictureCardPreview(file)"
          >
            <el-icon><zoom-in /></el-icon>
          </span>
          <span
            v-if="!disabled"
            class="el-upload-list__item-delete"
            @click="handleRemove(file)"
          >
            <el-icon><delete /></el-icon>
          </span>
        </span>
      </template>
    </el-upload>

    <!-- 预览对话框 -->
    <el-dialog v-model="dialogVisible">
      <img w-full h-60 object-cover :src="dialogImageUrl" alt="预览">
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { Plus, ZoomIn, Delete, Picture } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/modules/auth'

interface Props {
  uploadUrl?: string
  accept?: string
  maxSize?: number
  limit?: number
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  uploadUrl: import.meta.env.VITE_UPLOAD_URL || '/api/upload',
  accept: 'image/*',
  maxSize: 2, // MB
  limit: 9,
  disabled: false
})

const emit = defineEmits<{
  (e: 'success', fileUrls: string[]): void
  (e: 'error', error: any): void
  (e: 'change', fileUrls: string[]): void
}>()

const authStore = useAuthStore()
const fileList = ref<any[]>([])
const uploadedUrls = ref<string[]>([])
const dialogImageUrl = ref('')
const dialogVisible = ref(false)

const headers = computed(() => {
  return {
    Authorization: `Bearer ${authStore.token}`
  }
})

// 上传前校验
const beforeUpload = (file: File) => {
  // 检查文件类型
  const isAccept = props.accept ? new RegExp(props.accept.replace(/\*/g, '.+')).test(file.type) : true
  if (!isAccept) {
    ElMessage.error(`只能上传${props.accept}类型的文件`)
    return false
  }

  // 检查文件大小
  const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
  if (!isLtMaxSize) {
    ElMessage.error(`文件大小不能超过${props.maxSize}MB`)
    return false
  }

  return true
}

// 上传成功
const onSuccess = (response: any, file: any) => {
  const fileUrl = response.data.url
  uploadedUrls.value.push(fileUrl)
  file.url = fileUrl
  emit('success', uploadedUrls.value)
  emit('change', uploadedUrls.value)
  ElMessage.success('文件上传成功')
}

// 上传失败
const onError = (error: any) => {
  emit('error', error)
  ElMessage.error('文件上传失败')
}

// 移除文件
const onRemove = (file: any) => {
  const index = fileList.value.findIndex(item => item.uid === file.uid)
  if (index !== -1) {
    fileList.value.splice(index, 1)
    // 从已上传列表中移除
    const urlIndex = uploadedUrls.value.indexOf(file.url)
    if (urlIndex !== -1) {
      uploadedUrls.value.splice(urlIndex, 1)
      emit('change', uploadedUrls.value)
    }
  }
}

// 处理预览
const handlePictureCardPreview = (file: any) => {
  dialogImageUrl.value = file.url || file.raw
  dialogVisible.value = true
}

// 超出限制
const onExceed = () => {
  ElMessage.error(`最多只能上传${props.limit}个文件`)
}
</script>

3. 断点续传

<!-- src/components/common/ResumableUpload.vue -->
<template>
  <div class="resumable-upload">
    <el-upload
      :action="uploadUrl"
      :headers="headers"
      :before-upload="beforeUpload"
      :http-request="httpRequest"
      :file-list="fileList"
      :multiple="false"
      :show-file-list="false"
      :drag="true"
    >
      <el-icon class="el-icon--upload"><upload-filled /></el-icon>
      <div class="el-upload__text">
        拖放文件到此处,或<em>点击上传</em>
      </div>
      <template #tip>
        <div class="el-upload__tip">
          支持断点续传,只能上传视频文件,且不超过 1GB
        </div>
      </template>
    </el-upload>

    <!-- 上传进度 -->
    <div v-if="uploading" class="upload-progress">
      <el-progress
        type="line"
        :percentage="uploadProgress"
        :status="uploadStatus"
      >
        <template #default>
          {{ `${uploadProgress}% (${formatFileSize(uploadedSize)} / ${formatFileSize(fileSize)})` }}
        </template>
      </el-progress>
      <div class="upload-actions">
        <el-button
          v-if="uploadStatus === 'success'"
          type="success"
          size="small"
          @click="resetUpload"
        >
          重新上传
        </el-button>
        <el-button
          v-else-if="uploadStatus === 'error'"
          type="danger"
          size="small"
          @click="resumeUpload"
        >
          重试
        </el-button>
        <el-button
          v-else
          type="warning"
          size="small"
          @click="toggleUpload"
        >
          {{ isPaused ? '继续' : '暂停' }}
        </el-button>
        <el-button
          v-if="uploadStatus !== 'success'"
          type="danger"
          size="small"
          @click="cancelUpload"
        >
          取消
        </el-button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { UploadFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/modules/auth'

interface Props {
  uploadUrl?: string
  accept?: string
  maxSize?: number
}

const props = withDefaults(defineProps<Props>(), {
  uploadUrl: import.meta.env.VITE_UPLOAD_URL || '/api/upload/resumable',
  accept: 'video/*',
  maxSize: 1024 // MB
})

const emit = defineEmits<{
  (e: 'success', fileUrl: string): void
  (e: 'error', error: any): void
  (e: 'progress', progress: number, uploadedSize: number, fileSize: number): void
}>()

const authStore = useAuthStore()
const uploading = ref(false)
const uploadProgress = ref(0)
const uploadStatus = ref<'success' | 'error' | 'uploading' | 'paused'>('uploading')
const fileList = ref<any[]>([])
const file = ref<File | null>(null)
const fileSize = ref(0)
const uploadedSize = ref(0)
const isPaused = ref(false)
const uploadId = ref('')
const chunkSize = 1024 * 1024 * 5 // 5MB

const headers = computed(() => {
  return {
    Authorization: `Bearer ${authStore.token}`
  }
})

// 格式化文件大小
const formatFileSize = (bytes: number): string => {
  if (bytes < 1024) {
    return bytes + ' B'
  } else if (bytes < 1024 * 1024) {
    return (bytes / 1024).toFixed(2) + ' KB'
  } else if (bytes < 1024 * 1024 * 1024) {
    return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
  } else {
    return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
  }
}

// 上传前校验
const beforeUpload = (file: File) => {
  // 检查文件类型
  const isAccept = props.accept ? new RegExp(props.accept.replace(/\*/g, '.+')).test(file.type) : true
  if (!isAccept) {
    ElMessage.error(`只能上传${props.accept}类型的文件`)
    return false
  }

  // 检查文件大小
  const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
  if (!isLtMaxSize) {
    ElMessage.error(`文件大小不能超过${props.maxSize}MB`)
    return false
  }

  file.value = file
  fileSize.value = file.size
  uploadedSize.value = 0
  uploadProgress.value = 0
  uploadStatus.value = 'uploading'
  isPaused.value = false
  uploadId.value = ''
  uploading.value = true

  // 生成文件唯一标识
  uploadId.value = `${file.name}-${file.size}-${file.lastModified}`

  return false // 阻止默认上传
}

// 自定义上传请求
const httpRequest = () => {
  // 开始上传
  startUpload()
}

// 开始上传
const startUpload = async () => {
  if (!file.value) return

  try {
    // 检查上传状态
    const checkResponse = await fetch(`${props.uploadUrl}/check`, {
      method: 'POST',
      headers: {
        ...headers.value,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        uploadId: uploadId.value,
        fileName: file.value.name,
        fileSize: file.value.size,
        fileType: file.value.type
      })
    })

    const checkResult = await checkResponse.json()
    if (checkResult.exists) {
      // 文件已存在,直接返回
      handleUploadSuccess(checkResult.fileUrl)
      return
    }

    // 从上次上传的位置继续
    uploadedSize.value = checkResult.uploadedSize || 0
    uploadProgress.value = Math.round((uploadedSize.value / fileSize.value) * 100)

    // 开始分片上传
    uploadChunks()
  } catch (error) {
    console.error('检查上传状态失败:', error)
    handleUploadError(error)
  }
}

// 上传分片
const uploadChunks = async () => {
  if (!file.value || isPaused.value || uploadStatus.value !== 'uploading') {
    return
  }

  try {
    const start = uploadedSize.value
    const end = Math.min(start + chunkSize, fileSize.value)
    const chunk = file.value.slice(start, end)

    const formData = new FormData()
    formData.append('file', chunk)
    formData.append('uploadId', uploadId.value)
    formData.append('fileName', file.value.name)
    formData.append('fileSize', fileSize.value.toString())
    formData.append('fileType', file.value.type)
    formData.append('chunkStart', start.toString())
    formData.append('chunkEnd', end.toString())

    const response = await fetch(props.uploadUrl, {
      method: 'POST',
      headers: {
        ...headers.value
      },
      body: formData
    })

    const result = await response.json()
    if (result.success) {
      uploadedSize.value = end
      uploadProgress.value = Math.round((uploadedSize.value / fileSize.value) * 100)
      emit('progress', uploadProgress.value, uploadedSize.value, fileSize.value)

      // 检查是否上传完成
      if (uploadedSize.value >= fileSize.value) {
        // 合并分片
        const mergeResponse = await fetch(`${props.uploadUrl}/merge`, {
          method: 'POST',
          headers: {
            ...headers.value,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            uploadId: uploadId.value,
            fileName: file.value.name,
            fileSize: file.value.size,
            fileType: file.value.type
          })
        })

        const mergeResult = await mergeResponse.json()
        if (mergeResult.success) {
          handleUploadSuccess(mergeResult.fileUrl)
        } else {
          handleUploadError(new Error(mergeResult.message || '合并分片失败'))
        }
      } else {
        // 继续上传下一个分片
        uploadChunks()
      }
    } else {
      handleUploadError(new Error(result.message || '上传分片失败'))
    }
  } catch (error) {
    console.error('上传分片失败:', error)
    handleUploadError(error)
  }
}

// 处理上传成功
const handleUploadSuccess = (fileUrl: string) => {
  uploading.value = true
  uploadProgress.value = 100
  uploadStatus.value = 'success'
  emit('success', fileUrl)
  ElMessage.success('文件上传成功')
}

// 处理上传错误
const handleUploadError = (error: any) => {
  uploading.value = true
  uploadStatus.value = 'error'
  emit('error', error)
  ElMessage.error('文件上传失败')
}

// 暂停/继续上传
const toggleUpload = () => {
  isPaused.value = !isPaused.value
  if (!isPaused.value) {
    uploadStatus.value = 'uploading'
    uploadChunks()
  }
}

// 恢复上传
const resumeUpload = () => {
  uploadStatus.value = 'uploading'
  isPaused.value = false
  uploadChunks()
}

// 取消上传
const cancelUpload = () => {
  uploading.value = false
  uploadStatus.value = 'error'
  resetUpload()
  ElMessage.info('上传已取消')
}

// 重置上传
const resetUpload = () => {
  uploading.value = false
  uploadProgress.value = 0
  uploadStatus.value = 'uploading'
  fileList.value = []
  file.value = null
  fileSize.value = 0
  uploadedSize.value = 0
  isPaused.value = false
  uploadId.value = ''
}
</script>

14.37.3 移动端适配

1. 响应式设计

媒体查询

// src/styles/mixins.scss
// 移动端适配
@mixin mobile {
  @media (max-width: 768px) {
    @content;
  }
}

@mixin tablet {
  @media (min-width: 769px) and (max-width: 1024px) {
    @content;
  }
}

@mixin desktop {
  @media (min-width: 1025px) {
    @content;
  }
}

// src/components/chat/ChatRoom.vue
<style scoped lang="scss">
.chat-room {
  display: flex;
  flex-direction: column;
  height: 100%;
  
  @include mobile {
    padding: 0 10px;
  }
}

.chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px;
  border-bottom: 1px solid #eee;
  
  @include mobile {
    padding: 10px;
  }
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 15px;
  
  @include mobile {
    padding: 10px;
  }
}

.chat-input {
  padding: 15px;
  border-top: 1px solid #eee;
  
  @include mobile {
    padding: 10px;
  }
}
</style>

弹性布局

<!-- src/components/layout/MobileLayout.vue -->
<template>
  <div class="mobile-layout">
    <!-- 顶部导航 -->
    <header class="mobile-header">
      <el-icon v-if="showBack" class="header-icon" @click="$router.back()">
        <arrow-left />
      </el-icon>
      <h1 class="header-title">{{ title }}</h1>
      <div class="header-actions">
        <slot name="actions"></slot>
      </div>
    </header>
    
    <!-- 主内容区 -->
    <main class="mobile-content">
      <slot></slot>
    </main>
    
    <!-- 底部导航 -->
    <footer class="mobile-footer" v-if="showFooter">
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

<script setup lang="ts">
import { ArrowLeft } from '@element-plus/icons-vue'

interface Props {
  title: string
  showBack?: boolean
  showFooter?: boolean
}

withDefaults(defineProps<Props>(), {
  showBack: true,
  showFooter: false
})
</script>

<style scoped lang="scss">
.mobile-layout {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  background-color: #f5f7fa;
}

.mobile-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  background-color: #fff;
  border-bottom: 1px solid #e4e7ed;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
  
  .header-icon {
    font-size: 20px;
    cursor: pointer;
    color: #606266;
    
    &:hover {
      color: #409eff;
    }
  }
  
  .header-title {
    font-size: 18px;
    font-weight: 500;
    color: #303133;
    margin: 0;
  }
  
  .header-actions {
    display: flex;
    gap: 12px;
  }
}

.mobile-content {
  flex: 1;
  padding: 16px;
  overflow-y: auto;
}

.mobile-footer {
  padding: 12px 16px;
  background-color: #fff;
  border-top: 1px solid #e4e7ed;
}
</style>

2. 触摸事件优化

// src/directives/touch.ts
import type { Directive, DirectiveBinding } from 'vue'

interface TouchState {
  startX: number
  startY: number
  endX: number
  endY: number
  startTime: number
  endTime: number
}

// 触摸指令
const touch: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const touchState: TouchState = {
      startX: 0,
      startY: 0,
      endX: 0,
      endY: 0,
      startTime: 0,
      endTime: 0
    }

    // 触摸开始
    el.addEventListener('touchstart', (e) => {
      touchState.startX = e.touches[0].clientX
      touchState.startY = e.touches[0].clientY
      touchState.startTime = Date.now()
    })

    // 触摸结束
    el.addEventListener('touchend', (e) => {
      touchState.endX = e.changedTouches[0].clientX
      touchState.endY = e.changedTouches[0].clientY
      touchState.endTime = Date.now()

      const deltaX = touchState.endX - touchState.startX
      const deltaY = touchState.endY - touchState.startY
      const deltaTime = touchState.endTime - touchState.startTime

      // 计算滑动距离和速度
      const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
      const speed = distance / deltaTime

      // 处理滑动事件
      if (distance > 30) {
        // 水平滑动
        if (Math.abs(deltaX) > Math.abs(deltaY)) {
          if (deltaX > 0) {
            binding.value?.swipeRight?.(e, touchState)
          } else {
            binding.value?.swipeLeft?.(e, touchState)
          }
        } 
        // 垂直滑动
        else {
          if (deltaY > 0) {
            binding.value?.swipeDown?.(e, touchState)
          } else {
            binding.value?.swipeUp?.(e, touchState)
          }
        }
      }
      // 处理点击事件
      else if (deltaTime < 300) {
        binding.value?.tap?.(e, touchState)
      }
      // 处理长按事件
      else if (deltaTime > 500) {
        binding.value?.longPress?.(e, touchState)
      }
    })
  }
}

export default touch

3. 性能优化

虚拟列表

<!-- src/components/common/VirtualList.vue -->
<template>
  <div 
    class="virtual-list" 
    :style="containerStyle" 
    @scroll="handleScroll"
  >
    <div 
      class="virtual-list__content" 
      :style="contentStyle"
    >
      <div 
        v-for="item in visibleItems" 
        :key="item.key || item.index" 
        class="virtual-list__item" 
        :style="getItemStyle(item)"
      >
        <slot :item="item.data" :index="item.index"></slot>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'

interface VirtualListItem {
  index: number
  key?: string
  data: any
}

interface Props {
  items: any[]
  itemHeight: number
  containerHeight?: number
  overscan?: number
  keyField?: string
}

const props = withDefaults(defineProps<Props>(), {
  containerHeight: 400,
  overscan: 5,
  keyField: 'id'
})

const containerStyle = computed(() => {
  return {
    height: `${props.containerHeight}px`,
    overflow: 'auto',
    position: 'relative'
  }
})

const contentStyle = computed(() => {
  return {
    height: `${props.items.length * props.itemHeight}px`,
    position: 'relative'
  }
})

const scrollTop = ref(0)
const startIndex = ref(0)
const endIndex = ref(0)

const visibleCount = computed(() => {
  return Math.ceil(props.containerHeight / props.itemHeight)
})

const offset = computed(() => {
  return startIndex.value * props.itemHeight
})

const visibleItems = computed(() => {
  const start = Math.max(0, startIndex.value - props.overscan)
  const end = Math.min(props.items.length - 1, endIndex.value + props.overscan)
  
  return props.items.slice(start, end + 1).map((item, index) => ({
    index: start + index,
    key: item[props.keyField],
    data: item
  }))
})

const handleScroll = (e: Event) => {
  const target = e.target as HTMLElement
  scrollTop.value = target.scrollTop
  updateVisibleRange()
}

const updateVisibleRange = () => {
  startIndex.value = Math.floor(scrollTop.value / props.itemHeight)
  endIndex.value = startIndex.value + visibleCount.value - 1
}

const getItemStyle = (item: VirtualListItem) => {
  return {
    position: 'absolute',
    top: `${item.index * props.itemHeight}px`,
    left: '0',
    width: '100%',
    height: `${props.itemHeight}px`
  }
}

watch(() => props.items.length, () => {
  updateVisibleRange()
})

watch(() => props.containerHeight, () => {
  updateVisibleRange()
})

onMounted(() => {
  updateVisibleRange()
})
</script>

<style scoped lang="scss">
.virtual-list {
  width: 100%;
  
  &__content {
    width: 100%;
  }
  
  &__item {
    box-sizing: border-box;
  }
}
</style>

14.37.4 性能监控

1. 应用性能监控

// src/utils/performance.ts
// 性能监控类
class PerformanceMonitor {
  private performanceData: any = {}
  private observers: any[] = []

  constructor() {
    // 初始化性能数据
    this.initPerformanceData()
    // 注册性能观察者
    this.registerObservers()
  }

  // 初始化性能数据
  private initPerformanceData() {
    this.performanceData = {
      navigation: {},
      paint: {},
      resource: [],
      longtask: [],
      firstInput: {},
      layoutShift: 0
    }
  }

  // 注册性能观察者
  private registerObservers() {
    // 导航性能
    if ('navigation' in performance) {
      this.performanceData.navigation = this.getNavigationData()
    }

    // 绘制性能
    if ('PerformanceObserver' in window) {
      // 首屏绘制
      this.observers.push(new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
          if (entry.name === 'first-paint') {
            this.performanceData.paint.firstPaint = entry.startTime
          } else if (entry.name === 'first-contentful-paint') {
            this.performanceData.paint.firstContentfulPaint = entry.startTime
          }
        })
      }).observe({ entryTypes: ['paint'] }))

      // 资源加载
      this.observers.push(new PerformanceObserver((list) => {
        this.performanceData.resource.push(...list.getEntries())
      }).observe({ entryTypes: ['resource'] }))

      // 长任务
      this.observers.push(new PerformanceObserver((list) => {
        this.performanceData.longtask.push(...list.getEntries())
      }).observe({ entryTypes: ['longtask'] }))

      // 首次输入延迟
      this.observers.push(new PerformanceObserver((list) => {
        const firstInputEntry = list.getEntries()[0]
        if (firstInputEntry) {
          this.performanceData.firstInput.delay = firstInputEntry.processingStart - firstInputEntry.startTime
          this.performanceData.firstInput.duration = firstInputEntry.duration
        }
      }).observe({ entryTypes: ['first-input'] }))

      // 布局偏移
      this.observers.push(new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
          if (!entry.hadRecentInput) {
            this.performanceData.layoutShift += entry.value
          }
        })
      }).observe({ entryTypes: ['layout-shift'] }))
    }
  }

  // 获取导航性能数据
  private getNavigationData() {
    const timing = performance.timing
    return {
      // 重定向时间
      redirectTime: timing.redirectEnd - timing.redirectStart,
      // DNS查询时间
      dnsLookupTime: timing.domainLookupEnd - timing.domainLookupStart,
      // TCP连接时间
      tcpConnectTime: timing.connectEnd - timing.connectStart,
      // SSL握手时间
      sslHandshakeTime: timing.secureConnectionStart > 0 ? timing.connectEnd - timing.secureConnectionStart : 0,
      // 首字节时间
      ttfb: timing.responseStart - timing.navigationStart,
      // 内容加载时间
      contentLoadTime: timing.responseEnd - timing.responseStart,
      // DOM构建时间
      domBuildTime: timing.domContentLoadedEventEnd - timing.domLoading,
      // 页面加载时间
      pageLoadTime: timing.loadEventEnd - timing.navigationStart,
      // 白屏时间
      whiteScreenTime: timing.responseStart - timing.navigationStart
    }
  }

  // 获取性能数据
  getPerformanceData() {
    return this.performanceData
  }

  // 上报性能数据
  reportPerformance() {
    const data = this.getPerformanceData()
    // 这里可以添加上报逻辑
    console.log('性能数据:', data)
  }

  // 销毁观察者
  destroy() {
    this.observers.forEach(observer => observer.disconnect())
  }
}

// 创建性能监控实例
export const performanceMonitor = new PerformanceMonitor()

2. WebSocket性能监控

// src/utils/websocketMonitor.ts
class WebSocketMonitor {
  private messageTimes: Map<string, number> = new Map()
  private latencyData: number[] = []
  private messageCount: number = 0
  private errorCount: number = 0
  private reconnectCount: number = 0
  private startTime: number = Date.now()

  // 记录消息发送时间
  recordMessageSend(messageId: string) {
    this.messageTimes.set(messageId, Date.now())
  }

  // 记录消息接收时间
  recordMessageReceive(messageId: string) {
    const sendTime = this.messageTimes.get(messageId)
    if (sendTime) {
      const latency = Date.now() - sendTime
      this.latencyData.push(latency)
      this.messageCount++
      this.messageTimes.delete(messageId)
      // 这里可以添加上报逻辑
      console.log(`消息延迟: ${latency}ms`)
    }
  }

  // 记录错误
  recordError(error: any) {
    this.errorCount++
    // 这里可以添加上报逻辑
    console.error('WebSocket错误:', error)
  }

  // 记录重连
  recordReconnect() {
    this.reconnectCount++
    // 这里可以添加上报逻辑
    console.log(`WebSocket重连: ${this.reconnectCount}次`)
  }

  // 获取统计数据
  getStats() {
    const avgLatency = this.latencyData.length > 0 
      ? Math.round(this.latencyData.reduce((a, b) => a + b, 0) / this.latencyData.length)
      : 0
    
    const maxLatency = this.latencyData.length > 0 
      ? Math.max(...this.latencyData)
      : 0
    
    const minLatency = this.latencyData.length > 0 
      ? Math.min(...this.latencyData)
      : 0

    const uptime = Date.now() - this.startTime

    return {
      messageCount: this.messageCount,
      errorCount: this.errorCount,
      reconnectCount: this.reconnectCount,
      avgLatency,
      maxLatency,
      minLatency,
      uptime
    }
  }

  // 上报统计数据
  reportStats() {
    const stats = this.getStats()
    // 这里可以添加上报逻辑
    console.log('WebSocket统计数据:', stats)
  }
}

// 创建WebSocket监控实例
export const websocketMonitor = new WebSocketMonitor()

3. 内存监控

// src/utils/memoryMonitor.ts
class MemoryMonitor {
  private memoryData: any[] = []
  private intervalId: number | null = null
  private interval: number = 5000 // 5秒

  // 开始监控
  start() {
    if ('memory' in performance) {
      this.intervalId = window.setInterval(() => {
        this.recordMemory()
      }, this.interval)
    }
  }

  // 停止监控
  stop() {
    if (this.intervalId) {
      clearInterval(this.intervalId)
      this.intervalId = null
    }
  }

  // 记录内存使用情况
  private recordMemory() {
    const memory = (performance as any).memory
    if (memory) {
      const data = {
        timestamp: Date.now(),
        usedJSHeapSize: memory.usedJSHeapSize,
        totalJSHeapSize: memory.totalJSHeapSize,
        jsHeapSizeLimit: memory.jsHeapSizeLimit
      }
      this.memoryData.push(data)
      // 只保留最近100条数据
      if (this.memoryData.length > 100) {
        this.memoryData.shift()
      }
      // 这里可以添加上报逻辑
      console.log('内存使用情况:', data)
    }
  }

  // 获取内存数据
  getMemoryData() {
    return this.memoryData
  }

  // 上报内存数据
  reportMemory() {
    const data = this.getMemoryData()
    // 这里可以添加上报逻辑
    console.log('内存数据:', data)
  }
}

// 创建内存监控实例
export const memoryMonitor = new MemoryMonitor()

实时聊天应用总结

  1. WebSocket集成

    • 实现了完整的WebSocket客户端
    • 支持自动重连和消息处理
    • 实现了消息类型定义和状态管理
  2. 文件上传处理

    • 单文件上传
    • 多文件上传
    • 断点续传功能
  3. 移动端适配

    • 响应式设计
    • 弹性布局
    • 触摸事件优化
    • 虚拟列表优化
  4. 性能监控

    • 应用性能监控
    • WebSocket性能监控
    • 内存监控

通过本项目,我们学习了如何使用Vue.js 3构建一个完整的实时聊天应用,包括WebSocket集成、文件上传处理、移动端适配和性能监控等方面。在实际开发中,我们应该根据项目需求选择合适的技术方案,并注重性能优化和用户体验。

« 上一篇 35-ecommerce-admin 下一篇 » 37-advanced-vue-features