1. 常见的做法:下拉追加数据
在实际开发中,我们常见的"无限加载"列表,基本逻辑是这样的:
xml
<template>
<div class="list">
<div v-for="item in items" :key="item.id" class="list-item">
{{ item.text }}
</div>
<button @click="loadMore">加载更多</button>
</div>
</template>
<script setup>
import { ref } from "vue"
const items = ref([])
function generate(start, count) {
return Array.from({ length: count }, (_, i) => ({
id: start + i,
text: `用户 ${start + i}`
}))
}
function loadMore() {
const start = items.value.length
items.value.push(...generate(start, 20))
}
// 首次加载
loadMore()
</script>
问题在哪里?
- 数据量小(几百条)时不卡;
- 但当数据量达到 1 万条 时,DOM 元素 = 1 万个,渲染、回流、Diff 全部爆炸;
- 滚动会出现 明显卡顿,甚至浏览器崩溃。
2. 真正的挑战:大数据量下的流畅滚动
如果需求是:
- 一次展示上万条数据(例如聊天记录、订单列表);
- 需要支持任意跳转(直接滚到第 5000 条);
👉 下拉追加方式完全不够用,因为浏览器要一直背着所有 DOM 节点。
3. 高性能虚拟列表:核心思想
虚拟列表的思路非常简单:
不管有多少条数据,始终只渲染视口中可见的部分 + 少量缓冲区。
假设视口高度 = 500px,单个 item = 50px → 最多可见 10 条,再加上下缓冲,总共渲染 20~30 个元素即可。
无论总数据量是 1,000 还是 100,000,渲染成本几乎一样。
4. 固定高度虚拟列表的实现
最简单的场景:每个 item 高度一致。
xml
<template>
<div ref="container" class="list" @scroll="onScroll">
<!-- 总高度占位 -->
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 实际渲染区域 -->
<div :style="{ transform: `translateY(${topOffset}px)` }">
<div v-for="item in visibleItems" :key="item.id" class="item">
{{ item.text }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue"
const props = defineProps({ items: Array, itemHeight: Number, containerHeight: Number })
const scrollTop = ref(0)
const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight) + 2)
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const endIndex = computed(() => startIndex.value + visibleCount.value)
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const topOffset = computed(() => startIndex.value * props.itemHeight)
const totalHeight = computed(() => props.items.length * props.itemHeight)
function onScroll(e) {
scrollTop.value = e.target.scrollTop
}
</script>
效果验证
- 即使是 1 万条数据,DOM 元素始终保持在 20~30 个;
- 滚动非常流畅。
5. 动态高度的挑战与解决
现实中,列表项往往不是固定高度:
- 有的用户名字很短 → 一行就够;
- 有的备注很长 → 占 3 行。
难点
- 无法简单用
index * itemHeight
来计算偏移; - 滚动时需要知道 第 N 个元素的累计高度。
解决思路
- 用
ResizeObserver
动态监听每个 item 的真实高度; - 用 前缀和数组 (prefix sum) 记录累计高度;
- 用 二分查找 根据 scrollTop 找到第一个可见元素。
核心代码:
ini
const heights = ref(new Map()) // 记录每个 item 的高度
const prefixHeights = ref([0]) // 前缀和数组
function updateHeight(id, el) {
if (!el) return
const h = el.getBoundingClientRect().height
heights.value.set(id, h)
// 重建前缀和
const arr = [0]
for (let i = 0; i < props.items.length; i++) {
arr[i + 1] = arr[i] + (heights.value.get(props.items[i].id) || 50)
}
prefixHeights.value = arr
}
function findStartIndex(scrollTop) {
let low = 0, high = prefixHeights.value.length - 1
while (low < high) {
const mid = Math.floor((low + high) / 2)
if (prefixHeights.value[mid] <= scrollTop) low = mid + 1
else high = mid
}
return low - 1
}
6. 对比总结
方法 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
下拉追加 | 实现简单,分页习惯 | DOM 无限膨胀,滚动卡顿 | 数据量 ≤ 500 |
虚拟列表 | 高性能,支持任意跳转 | 实现复杂 | 数据量 ≥ 1000 |
最终结论:
- 小数据 → 下拉追加就够了;
- 大数据(上万条) → 必须虚拟列表,否则用户体验完全不可接受。