topfans/frontend/components/VirtualList.vue
2026-04-07 23:08:49 +08:00

271 lines
6.2 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<scroll-view
class="virtual-list"
:scroll-y="true"
:show-scrollbar="false"
:scroll-top="scrollTopValue"
:lower-threshold="lowerThreshold"
:upper-threshold="upperThreshold"
@scroll="handleScroll"
@scrolltolower="handleScrollToLower"
@scrolltoupper="handleScrollToUpper"
:style="{ height: containerHeight }"
>
<!-- 占位容器撑开总高度 -->
<view :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 可见区域容器 -->
<view
class="visible-items"
:style="{
transform: `translateY(${offsetY}px)`,
position: 'absolute',
top: 0,
left: 0,
right: 0
}"
>
<slot
v-for="item in visibleItems"
:key="getItemKey(item)"
:item="item"
:index="item.__index"
></slot>
</view>
</view>
</scroll-view>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
const props = defineProps({
// 数据列表
items: {
type: Array,
required: true,
default: () => []
},
// 每项的高度rpx
itemHeight: {
type: Number,
required: true
},
// 容器高度
containerHeight: {
type: String,
default: '100%'
},
// 缓冲区项数(上下各渲染多少额外项)
bufferSize: {
type: Number,
default: 3
},
// 获取项的唯一key
keyField: {
type: String,
default: 'id'
},
// 滚动节流时间ms
throttleTime: {
type: Number,
default: 16 // 约60fps
}
})
const emit = defineEmits(['scroll', 'scrolltolower', 'scrolltoupper'])
// 滚动位置
const scrollTop = ref(0)
const scrollTopValue = ref(0)
// 节流控制
const lastScrollTime = ref(0)
const scrollTimer = ref(null)
// 初始化状态
const isInitialized = ref(false)
// 阈值设置(使用 px 单位)
const lowerThreshold = ref(100)
const upperThreshold = ref(100)
// 优化:缓存系统信息,避免重复调用 getSystemInfoSync()
const systemInfo = ref(null)
const rpxToPxRatio = ref(1)
// 初始化系统信息(只调用一次)
const initSystemInfo = () => {
if (!systemInfo.value) {
systemInfo.value = uni.getSystemInfoSync()
rpxToPxRatio.value = systemInfo.value.windowWidth / 750
}
}
// 转换rpx到px使用缓存的比例
const rpxToPx = (rpx) => {
return rpx * rpxToPxRatio.value
}
// 项高度px- 使用缓存的转换比例
const itemHeightPx = ref(0)
// 总高度px
const totalHeight = computed(() => {
return props.items.length * itemHeightPx.value
})
// 可视区域高度px
const viewportHeight = ref(0)
// 优化:修复初始化时序
onMounted(() => {
nextTick(() => {
// 初始化系统信息(只调用一次 getSystemInfoSync
initSystemInfo()
// 计算项高度(只计算一次)
itemHeightPx.value = rpxToPx(props.itemHeight)
// 设置阈值转换为px
lowerThreshold.value = rpxToPx(200)
upperThreshold.value = rpxToPx(200)
// 先使用默认值并标记初始化完成,避免阻塞渲染
viewportHeight.value = systemInfo.value.windowHeight * 0.8
isInitialized.value = true
// 异步获取实际容器高度(不阻塞初始化)
nextTick(() => {
const query = uni.createSelectorQuery()
query.select('.virtual-list').boundingClientRect((rect) => {
if (rect && rect.height > 0) {
viewportHeight.value = rect.height
}
}).exec()
})
})
})
// 可见项的起始索引
const startIndex = computed(() => {
if (!isInitialized.value || itemHeightPx.value === 0) return 0
const index = Math.floor(scrollTop.value / itemHeightPx.value) - props.bufferSize
return Math.max(0, index)
})
// 可见项的结束索引
const endIndex = computed(() => {
if (!isInitialized.value || itemHeightPx.value === 0) {
// 初始化时只渲染前几项
return Math.min(props.items.length, 10)
}
const visibleCount = Math.ceil(viewportHeight.value / itemHeightPx.value)
const index = startIndex.value + visibleCount + props.bufferSize * 2
return Math.min(props.items.length, index)
})
// 可见项列表
const visibleItems = computed(() => {
return props.items
.slice(startIndex.value, endIndex.value)
.map((item, index) => ({
...item,
__index: startIndex.value + index
}))
})
// 偏移量
const offsetY = computed(() => {
if (itemHeightPx.value === 0) return 0
return startIndex.value * itemHeightPx.value
})
// 优化:添加滚动节流
const handleScroll = (e) => {
const now = Date.now()
// 节流控制
if (now - lastScrollTime.value < props.throttleTime) {
// 清除之前的定时器
if (scrollTimer.value) {
clearTimeout(scrollTimer.value)
}
// 设置新的定时器,确保最后一次滚动事件被处理
scrollTimer.value = setTimeout(() => {
updateScrollPosition(e.detail.scrollTop)
emit('scroll', e)
scrollTimer.value = null
}, props.throttleTime)
return
}
lastScrollTime.value = now
updateScrollPosition(e.detail.scrollTop)
emit('scroll', e)
}
// 更新滚动位置
const updateScrollPosition = (newScrollTop) => {
scrollTop.value = newScrollTop
}
// 使用 provide 向子组件提供 scrollTop
import { provide } from 'vue'
provide('virtualListScrollTop', scrollTop)
// 暴露 scrollTop 给父组件使用
defineExpose({
scrollTop
})
// 优化:使用 scrolltolower 事件替代高频滚动检测
const handleScrollToLower = (e) => {
emit('scrolltolower', e)
}
// 优化:使用 scrolltoupper 事件替代高频滚动检测
const handleScrollToUpper = (e) => {
emit('scrolltoupper', e)
}
// 获取项的key
const getItemKey = (item) => {
return item[props.keyField] || item.__index
}
// 监听数据变化,重置滚动位置
watch(() => props.items, () => {
scrollTop.value = 0
scrollTopValue.value = 0
}, { deep: false })
// 清理定时器
const cleanup = () => {
if (scrollTimer.value) {
clearTimeout(scrollTimer.value)
scrollTimer.value = null
}
}
// 组件卸载时清理
import { onUnmounted } from 'vue'
onUnmounted(() => {
cleanup()
})
</script>
<style scoped>
.virtual-list {
width: 100%;
overflow: hidden;
}
.visible-items {
width: 100%;
}
</style>