💡 核心实现思路与技术要点
虚拟列表的实现涉及几个关键步骤:数据准备、位置预估、滚动计算和动态修正。
1. 数据准备与初始位置预估
数据结构化
为了方便组件内部管理和索引,原始数据需要被封装。
- 思路: 将原始数据包裹一层,添加一个唯一的内部索引
_index
。 - 关键实现:
formatVirtualList
函数创建IVirtualItem
结构,包含origin
(原始数据)和_index
。
占位与预估
在组件初次渲染和数据加载时,需要根据预估高度计算每个项目的位置,并撑起整个列表的滚动区域。
-
思路:
- 使用一个隐藏的 空块 (
empty-block
) 撑起整个列表的总高度,以确保滚动条的正确出现。 - 创建
positions
数组,用于存储每个列表项的height
、top
和bottom
位置信息。初始化时,所有项的height
均使用传入的props.itemHeight
(预估高度)。
- 使用一个隐藏的 空块 (
-
关键实现:
totalHeight
computed 属性:计算positions
数组中最后一项的bottom
值。setPositions
函数:初始化positions
数组,利用props.itemHeight
计算初始的top
和bottom
。
2. 确定渲染区域(窗口计算)
虚拟列表的关键是高效计算当前滚动位置下,需要渲染的列表项的起始和结束索引。
获取容器信息
- 思路: 在组件挂载后,获取滚动容器的实际高度,并计算出预估的可见列表项数量。
- 关键实现:
onMounted
钩子中,使用uni.createSelectorQuery()
获取.virtual-container
的高度 (screenHeight
),并计算visibleCount
。
高效查找起始索引
- 思路: 监听滚动事件,根据当前的
scrollTop
,快速定位第一个进入可视区域的列表项索引 (startIndex
)。 - 关键实现:
getStartIndex(scrollTop)
函数 :采用了二分查找法(Binary Search) 。在有序的positions
数组中查找第一个bottom
值大于scrollTop
的元素,这是性能优化的重要体现。
设置缓冲与渲染
-
思路: 在可视区域的
startIndex
之前和endIndex
之后各增加一个缓冲区(Buffer) ,以减少滚动时的白屏现象。 -
关键实现:
aboveCount
和belowCount
computed 属性:根据visibleCount
和props.buffCount
计算缓冲区的项目数量。renderList
computed 属性:使用slice()
截取包含缓冲区的完整数据。
3. 列表定位与滚动偏移
为了让渲染的内容与滚动条的位置保持一致,需要对渲染区域进行位移。
-
思路: 使用 CSS
transform: translateY()
将实际渲染的列表区域平移到正确的位置。 -
关键实现:
setOffet()
函数:- 计算
offsetY
的值。它应该等于startIndex
对应的top
值减去上方缓冲区的总高度。 - 这样做保证了即使渲染区域只是一部分,它在整个虚拟高度中仍然位于正确的垂直位置。
- 计算
4. 动态高度测量与修正(不定高核心)
这是实现不定高列表最复杂且最核心的部分。
测量实际高度
-
思路: 每次滚动后,都需要测量当前渲染的列表项的实际高度,以修正最初的预估值。
-
关键实现:
handleScroll
中,设置isMeasuring
标志位,并在nextTick
中调用updatePositions()
。updatePositions
函数:使用uni.createSelectorQuery().selectAll(".virtual-item").boundingClientRect()
获取当前渲染的 DOM 元素的实际尺寸。
修正位置数据
-
思路:
- 遍历测量结果,如果任何一项的实际高度与
positions
数组中记录的高度不一致(存在变化),则更新该项的height
。 - 一旦某一项的高度发生变化,其后续所有列表项 的
top
和bottom
值都会受到影响,必须全部重新计算(连锁更新)。
- 遍历测量结果,如果任何一项的实际高度与
-
关键实现:
updatePositions
中,通过循环遍历positions
数组,使用新的height
重新计算每个项目的top
和bottom
。- 修正后,必须再次调用
setOffet()
来修正渲染列表的垂直偏移量。
5. 滚动加载(Load More)
组件还集成了触底加载更多数据的能力。
-
思路: 监听滚动事件,判断当前滚动位置是否已接近列表底部预设的阈值。
-
关键实现:
handleScroll
中,判断逻辑为:totalHeight.value - (scrollTop + screenHeight.value) <= bottomThreshold
。- 如果触底,则触发
loadMore
事件,同时将loading
状态设为true
。 - 通过向父组件传递一个
done()
回调函数,允许父组件在数据加载完成后通知子组件,关闭loading
状态。
- 如果触底,则触发
源代码:
javascript
<template>
<scroll-view scroll-y="true" class="virtual-container" @scroll="handleScroll">
<view class="empty-block" :style="{ height: totalHeight + 'px' }"></view>
<view class="virtual-list" :style="{ transform: 'translateY(' + offsetY + 'px)' }">
<view
class="virtual-item"
v-for="item in renderList"
:id="item._index"
:key="item._index"
:data-index="item._index"
>
<slot name="default" :item="item.origin">
<view>{{item.origin}}</view>
</slot>
</view>
</view>
<view v-show="loading">
<slot name="loading">
<view class="loading-wrap">
<Loading />
</view>
</slot>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch, nextTick } from "vue";
import Loading from "@/components/loading/index.vue";
const props = defineProps({
dataSource: {
type: Array,
default: () => [],
},
// 预估高度对于不定高虚拟列表至关重要
itemHeight: {
type: Number,
default: 80,
},
buffCount: {
type: Number,
default: 1
}
});
const emits = defineEmits(['loadMore'])
interface IPositionItem {
height: number;
top: number;
bottom: number;
}
interface IVirtualItem {
origin: any;
_index: number;
}
const virtualList = ref<IVirtualItem[]>([]);
const positions = ref<IPositionItem[]>([]);
const screenHeight = ref(0);
const visibleCount = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const offsetY = ref(0);
const totalHeight = computed(() => {
return positions.value[positions.value.length - 1]?.bottom || 0;
});
const renderList = computed(() => {
return (virtualList.value || []).slice(startIndex.value - aboveCount.value, endIndex.value + belowCount.value);
});
const aboveCount = computed(() => {
return Math.min(startIndex.value, visibleCount.value * props.buffCount)
})
const belowCount = computed(() => {
return Math.min((virtualList.value || []).length - endIndex.value, visibleCount.value * props.buffCount)
})
// 计算所有项的预估位置信息
function setPositions(arr: any[]) {
if (!arr || !arr.length || !Array.isArray(arr)) return;
if (positions.value.length > 0) {
let currentTop = 0;
positions.value = arr.map((item, index) => {
const existingPos = positions.value[index];
const itemHeight = existingPos ? existingPos.height : props.itemHeight;
const newItem = {
height: itemHeight,
top: currentTop,
bottom: currentTop + itemHeight,
};
currentTop = newItem.bottom;
return newItem;
});
} else {
positions.value = arr.reduce((acc, item, index) => {
const itemHeight = props.itemHeight;
const newItem = {
height: itemHeight,
top: index * itemHeight,
bottom: (index + 1) * itemHeight,
};
acc.push(newItem);
return acc;
}, []);
}
}
function formatVirtualList(arr: any) {
if (!arr || !arr.length || !Array.isArray(arr)) return;
return arr.map((item, index) => {
return {
origin: item,
_index: index,
};
});
}
// 二分法查找startIndex
function getStartIndex(scrollTop: number) {
let left = 0;
let right = positions.value.length - 1;
let result = null;
while (left <= right) {
const middle = Math.floor(left + (right - left) / 2);
const middleValue = positions.value[middle].bottom;
if (middleValue === scrollTop) {
return middle + 1;
} else if (middleValue < scrollTop) {
left = middle + 1;
} else {
if (result == null || result > middle) {
result = middle;
}
right = middle - 1;
}
}
return result;
}
function setOffet() {
if(startIndex.value >= 1) {
const size = positions.value[startIndex.value - 1].bottom - positions.value[startIndex.value - aboveCount.value].top;
offsetY.value = positions.value[startIndex.value].top - size;
} else {
offsetY.value = 0;
}
}
let isMeasuring = false;
const bottomThreshold = 50; // 触底阈值
const loading = ref(false)
function handleScroll(e: any) {
const scrollTop = e.detail.scrollTop;
const newStart = getStartIndex(scrollTop) as number;
if (startIndex.value !== newStart) {
startIndex.value = newStart;
endIndex.value = startIndex.value + visibleCount.value;
setOffet();
}
if (!isMeasuring) {
isMeasuring = true;
nextTick(() => {
updatePositions();
setTimeout(() => {
isMeasuring = false;
}, 100);
});
}
if(totalHeight.value - (scrollTop + screenHeight.value) <= bottomThreshold && !loading.value && virtualList.value.length) {
loading.value = true;
emits('loadMore', () => {
loading.value = false;
})
}
}
function updatePositions() {
uni
.createSelectorQuery()
.selectAll(".virtual-item")
.boundingClientRect((res: any) => {
if (!res || res.length === 0) return;
let hasPositionChanged = false;
for (let i = 0; i < res.length; i++) {
const item = res[i];
const index = Number(item.id);
if (!positions.value[index]) continue;
const oldHeight = positions.value[index].height;
const newHeight = item.height;
const diff = newHeight - oldHeight;
if (Math.abs(diff) > 1) {
positions.value[index].height = newHeight;
hasPositionChanged = true;
}
}
if (hasPositionChanged) {
let currentTop = 0;
for (let j = 0; j < positions.value.length; j++) {
const item = positions.value[j];
item.top = currentTop;
item.bottom = currentTop + item.height;
currentTop = item.bottom;
}
setOffet();
}
})
.exec();
}
watch(
() => props.dataSource,
(nVal) => {
virtualList.value = formatVirtualList(nVal) as IVirtualItem[];
setPositions(nVal);
},
{
deep: true,
immediate: true,
}
);
onMounted(() => {
uni
.createSelectorQuery()
.select('.virtual-container')
.boundingClientRect((res:any) => {
screenHeight.value = res.height;
visibleCount.value = Math.ceil(screenHeight.value / props.itemHeight);
endIndex.value = startIndex.value + visibleCount.value;
})
.exec();
});
</script>
<style scoped lang="less">
.virtual-container {
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.virtual-list {
position: absolute;
left: 0;
right: 0;
top: 0;
will-change: transform;
}
.loading-wrap {
display: flex;
justify-content: center;
padding: 20rpx 0;
}
</style>
