Skip to content

Commit d999153

Browse files
committed
perf(chat-ui): 聊天媒体懒加载并补充资源加载埋点
- 为消息图片、视频缩略图、头像和引用缩略图接入懒加载 - 新增聊天媒体性能采集插件与通用 perf logger - 优化媒体占位并补充前端资源加载耗时埋点
1 parent b33a9bd commit d999153

4 files changed

Lines changed: 361 additions & 19 deletions

File tree

frontend/components/chat/MessageContent.vue

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,17 @@
3030
</div>
3131
<div v-else-if="message.renderType === 'image'"
3232
class="max-w-sm">
33-
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
34-
<img v-if="message.imageUrl" :src="message.imageUrl" alt="图片" class="max-w-[240px] max-h-[240px] object-cover hover:opacity-90 transition-opacity">
33+
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
34+
<img
35+
v-if="message.imageUrl"
36+
v-chat-lazy-src="message.imageUrl"
37+
alt="图片"
38+
class="block min-w-[96px] min-h-[96px] max-w-[240px] max-h-[240px] object-cover bg-gray-100 hover:opacity-90 transition-opacity"
39+
loading="lazy"
40+
decoding="async"
41+
fetchpriority="low"
42+
v-chat-media-perf="{ kind: 'message-image', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', imageMd5: message.imageMd5 || '', imageFileId: message.imageFileId || '' } }"
43+
>
3544
<div v-else class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
3645
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
3746
{{ message.content }}
@@ -40,7 +49,16 @@
4049
</div>
4150
<div v-else-if="message.renderType === 'video'" class="max-w-sm">
4251
<div class="msg-radius overflow-hidden relative bg-black/5" @contextmenu="openMediaContextMenu($event, message, 'video')">
43-
<img v-if="message.videoThumbUrl" :src="message.videoThumbUrl" alt="视频" class="block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover">
52+
<img
53+
v-if="message.videoThumbUrl"
54+
v-chat-lazy-src="message.videoThumbUrl"
55+
alt="视频"
56+
class="block w-[220px] min-h-[120px] max-w-[260px] h-auto max-h-[260px] object-cover bg-gray-100"
57+
loading="lazy"
58+
decoding="async"
59+
fetchpriority="low"
60+
v-chat-media-perf="{ kind: 'message-video-thumb', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', videoThumbMd5: message.videoThumbMd5 || '', videoThumbFileId: message.videoThumbFileId || '' } }"
61+
>
4462
<div v-else class="px-3 py-2 text-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
4563
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
4664
{{ message.content }}
@@ -100,7 +118,15 @@
100118
</div>
101119
<div v-else-if="message.renderType === 'emoji'" class="max-w-sm flex items-center group" :class="message.isSent ? 'flex-row-reverse' : ''">
102120
<template v-if="message.emojiUrl">
103-
<img :src="message.emojiUrl" alt="表情" class="w-24 h-24 object-contain" @contextmenu="openMediaContextMenu($event, message, 'emoji')">
121+
<img
122+
v-chat-lazy-src="message.emojiUrl"
123+
alt="表情"
124+
class="w-24 h-24 object-contain"
125+
loading="lazy"
126+
decoding="async"
127+
fetchpriority="low"
128+
@contextmenu="openMediaContextMenu($event, message, 'emoji')"
129+
>
104130
<button
105131
v-if="shouldShowEmojiDownload(message)"
106132
class="text-xs px-2 py-1 rounded bg-white border border-gray-200 text-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
@@ -122,7 +148,7 @@
122148
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
123149
<span v-for="(seg, idx) in parseTextWithEmoji(message.content)" :key="idx">
124150
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
125-
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px">
151+
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" loading="lazy" decoding="async">
126152
</span>
127153
</div>
128154
<div
@@ -189,13 +215,15 @@
189215
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
190216
@click.stop="openImagePreview(message.quoteThumbUrl)"
191217
>
192-
<img
193-
:src="message.quoteThumbUrl"
194-
alt="引用链接缩略图"
195-
class="max-h-[49px] w-auto max-w-[98px] object-contain"
196-
loading="lazy"
197-
decoding="async"
198-
referrerpolicy="no-referrer"
218+
<img
219+
v-chat-lazy-src="message.quoteThumbUrl"
220+
alt="引用链接缩略图"
221+
class="max-h-[49px] w-auto max-w-[98px] object-contain"
222+
loading="lazy"
223+
decoding="async"
224+
fetchpriority="low"
225+
referrerpolicy="no-referrer"
226+
v-chat-media-perf="{ kind: 'quote-thumb', meta: { conversation: selectedContact?.username || '', messageId: message.id, quoteServerId: message.quoteServerId || '' } }"
199227
@error="onQuoteThumbError(message)"
200228
/>
201229
</div>
@@ -204,12 +232,14 @@
204232
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
205233
@click.stop="openImagePreview(message.quoteImageUrl)"
206234
>
207-
<img
208-
:src="message.quoteImageUrl"
209-
alt="引用图片"
210-
class="max-h-[49px] w-auto max-w-[98px] object-contain"
211-
loading="lazy"
212-
decoding="async"
235+
<img
236+
v-chat-lazy-src="message.quoteImageUrl"
237+
alt="引用图片"
238+
class="max-h-[49px] w-auto max-w-[98px] object-contain"
239+
loading="lazy"
240+
decoding="async"
241+
fetchpriority="low"
242+
v-chat-media-perf="{ kind: 'quote-image', meta: { conversation: selectedContact?.username || '', messageId: message.id, quoteServerId: message.quoteServerId || '' } }"
213243
@error="onQuoteImageError(message)"
214244
/>
215245
</div>

frontend/components/chat/MessageItem.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@
3131
<div class="w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
3232
<div v-if="message.avatar" class="w-full h-full">
3333
<img
34-
:src="message.avatar"
34+
v-chat-lazy-src="message.avatar"
3535
:alt="message.sender + '的头像'"
3636
class="w-full h-full object-cover"
37+
loading="lazy"
38+
decoding="async"
39+
fetchpriority="low"
3740
referrerpolicy="no-referrer"
41+
v-chat-media-perf="{ kind: 'message-avatar', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', senderUsername: message.senderUsername || '' } }"
3842
@error="onAvatarError($event, message)"
3943
>
4044
</div>

frontend/lib/chat/perf-logger.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
const roundPerfMs = (value) => {
2+
const numeric = Number(value)
3+
if (!Number.isFinite(numeric)) return null
4+
return Number(numeric.toFixed(1))
5+
}
6+
7+
const isDesktopShell = () => {
8+
if (typeof window === 'undefined') return false
9+
return !!window.wechatDesktop?.__brand
10+
}
11+
12+
export const nowPerfMs = () => {
13+
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
14+
return performance.now()
15+
}
16+
return Date.now()
17+
}
18+
19+
export const logPerfChannel = (channel, phase, details = {}) => {
20+
const payload = { ...details }
21+
if (isDesktopShell()) {
22+
try {
23+
window.wechatDesktop?.logDebug?.(channel, phase, payload)
24+
} catch {}
25+
}
26+
try {
27+
console.info(`[${channel}] ${phase}`, payload)
28+
} catch {}
29+
}
30+
31+
export const createPerfTrace = (channel, baseDetails = {}) => {
32+
const traceId = `${channel}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
33+
const startedAt = nowPerfMs()
34+
let lastAt = startedAt
35+
36+
return {
37+
id: traceId,
38+
log(phase, details = {}) {
39+
const now = nowPerfMs()
40+
const payload = {
41+
...baseDetails,
42+
...details,
43+
traceId,
44+
elapsedMs: roundPerfMs(now - startedAt),
45+
deltaMs: roundPerfMs(now - lastAt)
46+
}
47+
lastAt = now
48+
logPerfChannel(channel, phase, payload)
49+
return payload
50+
}
51+
}
52+
}
53+
54+
export const getLatestResourceTiming = (resourceUrl) => {
55+
const url = String(resourceUrl || '').trim()
56+
if (!url || typeof performance === 'undefined' || typeof performance.getEntriesByName !== 'function') {
57+
return {}
58+
}
59+
60+
try {
61+
const entries = performance.getEntriesByName(url)
62+
if (!entries?.length) return {}
63+
const entry = entries[entries.length - 1]
64+
return {
65+
resourceDurationMs: roundPerfMs(entry.duration),
66+
fetchStartMs: roundPerfMs(entry.fetchStart),
67+
responseEndMs: roundPerfMs(entry.responseEnd),
68+
transferSize: Number.isFinite(entry.transferSize) ? Number(entry.transferSize) : null,
69+
encodedBodySize: Number.isFinite(entry.encodedBodySize) ? Number(entry.encodedBodySize) : null,
70+
decodedBodySize: Number.isFinite(entry.decodedBodySize) ? Number(entry.decodedBodySize) : null,
71+
initiatorType: String(entry.initiatorType || '').trim()
72+
}
73+
} catch {
74+
return {}
75+
}
76+
}

0 commit comments

Comments
 (0)