271 lines
6.2 KiB
Vue
271 lines
6.2 KiB
Vue
<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>
|