perf(common): ⚡ add performance monitoring to optimize some performance issues

This commit is contained in:
Dawn
2025-10-31 08:09:01 +08:00
parent 966abeb2bf
commit 0c6c438f44
9 changed files with 218 additions and 15 deletions
+2 -1
View File
@@ -132,7 +132,8 @@
"vue-cropper": "1.1.4",
"vue-demi": "0.14.10",
"vue-router": "^4.6.3",
"vue-virtual-scroller": "2.0.0-beta.8"
"vue-virtual-scroller": "2.0.0-beta.8",
"web-vitals": "^5.1.0"
},
"devDependencies": {
"@biomejs/biome": "^2.2.7",
Generated Vendored
+8
View File
@@ -170,6 +170,9 @@ importers:
vue-virtual-scroller:
specifier: 2.0.0-beta.8
version: 2.0.0-beta.8(vue@3.5.22(typescript@5.9.3))
web-vitals:
specifier: ^5.1.0
version: 5.1.0
devDependencies:
'@biomejs/biome':
specifier: ^2.2.7
@@ -4678,6 +4681,9 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-vitals@5.1.0:
resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@@ -9146,6 +9152,8 @@ snapshots:
dependencies:
defaults: 1.0.4
web-vitals@5.1.0: {}
webpack-virtual-modules@0.6.2: {}
whatwg-mimetype@3.0.0: {}
+8 -4
View File
@@ -113,12 +113,16 @@ pub async fn page_msg(
.map_err(|e| e.to_string())?;
// 转换数据库模型为响应模型
let message_resps: Vec<MessageResp> = db_result
.list
.unwrap_or_default()
let mut raw_list = db_result.list.unwrap_or_default();
raw_list.sort_by(|a, b| {
let a_time = a.send_time.unwrap_or(0);
let b_time = b.send_time.unwrap_or(0);
a_time.cmp(&b_time)
});
let message_resps: Vec<MessageResp> = raw_list
.into_iter()
.map(|msg| convert_message_to_resp(msg, None))
.rev()
.collect();
Ok(CursorPageResp {
@@ -636,7 +636,6 @@ const handleChatAreaClick = (event: Event): void => {
// 防冲突的加载更多处理
const handleLoadMore = async (): Promise<void> => {
// 如果正在加载、已经触发了加载、或已到达最后一页,则不重复触发
console.log(chatStore.currentMessageOptions)
if (chatStore.currentMessageOptions?.isLoading || isLoadingMore.value || chatStore.currentMessageOptions?.isLast)
return
@@ -11,8 +11,10 @@
object-fit="cover"
show-toolbar-tooltip
preview-disabled
lazy
:intersection-observer-options="{ rootMargin: '256px' }"
style="border-radius: 8px; cursor: pointer !important"
:src="body?.url"
:src="displayImageSrc"
@dblclick="handleOpenImageViewer"
@click="handleOpenImage"
@error="handleImageError">
@@ -54,6 +56,7 @@ import { MsgEnum } from '@/enums'
import { useImageViewer } from '@/hooks/useImageViewer'
import type { ImageBody, MsgType } from '@/services/types'
import { isMobile } from '@/utils/PlatformConstants'
import { buildQiniuThumbnailUrl, getPreferredQiniuFormat } from '@/utils/QiniuImageUtils'
const ImagePreview = isMobile() ? defineAsyncComponent(() => import('@/mobile/components/ImagePreview.vue')) : void 0
@@ -67,6 +70,7 @@ const MAX_WIDTH = isMobile() ? 240 : 320
const MAX_HEIGHT = 240
const MIN_WIDTH = 60
const MIN_HEIGHT = 60
const THUMB_QUALITY = 60
// 错误状态控制
const isError = ref(false)
// 使用图片查看器hook
@@ -107,6 +111,23 @@ const handleOpenImageViewer = () => {
/**
* 计算图片样式
*/
const displayImageSrc = computed(() => {
const originalUrl = props.body?.url
if (!originalUrl) return ''
const deviceRatio = typeof window !== 'undefined' ? Math.max(window.devicePixelRatio || 1, 1) : 1
const thumbnailWidth = Math.ceil(MAX_WIDTH * Math.min(deviceRatio, 2))
const format = getPreferredQiniuFormat()
return (
buildQiniuThumbnailUrl(originalUrl, {
width: thumbnailWidth,
quality: THUMB_QUALITY,
format
}) ?? originalUrl
)
})
const imageStyle = computed(() => {
// 如果有原始尺寸,使用原始尺寸计算
let width = props.body?.width
+2
View File
@@ -7,11 +7,13 @@ import vSlide from '@/directives/v-slide.ts'
import router from '@/router'
import { pinia } from '@/stores'
import { initializePlatform } from '@/utils/PlatformConstants'
import { startWebVitalObserver } from '@/utils/WebVitalsObserver'
import { invoke } from '@tauri-apps/api/core'
import { isMobile } from '@/utils/PlatformConstants'
import App from '@/App.vue'
initializePlatform()
startWebVitalObserver()
import('@/services/webSocketAdapter')
if (process.env.NODE_ENV === 'development') {
+23 -8
View File
@@ -400,6 +400,14 @@ export const useGroupStore = defineStore(
}
}
// 校验合法的群聊/频道才会触发成员刷新
const isValidGroupRoom = (roomId?: string | null): roomId is string => {
if (!roomId) return false
if (roomId === '1') return true
const session = chatStore.getSession(roomId)
return session?.type === RoomTypeEnum.GROUP
}
/**
* 获取群成员列表
* @param roomId 群聊房间ID
@@ -407,6 +415,12 @@ export const useGroupStore = defineStore(
*/
const getGroupUserList = async (roomId: string, forceRefresh = false, keyword = '') => {
const trimmedKeyword = keyword.trim()
if (!isValidGroupRoom(roomId)) {
console.warn('[group] skip member refresh, invalid room id:', roomId)
return []
}
const cachedList = userListMap[roomId]
if (!trimmedKeyword && !forceRefresh && Array.isArray(cachedList) && cachedList.length > 0) {
@@ -474,12 +488,13 @@ export const useGroupStore = defineStore(
if (roomId === 'all') {
getRoomIdsByUid(uid).forEach((room) => {
if (room) refreshTargets.add(room)
if (isValidGroupRoom(room)) {
refreshTargets.add(room)
}
})
} else {
const targetRoomId = roomId || globalStore.currentSession?.roomId
if (!targetRoomId) {
console.warn('[updateUserItem] cannot determine target room id')
if (!isValidGroupRoom(targetRoomId)) {
return false
}
refreshTargets.add(targetRoomId)
@@ -489,8 +504,8 @@ export const useGroupStore = defineStore(
return false
}
refreshTargets.forEach((roomId) => {
getGroupUserList(roomId, true).catch((error) => {
refreshTargets.forEach((validRoomId) => {
getGroupUserList(validRoomId, true).catch((error) => {
console.error('[group] refresh members failed:', error)
})
})
@@ -512,7 +527,7 @@ export const useGroupStore = defineStore(
}
const targetRoomId = roomId || globalStore.currentSession?.roomId
if (!targetRoomId) {
if (!isValidGroupRoom(targetRoomId)) {
console.warn('[addUserItem] cannot determine target room id')
return false
}
@@ -537,7 +552,7 @@ export const useGroupStore = defineStore(
}
const targetRoomId = roomId || globalStore.currentSession?.roomId
if (!targetRoomId) {
if (!isValidGroupRoom(targetRoomId)) {
console.warn('[removeUserItem] cannot determine target room id')
return false
}
@@ -701,7 +716,7 @@ export const useGroupStore = defineStore(
}
// 检查当前房间是否包含指定的用户
const hasUser = userList.some((user) => user.uid === uid)
if (hasUser) {
if (hasUser && isValidGroupRoom(roomId)) {
roomIds.push(roomId)
}
})
+90
View File
@@ -0,0 +1,90 @@
const QINIU_HOST_KEYWORDS = ['cdn.hulaspark.com']
const isQiniuHost = (hostname: string) => {
const lowerHost = hostname.toLowerCase()
return QINIU_HOST_KEYWORDS.some((keyword) => lowerHost.includes(keyword))
}
export type QiniuImageFormat = 'webp' | 'avif'
export type QiniuThumbOptions = {
width?: number
height?: number
quality?: number
format?: QiniuImageFormat
}
export const buildQiniuThumbnailUrl = (url?: string | null, options: QiniuThumbOptions = {}): string | undefined => {
if (!url) return url ?? undefined
try {
const parsedUrl = new URL(url)
if (!isQiniuHost(parsedUrl.hostname)) {
return url
}
// 如果已经带有 imageView/imageMogr 等后处理参数,直接返回原链接
const existingQuery = parsedUrl.search.toLowerCase()
if (
existingQuery.includes('imageview') ||
existingQuery.includes('imagemogr') ||
existingQuery.includes('imageview2')
) {
return url
}
const { width, height, quality = 75 } = options
const segments = ['imageView2', '2']
if (width) segments.push('w', Math.round(width).toString())
if (height) segments.push('h', Math.round(height).toString())
if (quality) segments.push('q', Math.round(quality).toString())
if (options.format) segments.push('format', options.format)
const directive = segments.join('/')
const hasQuery = parsedUrl.search.length > 0
parsedUrl.search += `${hasQuery ? '&' : '?'}${directive}`
return parsedUrl.toString()
} catch {
return url
}
}
let preferredFormat: QiniuImageFormat | null | undefined
const canUseDataUrl = (type: string) => {
if (typeof document === 'undefined') return false
const canvas = document.createElement('canvas')
if (!canvas.getContext) return false
try {
return canvas.toDataURL(type).startsWith(`data:${type}`)
} catch {
return false
}
}
const detectPreferredFormat = (): QiniuImageFormat | undefined => {
try {
if (canUseDataUrl('image/avif')) {
return 'avif'
}
if (canUseDataUrl('image/webp')) {
return 'webp'
}
} catch {
// ignore detection errors
}
return void 0
}
export const getPreferredQiniuFormat = (): QiniuImageFormat | undefined => {
if (preferredFormat === null) {
return undefined
}
if (preferredFormat === undefined) {
preferredFormat = detectPreferredFormat() ?? null
}
return preferredFormat === null ? undefined : preferredFormat
}
+63
View File
@@ -0,0 +1,63 @@
import type { Metric } from 'web-vitals'
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals'
type WebVitalMetric =
| (Metric & { type: 'web-vital' })
| {
type: 'longtask'
name: string
startTime: number
duration: number
attribution?: Record<string, unknown>
}
type Reporter = (metric: WebVitalMetric) => void
const defaultReporter: Reporter = (metric) => {
const label = metric.type === 'web-vital' ? metric.name : 'longtask'
console.info('[performance]', label, metric)
}
let hasStarted = false
export const startWebVitalObserver = (reporter: Reporter = defaultReporter) => {
if (hasStarted || typeof window === 'undefined') return
hasStarted = true
const report = (metric: Metric) => {
reporter({
...metric,
type: 'web-vital'
})
}
onCLS(report, { reportAllChanges: true })
onFCP(report)
onINP(report, { reportAllChanges: true })
onLCP(report, { reportAllChanges: true })
onTTFB(report)
if (
'PerformanceObserver' in window &&
Array.isArray((PerformanceObserver as any).supportedEntryTypes) &&
(PerformanceObserver as any).supportedEntryTypes.includes('longtask')
) {
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
reporter({
type: 'longtask',
name: entry.name || 'longtask',
startTime: entry.startTime,
duration: entry.duration,
attribution: (entry as any).attribution
})
}
})
try {
observer.observe({ type: 'longtask', buffered: true })
} catch (error) {
console.warn('[performance] longtask observer failed:', error)
}
}
}