mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
perf(chat-ui): 聊天媒体懒加载并补充资源加载埋点
- 为消息图片、视频缩略图、头像和引用缩略图接入懒加载 - 新增聊天媒体性能采集插件与通用 perf logger - 优化媒体占位并补充前端资源加载耗时埋点
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user