试想一下如果接口返回了上万条数据,前端要一次性渲染出来吗,可能会带来什么后果?
浏览器也许会直接“裂开”。如果是10万个 DOM 节点会占用巨量内存,滚动时卡顿得像幻灯片,甚至直接网页崩溃
核心本质是**‘局部渲染’**—它通过监听滚动事件,动态地维护一个只包含当前可见区域(及少量缓冲区)DOM 节点的‘窗口’
需要三层结构:
直接在 scroll 事件里做大量计算会导致 UI 线程阻塞。将计算逻辑包裹在 requestAnimationFrame 中,确保计算与浏览器刷新率同步。
为了防止快速滚动时出现白屏,通常在可视区的上下各多渲染 3-5 个节点。
<div ref="listRef" class="virtual-list-container" @scroll="onScroll">
<div class="virtual-list-phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="virtual-list-content" :style="{ transform: `translate3d(0, ${startOffset}px, 0)` }">
<div
v-for="item in visibleData"
:key="item._index"
class="virtual-list-item"
:style="{ height: itemHeight + 'px', lineHeight: itemHeight + 'px' }"
>
<slot :item="item.data" :index="item._index"></slot>
</div>
</div>
</div>//占位区高度为列表项高度 * 总数量
const listHeight = computed(() => {
return props.listData.length * props.itemHeight;
});
//可视区内容数量为可视高度 / 列表项高度
const visibleCount = computed(() => {
return Math.ceil(screenHeight.value / props.itemHeight);
});
//可视区数据截取
const visibleData = computed(() => {
//设置缓冲区,该buffer设置为5
const start = startIndex.value - props.buffer;
const end = endIndex.value + props.buffer;
const actualStart = start < 0 ? 0 : start;
const actualEnd = end > props.listData.length ? props.listData.length : end;
return props.listData.slice(actualStart, actualEnd).map((item, index) => ({
data: item,
_index: actualStart + index,
}));
});
//滚动事件添加RAF防抖
const onScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
if (!listRef.value) {
rafId = null;
return;
}
const scrollTop = listRef.value.scrollTop;
//计算开始及结束位置索引
startIndex.value = Math.floor(scrollTop / props.itemHeight);
endIndex.value = startIndex.value + visibleCount.value;
const start = startIndex.value - props.buffer;
const actualStart = start < 0 ? 0 : start;
startOffset.value = actualStart * props.itemHeight;
rafId = null;
});
};// 位置缓存,为每个列表项创建位置对象
let positions: Position[] = [];
// 初始化位置信息
const initPositions = () => {
positions = props.listData.map((_, index) => ({
index,
height: props.estimatedItemHeight,
top: index * props.estimatedItemHeight,
bottom: (index + 1) * props.estimatedItemHeight,
dValue: 0,
}));
listHeight.value = positions.length > 0 ? positions[positions.length - 1].bottom : 0;
};
// 二分查找:找到第一个 bottom > scrollTop 的元素索引,时间复杂度为O(log n),提高性能
const binarySearch = (list: Position[], value: number) => {
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while (start <= end) {
const midIndex = Math.floor((start + end) / 2);
const midValue = list[midIndex].bottom;
if (midValue === value) {
//因为是找bottom比scrollTop大的第一个元素,而不是找对应的那个元素,所以要返回midIndex + 1
return midIndex + 1;
} else if (midValue < value) {
start = midIndex + 1;
} else if (midValue > value) {
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex;
}
end = midIndex - 1;
}
}
return tempIndex ?? 0;
};
// 滚动处理(添加 rAF 防抖)
let rafId: number | null = null;
const onScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = null;
if (!viewportRef.value) return;
const scrollTop = viewportRef.value.scrollTop;
// 根据滚动位置计算startIndex和endIndex,此时的开始索引
startIndex.value = binarySearch(positions, scrollTop);
// 此时的结束索引 (根据当前视口高度计算)
const viewportHeight = viewportRef.value.clientHeight;
endIndex.value = binarySearch(positions, scrollTop + viewportHeight);
// 此时的偏移量
const start = startIndex.value - props.buffer;
const actualStart = start < 0 ? 0 : start;
// 修正:如果 actualStart 为 0,offset 应该为 0,否则取前一项的 bottom
// 或者直接取当前项的 top
if (positions.length > 0 && actualStart < positions.length) {
const position = positions[actualStart];
if (position) {
startOffset.value = actualStart >= 0 ? position.top : 0;
} else {
startOffset.value = 0;
}
} else {
startOffset.value = 0;
}
});
};
// 计算可视区域数据
const visibleData = computed(() => {
//添加缓冲区
const start = startIndex.value - props.buffer;
const end = endIndex.value + props.buffer;
const actualStart = start < 0 ? 0 : start;
const actualEnd = end > props.listData.length ? props.listData.length : end;
//渲染可视区域 + 缓冲区
return props.listData.slice(actualStart, actualEnd).map((item, index) => ({
data: item,
_index: actualStart + index,
}));
});