mirror of
https://github.com/HuLaSpark/HuLa.git
synced 2026-09-24 23:11:59 +08:00
perf(common): ⚡ add performance monitoring to optimize some performance issues
This commit is contained in:
Vendored
+2
-1
@@ -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",
|
||||
|
||||
+8
@@ -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: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user