我打开简书才发现我几年前也用react做过虚拟滚动的列表,但是之前是每项高度相等的,而且我看现实方式其实是比较繁杂的。
一、背景:
我的场景是一个AI助手的聊天窗口,消息数量无限制且消息内含虚拟表格等,上滑加载更多消息,也就是列表数量不固定,单条消息高度不固定。
其实虚拟滚动插件很多,但是我试了一下不是很符合我的场景,所以决定自己写一个,也是在deepSeek给的示例基础上优化的。
二、实现思路
1. 计算每个列表项的高度:
预加载新增的项,计算每个列表项的高度,并存储这些高度,再使用ResizeObserver监听元素高度变化,纠正为真实高度(大多数情况不会走到这一步)。
2. 计算可见区域:
根据滚动位置,计算出当前可见的列表项,这里做了一个小优化:第一项只要有一部分在可视区域内就展示,保证滚动的连续性。
3. 渲染可见项:
只渲染当前可见的列表项,而不是整个列表。
4. 缓存各项高度数据:
将数据缓存到本地,下次进来直接获取缓存,减少重复计算,提高性能。
5. 滚动到指定项:
上拉加载后让最后一条历史记录的位置不变,根据指定项的位置计算出scrollTop。
三、代码实现
index.vue
<template>
<div
ref="virtualScrollContainerRef"
class="virtual-scroll-container"
:style="{ height: viewportHeight > 0 ? viewportHeight + 'px' : '100%' }"
@scroll="handleScroll"
>
<div class="virtual-scroll-content" :style="{ height: totalHeight + 'px' }">
<div
v-for="item in visibleItems"
:key="item.messageId"
class="virtual-scroll-item"
:style="{ transform: `translateY(${item.offset}px)` }"
:data-id="item.messageId"
>
<slot :msg="item" :index="item.index"></slot>
</div>
</div>
</div>
<SetScrollItemHight v-model:itemHeights="itemHeights" :items="items">
<template #default="{ msg, index }">
<slot :msg="msg" :index="index"></slot>
</template>
</SetScrollItemHight>
</template>
<script setup lang="ts">
import SetScrollItemHight from './setScrollItemHight.vue'
const props = defineProps({
items: {
// 消息列表
type: Array<Message.ShowMessageType>,
default: () => []
},
scrollToItem: {
// 滚动到指定messageId的项
type: String,
default: ''
}
})
const emits = defineEmits(['setVirtualScrollContainerRef'])
// 使用缓存的高度,避免重复计算
const virtualScrollItemHeights = JSON.parse(
localStorage.getItem('virtualScrollItemHeights') || '{}'
)
const itemHeights = ref<any>(virtualScrollItemHeights) // 存储每个列表项的高度
const visibleItems = ref<any>([]) // 当前可见的列表项
const scrollTop = ref(0) // 当前滚动位置
const viewportHeight = ref(500) // 可视区域高度
const virtualScrollContainerRef = ref<any>(null)
// 各项高度转为数组格式方便计算
const itemHeightsArr = computed(() => {
return props.items.map(({ messageId }) => itemHeights.value[messageId])
})
const totalHeight = computed(() => itemHeightsArr.value.reduce((sum, height) => sum + height, 0)) // 列表总高度
const updateVisibleItems = () => {
let startIndex = 0
let endIndex = 0
let currentHeight = 0
// 找到第一个可见的项,scrollTop.value - itemHeightsArr.value[startIndex]保证第一项只要有一部分还在可视区域就展示
// (props.items.length - 1)保证至少有一个可见项
while (
startIndex < props.items.length - 1 &&
currentHeight < scrollTop.value - itemHeightsArr.value[startIndex]
) {
currentHeight += itemHeightsArr.value[startIndex]
startIndex++
}
// 找到最后一个可见的项
endIndex = startIndex
while (endIndex < props.items.length && currentHeight < scrollTop.value + viewportHeight.value) {
currentHeight += itemHeightsArr.value[endIndex]
endIndex++
}
// 更新可见项
visibleItems.value = props.items.slice(startIndex, endIndex).map((item: any, index) => {
const offset = itemHeightsArr.value
.slice(0, startIndex + index)
.reduce((sum, height) => sum + height, 0)
return {
...item,
offset
}
})
onItemObserver(visibleItems.value)
}
// 各项高度变化了更新显示项
watch(
() => itemHeights.value,
(val) => {
updateVisibleItems()
// 高度缓存起来
localStorage.setItem('virtualScrollItemHeights', JSON.stringify(val))
},
{
deep: true
}
)
// 列表数据变化,更新可见项
watch(
() => props.items,
() => {
nextTick(() => {
updateVisibleItems()
})
},
{
deep: true,
immediate: true
}
)
const handleScroll = (event) => {
scrollTop.value = event.target.scrollTop
updateVisibleItems()
}
/**
* 设置各项高度,只更新有变化的项
*/
const itemObserver = ref<any>(null)
let timer
// 监听元素高度变化,记录实际高度
itemObserver.value = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const _height = entry.contentRect.height
if (!_height) return
const { id } = (entry.target as any)?.dataset || {}
if (id && itemHeights.value[id] !== _height) {
itemHeights.value[id] = _height
}
})
})
const onItemObserver = (list) => {
if (timer) clearTimeout(timer)
// 等待元素加载完成再执行监听
timer = setTimeout(() => {
list?.forEach((item) => {
const element = document.querySelector(`[data-id="${item.messageId}"]`)
if (element) {
itemObserver.value.observe(element)
}
})
}, 25)
}
// 设置viewportHeight
const parentObserver = ref<any>(null)
// 监听父级元素的高度变化并更新
parentObserver.value = new ResizeObserver((entries) => {
for (let entry of entries) {
const { height } = entry.contentRect
viewportHeight.value = height
}
})
const setviewportHeight = () => {
const parentDom = document.getElementById('message-content-wrap')
viewportHeight.value = parentDom?.clientHeight ?? 500
if (parentDom) {
parentObserver.value.observe(parentDom)
}
}
onMounted(() => {
setviewportHeight()
emits('setVirtualScrollContainerRef', virtualScrollContainerRef.value)
})
onBeforeUnmount(() => {
parentObserver.value?.disconnect?.()
itemObserver.value?.disconnect?.()
if (timer) clearTimeout(timer)
})
// 滚动到指定项
const onScrollToItem = (messageId) => {
const cur: any = props.items?.find((item: any) => item.messageId === messageId)
if (cur) {
virtualScrollContainerRef.value.scrollTop = itemHeightsArr.value
.slice(0, cur.index)
.reduce((sum, height) => sum + height, 0)
}
}
watch(() => props.scrollToItem, onScrollToItem)
</script>
<style lang="scss" scoped>
.virtual-scroll-container {
height: 100%; /* 可视区域高度 */
overflow-y: auto;
}
.virtual-scroll-content {
position: relative;
padding: 10px 10px 0;
}
.virtual-scroll-item {
position: absolute;
width: calc(100% - 20px);
}
</style>
预加载并初始化虚拟列表单项高度:setScrollItemHight.vue
<template>
<teleport to="body">
<div class="hiddenDom" :style="{ width: `${offsetWidth - 5}px` }">
<div v-for="item in showItems" :key="item.messageId" ref="itemRefs" :data-id="item.messageId">
<slot :msg="item" :index="item.index"></slot>
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
/**
* 初始化虚拟列表单项高度
*/
const props = defineProps({
items: {
// 消息列表
type: Array<Message.ShowMessageType>,
default: () => []
},
itemHeights: {
type: Object,
default: () => ({})
}
})
const emits = defineEmits(['update:itemHeights'])
const myItemHeights = computed({
get() {
return props.itemHeights
},
set(val) {
emits('update:itemHeights', val)
}
})
// 只初始化首次加载的项
const showItems = computed(() => {
return props.items?.filter(({ messageId }) => !myItemHeights.value[messageId])
})
let timer: any
watch(
() => showItems.value,
(val) => {
if (val?.length) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
measureHeight()
}, 10)
}
},
{
deep: true,
immediate: true
}
)
const itemRefs = ref<any>([])
const measureHeight = () => {
itemRefs.value?.forEach((el) => {
myItemHeights.value[el.dataset.id] = el.offsetHeight
})
}
const offsetWidth = ref<any>(500)
onMounted(() => {
offsetWidth.value = document.getElementById('xiaolu-assistant')?.offsetWidth
})
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
</script>
<style lang="scss" scoped>
.hiddenDom {
position: absolute;
left: -9999px;
padding: 0 10px;
> div {
overflow: hidden;
}
}
</style>