perf(chat-ui): 聊天媒体懒加载并补充资源加载埋点

- 为消息图片、视频缩略图、头像和引用缩略图接入懒加载
- 新增聊天媒体性能采集插件与通用 perf logger
- 优化媒体占位并补充前端资源加载耗时埋点
This commit is contained in:
2977094657
2026-04-14 00:20:03 +08:00
Unverified
parent 839949a544
commit 7458762b2b
4 changed files with 361 additions and 19 deletions
+48 -18
View File
@@ -30,8 +30,17 @@
</div>
<div v-else-if="message.renderType === 'image'"
class="max-w-sm">
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
<img v-if="message.imageUrl" :src="message.imageUrl" alt="图片" class="max-w-[240px] max-h-[240px] object-cover hover:opacity-90 transition-opacity">
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
<img
v-if="message.imageUrl"
v-chat-lazy-src="message.imageUrl"
alt="图片"
class="block min-w-[96px] min-h-[96px] max-w-[240px] max-h-[240px] object-cover bg-gray-100 hover:opacity-90 transition-opacity"
loading="lazy"
decoding="async"
fetchpriority="low"
v-chat-media-perf="{ kind: 'message-image', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', imageMd5: message.imageMd5 || '', imageFileId: message.imageFileId || '' } }"
>
<div v-else class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
{{ message.content }}
@@ -40,7 +49,16 @@
</div>
<div v-else-if="message.renderType === 'video'" class="max-w-sm">
<div class="msg-radius overflow-hidden relative bg-black/5" @contextmenu="openMediaContextMenu($event, message, 'video')">
<img v-if="message.videoThumbUrl" :src="message.videoThumbUrl" alt="视频" class="block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover">
<img
v-if="message.videoThumbUrl"
v-chat-lazy-src="message.videoThumbUrl"
alt="视频"
class="block w-[220px] min-h-[120px] max-w-[260px] h-auto max-h-[260px] object-cover bg-gray-100"
loading="lazy"
decoding="async"
fetchpriority="low"
v-chat-media-perf="{ kind: 'message-video-thumb', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', videoThumbMd5: message.videoThumbMd5 || '', videoThumbFileId: message.videoThumbFileId || '' } }"
>
<div v-else class="px-3 py-2 text-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
{{ message.content }}
@@ -100,7 +118,15 @@
</div>
<div v-else-if="message.renderType === 'emoji'" class="max-w-sm flex items-center group" :class="message.isSent ? 'flex-row-reverse' : ''">
<template v-if="message.emojiUrl">
<img :src="message.emojiUrl" alt="表情" class="w-24 h-24 object-contain" @contextmenu="openMediaContextMenu($event, message, 'emoji')">
<img
v-chat-lazy-src="message.emojiUrl"
alt="表情"
class="w-24 h-24 object-contain"
loading="lazy"
decoding="async"
fetchpriority="low"
@contextmenu="openMediaContextMenu($event, message, 'emoji')"
>
<button
v-if="shouldShowEmojiDownload(message)"
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 @@
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
<span v-for="(seg, idx) in parseTextWithEmoji(message.content)" :key="idx">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px">
<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">
</span>
</div>
<div
@@ -189,13 +215,15 @@
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
@click.stop="openImagePreview(message.quoteThumbUrl)"
>
<img
:src="message.quoteThumbUrl"
alt="引用链接缩略图"
class="max-h-[49px] w-auto max-w-[98px] object-contain"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
<img
v-chat-lazy-src="message.quoteThumbUrl"
alt="引用链接缩略图"
class="max-h-[49px] w-auto max-w-[98px] object-contain"
loading="lazy"
decoding="async"
fetchpriority="low"
referrerpolicy="no-referrer"
v-chat-media-perf="{ kind: 'quote-thumb', meta: { conversation: selectedContact?.username || '', messageId: message.id, quoteServerId: message.quoteServerId || '' } }"
@error="onQuoteThumbError(message)"
/>
</div>
@@ -204,12 +232,14 @@
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
@click.stop="openImagePreview(message.quoteImageUrl)"
>
<img
:src="message.quoteImageUrl"
alt="引用图片"
class="max-h-[49px] w-auto max-w-[98px] object-contain"
loading="lazy"
decoding="async"
<img
v-chat-lazy-src="message.quoteImageUrl"
alt="引用图片"
class="max-h-[49px] w-auto max-w-[98px] object-contain"
loading="lazy"
decoding="async"
fetchpriority="low"
v-chat-media-perf="{ kind: 'quote-image', meta: { conversation: selectedContact?.username || '', messageId: message.id, quoteServerId: message.quoteServerId || '' } }"
@error="onQuoteImageError(message)"
/>
</div>
+5 -1
View File
@@ -31,10 +31,14 @@
<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 }]">
<div v-if="message.avatar" class="w-full h-full">
<img
:src="message.avatar"
v-chat-lazy-src="message.avatar"
:alt="message.sender + '的头像'"
class="w-full h-full object-cover"
loading="lazy"
decoding="async"
fetchpriority="low"
referrerpolicy="no-referrer"
v-chat-media-perf="{ kind: 'message-avatar', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', senderUsername: message.senderUsername || '' } }"
@error="onAvatarError($event, message)"
>
</div>
+76
View File
@@ -0,0 +1,76 @@
const roundPerfMs = (value) => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return null
return Number(numeric.toFixed(1))
}
const isDesktopShell = () => {
if (typeof window === 'undefined') return false
return !!window.wechatDesktop?.__brand
}
export const nowPerfMs = () => {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now()
}
return Date.now()
}
export const logPerfChannel = (channel, phase, details = {}) => {
const payload = { ...details }
if (isDesktopShell()) {
try {
window.wechatDesktop?.logDebug?.(channel, phase, payload)
} catch {}
}
try {
console.info(`[${channel}] ${phase}`, payload)
} catch {}
}
export const createPerfTrace = (channel, baseDetails = {}) => {
const traceId = `${channel}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const startedAt = nowPerfMs()
let lastAt = startedAt
return {
id: traceId,
log(phase, details = {}) {
const now = nowPerfMs()
const payload = {
...baseDetails,
...details,
traceId,
elapsedMs: roundPerfMs(now - startedAt),
deltaMs: roundPerfMs(now - lastAt)
}
lastAt = now
logPerfChannel(channel, phase, payload)
return payload
}
}
}
export const getLatestResourceTiming = (resourceUrl) => {
const url = String(resourceUrl || '').trim()
if (!url || typeof performance === 'undefined' || typeof performance.getEntriesByName !== 'function') {
return {}
}
try {
const entries = performance.getEntriesByName(url)
if (!entries?.length) return {}
const entry = entries[entries.length - 1]
return {
resourceDurationMs: roundPerfMs(entry.duration),
fetchStartMs: roundPerfMs(entry.fetchStart),
responseEndMs: roundPerfMs(entry.responseEnd),
transferSize: Number.isFinite(entry.transferSize) ? Number(entry.transferSize) : null,
encodedBodySize: Number.isFinite(entry.encodedBodySize) ? Number(entry.encodedBodySize) : null,
decodedBodySize: Number.isFinite(entry.decodedBodySize) ? Number(entry.decodedBodySize) : null,
initiatorType: String(entry.initiatorType || '').trim()
}
} catch {
return {}
}
}
+232
View File
@@ -0,0 +1,232 @@
import { createPerfTrace, getLatestResourceTiming } from '~/lib/chat/perf-logger'
const CHAT_LAZY_SRC_EVENT = 'chat-lazy-src:start'
const CHAT_LAZY_ROOT_MARGIN = '240px 0px 520px 0px'
const nextRenderTick = (callback) => {
if (typeof window === 'undefined') {
setTimeout(callback, 0)
return
}
if (typeof window.requestAnimationFrame !== 'function') {
window.setTimeout(callback, 0)
return
}
window.requestAnimationFrame(() => {
window.setTimeout(callback, 0)
})
}
const readImageSrc = (element) => {
return String(
element?.currentSrc
|| element?.getAttribute?.('src')
|| element?.src
|| ''
).trim()
}
const normalizeBindingValue = (value) => {
if (!value || typeof value !== 'object') {
return { kind: 'image', meta: {} }
}
return {
kind: String(value.kind || 'image').trim() || 'image',
meta: value.meta && typeof value.meta === 'object' ? { ...value.meta } : {}
}
}
const ensurePerfState = (element) => {
if (!element.__chatMediaPerfState) {
element.__chatMediaPerfState = {
src: '',
trace: null,
finalized: true,
onLoad: null,
onError: null,
onLazyStart: null
}
}
return element.__chatMediaPerfState
}
const normalizeLazySrc = (value) => {
if (value == null) return ''
if (typeof value === 'string') return value.trim()
if (typeof value === 'object') return String(value.src || '').trim()
return String(value || '').trim()
}
const ensureLazySrcState = (element) => {
if (!element.__chatLazySrcState) {
element.__chatLazySrcState = {
src: '',
loadedSrc: '',
observer: null,
timer: null
}
}
return element.__chatLazySrcState
}
const cleanupLazySrcObserver = (element) => {
const state = element?.__chatLazySrcState
if (!state) return
if (state.observer) {
try { state.observer.disconnect() } catch {}
state.observer = null
}
if (state.timer) {
try { clearTimeout(state.timer) } catch {}
state.timer = null
}
}
const applyLazySrc = (element, reason = '') => {
const state = element?.__chatLazySrcState
const src = String(state?.src || '').trim()
if (!element || !src) return
if (state.loadedSrc === src && readImageSrc(element) === src) return
state.loadedSrc = src
element.setAttribute('src', src)
try {
element.dispatchEvent(new CustomEvent(CHAT_LAZY_SRC_EVENT, {
detail: { src, reason }
}))
} catch {}
}
const updateLazySrc = (element, binding, reason = '') => {
const state = ensureLazySrcState(element)
const nextSrc = normalizeLazySrc(binding?.value)
cleanupLazySrcObserver(element)
state.src = nextSrc
if (!nextSrc) {
state.loadedSrc = ''
try { element.removeAttribute('src') } catch {}
return
}
if (state.loadedSrc !== nextSrc || readImageSrc(element) !== nextSrc) {
state.loadedSrc = ''
try { element.removeAttribute('src') } catch {}
try { element.setAttribute('data-chat-lazy-src', nextSrc) } catch {}
}
if (typeof window === 'undefined' || typeof window.IntersectionObserver !== 'function') {
state.timer = setTimeout(() => applyLazySrc(element, `${reason}:fallback`), 0)
return
}
state.observer = new window.IntersectionObserver((entries) => {
const entry = entries?.[0]
if (!entry?.isIntersecting) return
cleanupLazySrcObserver(element)
applyLazySrc(element, `${reason}:intersect`)
}, {
root: null,
rootMargin: CHAT_LAZY_ROOT_MARGIN,
threshold: 0.01
})
state.observer.observe(element)
}
const finalizeTracking = (element, status, reason = '') => {
const state = element?.__chatMediaPerfState
if (!state?.trace || state.finalized) return
const currentSrc = readImageSrc(element) || state.src
state.trace.log(status === 'load' ? 'resource:load' : 'resource:error', {
reason,
currentSrc,
complete: !!element?.complete,
naturalWidth: Number(element?.naturalWidth || 0),
naturalHeight: Number(element?.naturalHeight || 0),
...getLatestResourceTiming(currentSrc)
})
state.finalized = true
}
const beginTracking = (element, binding, reason = '') => {
const state = ensurePerfState(element)
const src = readImageSrc(element)
if (!src) return
if (state.src === src && state.trace && !state.finalized) return
const { kind, meta } = normalizeBindingValue(binding?.value)
state.src = src
state.finalized = false
state.trace = createPerfTrace('chat-media-ui', {
kind,
src,
...meta
})
state.trace.log('resource:start', {
reason,
complete: !!element?.complete,
loading: String(element?.getAttribute?.('loading') || '').trim(),
decoding: String(element?.getAttribute?.('decoding') || '').trim()
})
if (element?.complete) {
nextRenderTick(() => finalizeTracking(element, 'load', 'complete-sync'))
}
}
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('chat-media-perf', {
mounted(element, binding) {
const state = ensurePerfState(element)
state.onLoad = () => finalizeTracking(element, 'load', 'load-event')
state.onError = () => finalizeTracking(element, 'error', 'error-event')
state.onLazyStart = () => beginTracking(element, binding, 'lazy-src')
element.addEventListener('load', state.onLoad)
element.addEventListener('error', state.onError)
element.addEventListener(CHAT_LAZY_SRC_EVENT, state.onLazyStart)
beginTracking(element, binding, 'mounted')
},
updated(element, binding) {
const state = ensurePerfState(element)
const nextSrc = readImageSrc(element)
if (!nextSrc) return
if (nextSrc !== state.src) {
beginTracking(element, binding, 'updated-src')
return
}
if (element?.complete && !state.finalized) {
nextRenderTick(() => finalizeTracking(element, 'load', 'updated-complete'))
}
},
beforeUnmount(element) {
const state = element?.__chatMediaPerfState
if (state?.onLoad) element.removeEventListener('load', state.onLoad)
if (state?.onError) element.removeEventListener('error', state.onError)
if (state?.onLazyStart) element.removeEventListener(CHAT_LAZY_SRC_EVENT, state.onLazyStart)
if (state?.trace && !state.finalized) {
finalizeTracking(element, element?.complete ? 'load' : 'error', 'before-unmount')
}
delete element.__chatMediaPerfState
}
})
nuxtApp.vueApp.directive('chat-lazy-src', {
mounted(element, binding) {
updateLazySrc(element, binding, 'mounted')
},
updated(element, binding) {
const state = ensureLazySrcState(element)
const nextSrc = normalizeLazySrc(binding?.value)
if (nextSrc === state.src && (state.loadedSrc === nextSrc || !readImageSrc(element))) {
return
}
updateLazySrc(element, binding, 'updated')
},
beforeUnmount(element) {
cleanupLazySrcObserver(element)
delete element.__chatLazySrcState
}
})
})