在前端开发中,处理海量数据列表是一个常见的性能挑战。当列表项成千上万时,直接渲染所有 DOM 节点会导致页面卡顿甚至崩溃。虚拟列表(Virtual List) 是解决这一问题的最佳实践,它只渲染可视区域内的列表项,大大减少了 DOM 节点数量,从而实现丝滑般的滚动体验。
本文将手把手教你如何从零开始,实现一个功能完备、支持可变高度的高性能虚拟列表组件。
核心思想:只渲染"看得见"的部分
虚拟列表的核心思想非常直观: "用空间换时间" 。
它通过计算和只渲染当前用户可见区域内的列表项,同时利用一个大高度的空白占位元素(empty-block
)来模拟完整列表的滚动条,从而欺骗浏览器,让用户以为整个列表都在页面上。
我们的实现将包含以下几个关键步骤:
- 确定可视区域:通过监听滚动事件,动态计算当前可视区域的起始和结束索引。
- 数据裁剪与渲染:根据可视区域的索引,从完整数据中截取出一部分,并将其渲染到页面上。
- 动态定位 :利用
transform: translateY()
属性,将渲染的列表块精确地定位到正确的位置。 - 可变高度处理:这是难点,我们需要一个数据结构来动态存储和更新每个列表项的实际高度和位置。
代码实现与核心逻辑剖析
以下是虚拟列表组件的完整代码。我将通过注释和分段讲解,带你深入理解每一个细节。
📜 组件模板 (template
)
组件的 DOM 结构非常简洁,主要由三个部分组成:
.virtual-content
:承载所有内容的容器,负责监听滚动事件。.empty-block
:一个巨大的空白占位元素,它的高度等于所有列表项的总高度,用于撑起滚动条。.virtual-list
:实际渲染列表项的容器,通过transform: translateY()
实现精准定位。
HTML
ini
<template>
<div class="virtual-content" ref="screenRef" @scroll="scrollEvent">
<div
class="empty-block"
:style="{ height: virtualTotalHeight + 'px' }"
></div>
<div
class="virtual-list"
ref="listRef"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div v-for="item in visibleList" :key="item._index" :id="item._index">
<slot :item="item.item"></slot>
</div>
</div>
</div>
</template>
🧩 组件逻辑 (script
)
核心数据管理
我们使用几个 ref
和 computed
变量来管理组件的状态。
virtualList
:完整的列表数据,我们为每个列表项添加一个_index
属性,用于唯一标识。positions
:这是实现可变高度的关键 。它是一个数组,记录了每个列表项的预估/实际高度 (height
)、顶部距离 (top
) 和底部距离 (bottom
)。visibleCount
:可视区域内可以容纳的列表项数量。startIndex
/endIndex
:当前可视区域的起始和结束索引。offsetY
:列表容器的transform: translateY
偏移量。
JavaScript
ini
// ... 省略部分代码
interface VirtualListItem {
_index: number;
item: any;
}
interface PositionItem {
height: number;
top: number;
bottom: number;
}
const virtualList = ref<VirtualListItem[]>([]);
const screenHeight = ref(0);
const screenRef = ref();
const listRef = ref();
const positions = ref<PositionItem[]>([]);
const visibleCount = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const offsetY = ref(0);
// 监听父组件传入的列表,初始化虚拟列表和位置信息
watch(
() => props.list,
(value: any[]) => {
virtualList.value = value.map((item, index) => {
return {
_index: index,
item,
};
});
// 初始化 positions 数组,使用预估高度
positions.value = initPositions(virtualList.value);
},
{ immediate: true, deep: true }
);
// 计算所有列表项的总高度
const virtualTotalHeight = ref(0);
// 计算当前应渲染的可见列表,并加上缓冲区域
const visibleList = computed(() => {
return virtualList.value.slice(
startIndex.value - aboveCount.value,
endIndex.value + belowCount.value
);
});
// 计算底部缓冲区的数量
const belowCount = computed(() => {
return Math.min(
virtualList.value.length - endIndex.value,
Math.floor(props.bufferScale * visibleCount.value)
);
});
// 计算顶部缓冲区的数量
const aboveCount = computed(() => {
return Math.min(
startIndex.value,
Math.floor(props.bufferScale * visibleCount.value)
);
});
核心函数解析
-
initPositions
:初始化预估位置- 在初次渲染时,我们不知道每个列表项的实际高度。
- 这个函数根据
estimatedItemSize
(预估高度)来初始化positions
数组,为每个列表项计算一个预估的top
和bottom
值。
-
binarySearch
:二分查找优化- 这是一个非常重要的优化点。
- 当滚动时,我们需要快速找到当前可视区域的第一个列表项的索引。
binarySearch
函数通过二分查找,根据当前的scrollTop
值,在positions
数组中高效地找到对应的startIndex
。这比线性遍历快得多。
-
updateItemsSize
:动态更新实际高度- 当
visibleList
渲染到 DOM 后,我们可以获取每个列表项的实际高度。 - 这个函数在
onUpdated
生命周期钩子中被调用。 - 它会遍历所有可见的 DOM 节点,获取它们的
clientHeight
。 - 然后,它会比较实际高度和
positions
中记录的高度,如果存在差异,就会更新该列表项的height
、top
和bottom
,并级联更新它之后的所有列表项的位置信息。
- 当
-
scrollEvent
:滚动事件处理- 当用户滚动时,这个函数被触发。
- 它获取
scrollTop
,并使用binarySearch
找到startIndex
。 - 然后,计算
endIndex
,最后调用setOffsetY
来定位列表。
-
setOffsetY
:设置列表偏移量- 这个函数根据
startIndex
和缓冲区的数量,计算出offsetY
的值。 offsetY
决定了.virtual-list
容器的transform: translateY
偏移量,从而确保列表项能够准确地显示在可视区域。
- 这个函数根据
-
生命周期钩子 (
onMounted
,onUpdated
)onMounted
:组件挂载后,获取容器的实际高度,并计算visibleCount
和初始的endIndex
。onUpdated
:在数据更新后(例如visibleList
变化导致 DOM 重新渲染),nextTick
确保 DOM 更新完毕,然后调用updateItemsSize
来更新列表项的实际高度和位置信息,并重新计算总高度和偏移量。
完整代码
ini
import { ref, watch, onMounted, computed, onUpdated, nextTick } from "vue";
import { cloneDeep } from "lodash-es";
const props = defineProps({
list: {
type: Array,
default: () => [],
},
estimatedItemSize: {
type: Number,
default: 100,
},
bufferScale: {
type: Number,
default: 0.5,
},
});
const emits = defineEmits(["loadmore"]);
interface VirtualListItem {
_index: number;
item: any;
}
interface PositionItem {
height: number;
top: number;
bottom: number;
}
const virtualList = ref<VirtualListItem[]>([]);
const screenHeight = ref(0);
const screenRef = ref();
const listRef = ref();
const positions = ref<PositionItem[]>([]);
const visibleCount = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const offsetY = ref(0);
watch(
() => props.list,
(value: any[]) => {
virtualList.value = value.map((item, index) => {
return {
_index: index,
item,
};
});
positions.value = initPositions(virtualList.value);
},
{ immediate: true, deep: true }
);
const virtualTotalHeight = ref(0);
const visibleList = computed(() => {
return virtualList.value.slice(
startIndex.value - aboveCount.value,
endIndex.value + belowCount.value
);
});
const belowCount = computed(() => {
return Math.min(
virtualList.value.length - endIndex.value,
Math.floor(props.bufferScale * visibleCount.value)
);
});
const aboveCount = computed(() => {
return Math.min(
startIndex.value,
Math.floor(props.bufferScale * visibleCount.value)
);
});
function initPositions(list: any[]) {
return list.map((item: VirtualListItem) => {
return {
height: props.estimatedItemSize,
top: item._index * props.estimatedItemSize,
bottom: (item._index + 1) * props.estimatedItemSize,
};
});
}
const binarySearch = (list: PositionItem[], target: number) => {
let left = 0;
let right = list.length - 1;
let tempIndex = null;
while (left <= right) {
let midIndex = Math.floor((left + right) / 2);
let midValue = list[midIndex].bottom;
if (midValue === target) {
return midIndex + 1;
} else if (midValue < target) {
left = midIndex + 1;
} else {
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex;
}
right = midIndex - 1;
}
}
return tempIndex as number;
};
function updateItemsSize() {
const nodes = Array.from(listRef.value.children);
if (!nodes || !nodes.length) return;
const clonePosition = cloneDeep(positions.value);
(nodes as HTMLElement[]).forEach((node) => {
const height = node.clientHeight;
const index = Number(node.id);
const oldHeight = clonePosition[index].height;
const diff = oldHeight - height;
if (Math.abs(diff)) {
clonePosition[index].bottom -= diff;
clonePosition[index].height = height;
for (let k = index + 1; k < clonePosition.length; k++) {
clonePosition[k].top = clonePosition[k - 1].bottom;
clonePosition[k].bottom -= diff;
}
}
});
positions.value = clonePosition;
}
async function scrollEvent(e: Event) {
const scrollTop = (e.target as HTMLElement).scrollTop;
startIndex.value = binarySearch(positions.value, scrollTop);
endIndex.value = startIndex.value + visibleCount.value;
setOffsetY();
}
function setOffsetY() {
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;
}
}
onUpdated(() => {
nextTick(() => {
if (!positions.value.length) return;
updateItemsSize();
virtualTotalHeight.value =
positions.value[positions.value.length - 1].bottom;
setOffsetY();
});
});
onMounted(() => {
screenHeight.value = screenRef.value.clientHeight;
visibleCount.value = Math.ceil(screenHeight.value / props.estimatedItemSize);
startIndex.value = 0;
endIndex.value = startIndex.value + visibleCount.value;
});
🎨 组件样式 (style
)
为了保证组件的正确渲染和性能,CSS 样式也至关重要。
.virtual-content
容器设置为相对定位,overflow: auto
以便创建滚动条。.empty-block
和.virtual-list
均采用绝对定位,确保它们可以精准地覆盖在virtual-content
容器内。z-index
的设置保证了virtual-list
在empty-block
之上,同时empty-block
负责撑开滚动条。
CSS
css
.virtual-content {
height: 100%;
overflow: auto;
position: relative;
}
.virtual-list {
position: absolute;
left: 0;
right: 0;
z-index: 1;
}
.empty-block {
left: 0;
right: 0;
top: 0;
position: absolute;
z-index: -1;
}
使用组件
vue
<template>
<div class="demo">
<VirtualList :list="list" :loadMore="loadmore">
<template #default="{ item }">
<div class="item">{{ item.key }} - {{ item.value }}</div>
</template>
</VirtualList>
</div>
</template>
<script setup lang="ts">
import VirtualList from "./components/virtual-list/index.vue";
import faker from "faker";
import { onMounted, ref } from "vue";
const list = ref<any[]>([]);
const mockData = () => {
const data = [];
for (let i = 0; i < 100; i++) {
data.push({
value: faker.lorem.sentences(),
key: i,
});
}
list.value = data;
};
onMounted(() => {
mockData();
});
</script>
<style scoped>
.demo {
height: 100vh;
}
.item {
width: 100%;
background-color: #fff;
border-bottom: 1px solid red;
padding: 20px 0;
}
</style>
演示:
结语
通过以上实现,我们成功构建了一个支持可变高度、性能优异的虚拟列表组件。它巧妙地利用了 Proxy
的能力,通过动态计算和定位,解决了长列表的渲染性能瓶颈。希望这份详细的文档能帮助你更好地理解虚拟列表的实现原理,并在你的项目中发挥作用!