实时聊天应用
实时聊天应用是现代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 touch3. 性能优化
虚拟列表
<!-- 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()实时聊天应用总结
WebSocket集成:
- 实现了完整的WebSocket客户端
- 支持自动重连和消息处理
- 实现了消息类型定义和状态管理
文件上传处理:
- 单文件上传
- 多文件上传
- 断点续传功能
移动端适配:
- 响应式设计
- 弹性布局
- 触摸事件优化
- 虚拟列表优化
性能监控:
- 应用性能监控
- WebSocket性能监控
- 内存监控
通过本项目,我们学习了如何使用Vue.js 3构建一个完整的实时聊天应用,包括WebSocket集成、文件上传处理、移动端适配和性能监控等方面。在实际开发中,我们应该根据项目需求选择合适的技术方案,并注重性能优化和用户体验。