perf(component): ⚡ 优化虚拟列表滚动掉帧问题

移除消息气泡动画
This commit is contained in:
Dawn
2025-03-09 02:37:13 +08:00
parent 19d699b19f
commit 3bea8d9f02
6 changed files with 183 additions and 129 deletions
+4
View File
@@ -30,6 +30,10 @@
WeChat: <img src="https://img.shields.io/badge/cy2439646234-07C160?logo=wechat&logoColor=fff">
</p>
<p align="center">
☕️ server:<a href="https://github.com/HulaSpark/HuLa-Server">github HuLa-Server</a> | <a href="https://gitee.com/HulaSpark/HuLa-Server">gitee HuLa-Server</a>
</p>
<p align="center"><a href="README.zh-CN.md">中文</a> | English</p>
## Project Introduction
+1 -1
View File
@@ -31,7 +31,7 @@
</p>
<p align="center">
☕️ 服务端:<a href="https://github.com/HulaSpark/HuLa-Server">github HuLa-Server</a> | <a href="https://github.com/HulaSpark/HuLa-Server">gitee HuLa-Server</a>
☕️ 服务端:<a href="https://github.com/HulaSpark/HuLa-Server">github HuLa-Server</a> | <a href="https://gitee.com/HulaSpark/HuLa-Server">gitee HuLa-Server</a>
</p>
<p align="center"><a href="README.en.md">English</a> | 中文</p>
+148 -101
View File
@@ -17,6 +17,8 @@
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
const props = defineProps<{
items: any[]
estimatedItemHeight?: number
@@ -31,12 +33,12 @@ const emit = defineEmits<{
}>()
// 常量定义
const DEFAULT_ESTIMATED_HEIGHT = 80 // 默认预估的每项高度
const BUFFER_SIZE = props.buffer || 2 // 上下缓冲区域的数量
const OVERSCAN_SIZE = 100 // 预渲染区域的像素高度,防止滚动时出现空白
const MAX_CACHE_SIZE = 200 // 高度缓存的最大数量
const DEFAULT_ESTIMATED_HEIGHT = 90 // 默认预估的每项高度
const BUFFER_SIZE = props.buffer || 10 // 上下缓冲区域的数量
const OVERSCAN_SIZE = 1000 // 预渲染区域的像素高度,防止滚动时出现空白
const LOADING_OFFSET = 26 // 加载中需要的偏移量(26px是加载动画的高度)
const ESTIMATED_ITEM_HEIGHT = props.estimatedItemHeight || DEFAULT_ESTIMATED_HEIGHT // 每项的预估高度
const SCROLL_THROTTLE_DELAY = 16 // 滚动节流延迟时间(约60fps)
// 响应式引用
const containerRef = ref<HTMLElement | null>(null) // 容器元素引用
@@ -47,112 +49,64 @@ const isScrolling = ref(false) // 是否正在滚动中
const rafId = ref<number | null>(null) // requestAnimationFrame的ID
const lastScrollTop = ref(0) // 上次滚动位置
const consecutiveStaticFrames = ref(0) // 连续静止帧计数
const accumulatedHeights = ref<number[]>([]) // 缓存累积高度数组
// ResizeObserver 实例
const resizeObserver = ref<ResizeObserver | null>(null)
// 清理过期的高度缓存
const cleanupHeightCache = () => {
if (heights.value.size > MAX_CACHE_SIZE) {
// 获取所有键并按照最近使用时间排序
const keys = Array.from(heights.value.keys())
const visibleKeys = new Set(
props.items
.slice(Math.max(0, visibleRange.value.start - BUFFER_SIZE), visibleRange.value.end + BUFFER_SIZE + 1)
.map((item) => item.message?.id?.toString())
.filter(Boolean)
)
// 保留可见区域的缓存
const keysToDelete = keys.filter((key) => !visibleKeys.has(key))
const deleteCount = keysToDelete.length - MAX_CACHE_SIZE / 2
if (deleteCount > 0) {
for (const key of keysToDelete.slice(0, deleteCount)) {
heights.value.delete(key)
}
}
}
}
// 缓存上一次的可见数据,用于加载更多时保持稳定
const lastVisibleData = ref<any[]>([])
// 计算可见项目
const visibleData = computed(() => {
// 如果正在加载更多,保持当前可见项目不变
if (props.isLoadingMore) {
return lastVisibleData.value
}
// 根据可见范围切片并添加索引信息
return props.items.slice(visibleRange.value.start, visibleRange.value.end + 1).map((item, index) => ({
const data = props.items.slice(visibleRange.value.start, visibleRange.value.end + 1).map((item, index) => ({
...item,
_index: visibleRange.value.start + index // 添加真实索引,用于渲染
}))
// 缓存最后一次的可见数据
lastVisibleData.value = data
return data
})
// 计算列表总高度
// 计算列表总高度(优化版)
const totalHeight = computed(() => {
// 累加所有项目的高度,如果没有缓存则使用预估高度
// 使用缓存的累积高度数组的最后一个值
if (accumulatedHeights.value.length > 0) {
return accumulatedHeights.value[accumulatedHeights.value.length - 1]
}
// 如果没有缓存,才进行完整计算
return props.items.reduce((total, item) => {
return total + (heights.value.get(item.message?.id?.toString()) || ESTIMATED_ITEM_HEIGHT)
}, 0)
})
// 监听列表数据变化
watch(
() => props.items,
(newItems, oldItems) => {
// 如果列表完全重置,清空高度缓存
if (newItems.length === 0 || oldItems.length === 0) {
heights.value.clear()
}
// 数据变化时重新计算可见范围和更新高度
updateVisibleRange()
nextTick(() => {
updateItemHeight()
})
},
{ deep: true }
)
// 更新项目实际高度
const updateItemHeight = () => {
if (!containerRef.value) return
// 遍历可见项目,测量并缓存实际高度
for (const item of visibleData.value) {
const id = item.message?.id?.toString()
if (!id) continue
const el = document.getElementById(`item-${id}`)
if (el) {
const height = el.getBoundingClientRect().height
heights.value.set(id, height)
}
}
// 清理过期缓存
cleanupHeightCache()
// 非滚动状态下更新可见范围
if (!isScrolling.value) {
updateVisibleRange()
}
// 计算累积高度(仅在数据变化时更新)
const updateAccumulatedHeights = () => {
let totalHeight = 0
accumulatedHeights.value = props.items.map((item) => {
totalHeight += heights.value.get(item.message?.id?.toString()) || ESTIMATED_ITEM_HEIGHT
return totalHeight
})
}
// 根据滚动位置计算起始索引
// 根据滚动位置计算起始索引(优化版)
const getStartIndex = (scrollTop: number) => {
const accumulatedHeights: number[] = []
let totalHeight = 0
// 预计算累积高度 O(n),但只需要在列表数据变化时更新
props.items.forEach((item, index) => {
totalHeight += heights.value.get(item.message?.id?.toString()) || ESTIMATED_ITEM_HEIGHT
accumulatedHeights[index] = totalHeight
})
// 二分查找 O(log n)
let left = 0
let right = accumulatedHeights.length - 1
const target = scrollTop - OVERSCAN_SIZE
let left = 0
let right = accumulatedHeights.value.length - 1
// 使用缓存的累积高度进行二分查找
while (left <= right) {
const mid = Math.floor((left + right) / 2)
if (accumulatedHeights[mid] < target) {
if (accumulatedHeights.value[mid] < target) {
left = mid + 1
} else {
right = mid - 1
@@ -162,19 +116,15 @@ const getStartIndex = (scrollTop: number) => {
return Math.max(0, left - BUFFER_SIZE)
}
// 计算指定索引的偏移量
// 计算指定索引的偏移量(优化版)
const getOffsetForIndex = (index: number) => {
let total = 0
// 累加到目标索引前的所有项目高度
for (let i = 0; i < index; i++) {
const itemHeight = heights.value.get(props.items[i].message?.id?.toString()) || ESTIMATED_ITEM_HEIGHT
total += itemHeight
}
return total
if (index <= 0) return 0
if (index >= accumulatedHeights.value.length) return accumulatedHeights.value[accumulatedHeights.value.length - 1]
return accumulatedHeights.value[index - 1] || 0
}
// 更新可见范围
const updateVisibleRange = () => {
// 更新可见范围的节流版本
const updateVisibleRangeThrottled = useDebounceFn(() => {
if (!containerRef.value) return
const scrollTop = containerRef.value.scrollTop
@@ -197,13 +147,104 @@ const updateVisibleRange = () => {
// 更新可见范围和偏移量
visibleRange.value = { start, end }
// 加上加载中需要的偏移量
offset.value = getOffsetForIndex(start) + LOADING_OFFSET
}, SCROLL_THROTTLE_DELAY)
// 更新可见范围
const updateVisibleRange = () => {
updateVisibleRangeThrottled()
}
// 更新可见范围的帧动画处理
// 批量更新函数
const batchUpdate = () => {
// 更新累积高度缓存
updateAccumulatedHeights()
// 更新可见范围
updateVisibleRange()
// 更新实际高度
nextTick(() => {
updateItemHeight()
})
}
// 监听列表数据变化(优化版)
watch(
() => props.items,
(newItems, oldItems) => {
// 如果是加载更多,使用优化的更新策略
if (props.isLoadingMore) {
// 只更新累积高度缓存,其他更新推迟到加载完成后
updateAccumulatedHeights()
return
}
// 如果是完全重置或其他情况,执行完整更新
if (newItems.length === 0 || oldItems.length === 0) {
heights.value.clear()
}
// 使用批量更新
batchUpdate()
},
{ deep: false } // 移除深度监听,提高性能
)
// 监听加载状态变化
watch(
() => props.isLoadingMore,
(isLoading) => {
if (!isLoading) {
// 加载完成后执行一次完整的批量更新
nextTick(() => {
batchUpdate()
})
}
},
{ deep: false }
)
// 更新项目实际高度(优化版)
const updateItemHeight = () => {
if (!containerRef.value || props.isLoadingMore) return
let heightsUpdated = false
const updatedHeights = new Map()
// 批量收集高度更新
for (const item of visibleData.value) {
const id = item.message?.id?.toString()
if (!id) continue
const el = document.getElementById(`item-${id}`)
if (el) {
const height = el.getBoundingClientRect().height
if (heights.value.get(id) !== height) {
updatedHeights.set(id, height)
heightsUpdated = true
}
}
}
// 如果有高度更新,批量应用更新
if (heightsUpdated) {
// 批量更新高度缓存
updatedHeights.forEach((height, id) => {
heights.value.set(id, height)
})
// 更新累积高度缓存
updateAccumulatedHeights()
// 非滚动状态下更新可见范围
if (!isScrolling.value) {
updateVisibleRange()
}
}
}
// 更新可见范围的帧动画处理(优化版)
const updateFrame = () => {
if (!containerRef.value) return
if (!containerRef.value || props.isLoadingMore) return
const currentScrollTop = containerRef.value.scrollTop
@@ -217,7 +258,8 @@ const updateFrame = () => {
} else if (currentScrollTop > lastScrollTop.value) {
emit('scrollDirectionChange', 'down')
}
updateVisibleRange()
// 使用节流的可见范围更新
updateVisibleRangeThrottled()
lastScrollTop.value = currentScrollTop
} else {
// 滚动位置未变化,增加静止帧计数
@@ -227,7 +269,9 @@ const updateFrame = () => {
if (consecutiveStaticFrames.value >= 3) {
// 滚动结束,更新高度并停止动画
isScrolling.value = false
updateItemHeight()
if (!props.isLoadingMore) {
updateItemHeight()
}
if (rafId.value !== null) {
cancelAnimationFrame(rafId.value)
rafId.value = null
@@ -241,7 +285,7 @@ const updateFrame = () => {
}
// 滚动事件处理
const handleScroll = (event: Event) => {
const handleScroll = useDebounceFn((event: Event) => {
emit('scroll', event)
// 标记滚动状态并开始帧动画
@@ -252,15 +296,18 @@ const handleScroll = (event: Event) => {
rafId.value = requestAnimationFrame(updateFrame)
}
}
}
}, SCROLL_THROTTLE_DELAY)
onMounted(() => {
// 初始化累积高度缓存
updateAccumulatedHeights()
// 初始化可见范围
updateVisibleRange()
// 使用 ResizeObserver 监听容器大小变化
if (containerRef.value) {
resizeObserver.value = new ResizeObserver(() => {
updateAccumulatedHeights()
updateVisibleRange()
nextTick(() => {
updateItemHeight()
+26 -23
View File
@@ -45,7 +45,7 @@
ref="virtualListInst"
:items="chatMessageList"
:estimatedItemHeight="itemSize"
:buffer="2"
:buffer="10"
:isLoadingMore="messageOptions?.isLoading && isLoadingMore"
:isLast="messageOptions?.isLast"
@scroll.passive="handleScroll"
@@ -400,7 +400,7 @@
</template>
<script setup lang="ts">
import { EventEnum, MittEnum, MsgEnum, MessageStatusEnum } from '@/enums'
import { type MessageType, SessionItem } from '@/services/types.ts'
import { SessionItem } from '@/services/types.ts'
import { useMitt } from '@/hooks/useMitt.ts'
import { usePopover } from '@/hooks/usePopover.ts'
import { useWindow } from '@/hooks/useWindow.ts'
@@ -751,24 +751,27 @@ const jumpToReplyMsg = async (key: string) => {
* @param index 下标
* @param id 用户ID
*/
const addToDomUpdateQueue = (index: string, id: string) => {
// 使用 nextTick 确保虚拟列表渲染完最新的项目后进行滚动
nextTick(() => {
/** data-key标识的气泡,添加前缀用于区分用户消息,不然气泡动画会被覆盖 */
const dataKey = id === userUid.value ? `U${index}` : `Q${index}`
const lastMessageElement = document.querySelector(`[data-key="${dataKey}"]`) as HTMLElement
if (lastMessageElement) {
// 添加动画类
lastMessageElement.classList.add('bubble-animation')
// 监听动画结束事件
const handleAnimationEnd = () => {
lastMessageElement.classList.remove('bubble-animation')
lastMessageElement.removeEventListener('animationend', handleAnimationEnd)
}
lastMessageElement.addEventListener('animationend', handleAnimationEnd)
}
})
}
// const addToDomUpdateQueue = (index: string, id: string) => {
// // 使用 nextTick 确保虚拟列表渲染完最新的项目后进行滚动
// nextTick(() => {
// /** data-key标识的气泡,添加前缀用于区分用户消息,不然气泡动画会被覆盖 */
// const dataKey = id === userUid.value ? `U${index}` : `Q${index}`
// const lastMessageElement = document.querySelector(`[data-key="${dataKey}"]`) as HTMLElement
// console.log(dataKey, lastMessageElement)
// if (lastMessageElement) {
// console.log('触发气泡添加动画')
// // 添加动画类
// lastMessageElement.classList.add('bubble-animation')
// // 监听动画结束事件
// const handleAnimationEnd = () => {
// lastMessageElement.classList.remove('bubble-animation')
// lastMessageElement.removeEventListener('animationend', handleAnimationEnd)
// }
// lastMessageElement.addEventListener('animationend', handleAnimationEnd)
// }
// })
// }
/** 点击后滚动到底部 */
const scrollBottom = () => {
@@ -871,9 +874,9 @@ onMounted(async () => {
nextTick(() => {
virtualListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
})
useMitt.on(MittEnum.MESSAGE_ANIMATION, async (messageType: MessageType) => {
addToDomUpdateQueue(messageType.message.id, messageType.fromUser.uid)
})
// useMitt.on(MittEnum.MESSAGE_ANIMATION, (messageType: MessageType) => {
// addToDomUpdateQueue(messageType.message.id, messageType.fromUser.uid)
// })
useMitt.on(`${MittEnum.INFO_POPOVER}-Main`, (event: any) => {
selectKey.value = event.uid
infoPopover.value = true
+1 -1
View File
@@ -301,7 +301,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
// 先添加到消息列表 - 此时会显示本地预览
chatStore.pushMsg(tempMsg)
useMitt.emit(MittEnum.MESSAGE_ANIMATION, tempMsg)
// useMitt.emit(MittEnum.MESSAGE_ANIMATION, tempMsg)
console.log('👾临时消息:', tempMsg)
// 设置发送状态的定时器
+3 -3
View File
@@ -181,9 +181,9 @@ useMitt.on(WsResponseMessageType.MSG_RECALL, (data: RevokedMsgType) => {
})
useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
chatStore.pushMsg(data)
if (data.fromUser.uid !== userStore.userInfo.uid) {
useMitt.emit(MittEnum.MESSAGE_ANIMATION, data)
}
// if (data.fromUser.uid !== userStore.userInfo.uid) {
// useMitt.emit(MittEnum.MESSAGE_ANIMATION, data)
// }
// 接收到通知就设置图标闪烁
const username = useUserInfo(data.fromUser.uid).value.name!
// 不是自己发的消息才通知