代码只是练习,顺便记录思路,并非封装好的组件,需要根据实际情况调整参数
先尝试想象这样一个场景:
- 有一张2m长,10cm宽的纸竖着铺在墙上,这张纸上的字需要透过一种特殊的玻璃才能看到字
- 你手里有一个10cm长,10cm宽的特殊玻璃,把它放在纸的最顶部,然后依次往下滑动,就可以看到透过玻璃后面,纸上的文字了,而没有透过玻璃的其他区域,只是白纸没有字
- 就这样一路下滑,看完了整张纸
- 虚拟列表的原理和上述描述类似,白纸就是被撑起的container,玻璃就是数据渲染区,不透过玻璃的部分不会进行渲染。
理解了上述场景后,虚拟列表的原理可以简单概括为:
- 指定一个宽高固定的容器container,设置一个容纳数据列表且高度足够高的content撑起container,使container可以滑动(纸的大小)
- content内部放一个位置随滑动而变化的wrap(玻璃的大小),用来包裹渲染的数据,其位置会随着offsetTop(玻璃距离纸的顶部的距离)的变化而变化
- 最后设置好当前位置要展示哪些数据即可,根据index计算该位置需要展示哪几条数据
上代码
<!-- HTML部分 -->
<div id="vl-container" class="vf-container" :style="containerStyle" @scroll="onScroll">
<!-- vl-conetnt用来撑起高度 -->
<div id="vl-content" class="vf-content" :style="contentStyle">
<!-- vl-wrap用来定位列表 -->
<div class="vl-wrap" :style="{ transform: getTransform, fontSize:'28px',color:'#fff' }">
<div v-for="item in visibleData" :key="item.index" :style="{height: itemHeight+'px', backgroundColor: item.value % 2 === 0? '#345678' : '#123489'}">
Index{{ item.index }}
</div>
</div>
</div>
</div>
// js部分
import { ref, onMounted, computed } from 'vue'
export default {
setup(){
const itemCount = 100; // 数据的总数量
const itemHeight = 100; // 每一项的高度,固定为100px
const visibleHeight = 500; // 可视窗口高度
const visibleWidth = 200; // 可视窗口宽度
// 列表数据,模拟生成100条数据
let temp = []
for(let i=0; i<itemCount; i++){
temp.push({index:i, value:i+1})
}
let List = ref(temp)
// 外容器container样式
const containerStyle = {
position: 'relative',
width: visibleWidth + 'px',
height: visibleHeight + 'px',
overflow: 'auto',
backgroundColor: '#aefcdd'
};
// 100个元素撑起content的实际高度
const contentStyle = {
height: itemHeight * itemCount + 'px',
width: '100%',
};
let startOffset = ref(0) // 渲染的数据列表到顶部的距离
let startIndex = ref(0) // 可视区开始索引,从0开始
let downBufferEndIndex = ref(null) // 缓冲区结束索引
// 计算偏移量
const getTransform = computed(() => {
return `translate3d(0,${startOffset.value}px,0)`
})
// 计算可视化列表项数
const visibleItemCount = computed(() => {
return Math.ceil(visibleHeight / itemHeight)
})
// 计算虚拟列表数据
const visibleData = computed(() => {
// 开始位置-2,为了让顶部缓冲区存在2个列表项
return List.value.slice(Math.max(0, startIndex.value - 2), Math.min(itemCount, downBufferEndIndex.value))
})
const onScroll = (e) => {
// 当前滚动位置
const scrollTop = e.target.scrollTop
// 更新可视区开始索引
startIndex.value = Math.floor(scrollTop / itemHeight)
// 更新缓冲区结束索引,+2是为了底部缓冲区存在2个列表项
downBufferEndIndex.value = startIndex.value + visibleItemCount.value + 2
//此时的偏移量
if(startIndex.value - 2 >= 1){ // 因为保留顶部两个缓冲列表项,所以当滑动到第3个元素时,再开始更新位置
startOffset.value = scrollTop - (scrollTop % itemHeight) - 2*itemHeight;
}else if(startIndex.value == 0){ // 回到顶部时将位置归零
startOffset.value = 0
}
// 如果不保留顶部,则滑动时直接更新位置
// startOffset.value = scrollTop - (scrollTop % itemHeight)
}
onMounted(() => {
// 挂载后先更新索引位置,好截取出要渲染的列表项
startIndex.value = 0;
downBufferEndIndex.value = startIndex.value + visibleItemCount.value;
})
return {
containerStyle,
contentStyle,
itemHeight,
visibleData,
getTransform,
onScroll,
}
}