在web开发的过程中有时候会遇到有几千甚至几万条数据列表要显示的情况,这时候页面渲染或是操作可能会出现卡顿的情况,更严重的时候可能会导致浏览器崩溃,这样严重影响用户体验。为了解决这种大数据量导致的页面卡顿问题,便有了我们经常听到的虚拟滚动技术,本文来为大家介绍一下这种技术。
其实虚拟列表的思路就是:只渲染“当前能看到的”那一小段数据(例如 10~20 条), 用占位 + 位移的方式模拟出“整份长列表”的滚动效果。
实现原理概要:
1. 视口:给列表一个固定高度的可滚动容器(如 400px),只有这里能滚动。
2. 总高度占位:用一个 div(phantom)高度设为 总条数 × 每条高度,把滚动区域撑开, 这样滚动条长度就对应“整份列表”的长度。
3. 可见窗口计算:根据当前 scrollTop(已滚动的距离),用公式算出: - 当前视口里“第一条”应该是数据中的第几条(startIndex)- 当前视口里“最后一条”应该是第几条(endIndex)
4. 只渲染这一段:用 list.slice(startIndex, endIndex + 1) 得到 visibleList, 只把这几条渲染成真实 DOM。
5. 位移对齐:把这一段 DOM 放在一个绝对定位的容器里,用 transform: translateY(offsetY)下移,其中 offsetY = startIndex × 每条高度,这样视觉上就和“从第 startIndex 条开始”一致。
6.关键计算公式
- totalHeight = list.length * itemHeight
- startIndex = Math.floor(scrollTop / itemHeight) // 再减去 buffer 避免顶部闪白
- 可见条数 = Math.ceil(视口高度 / itemHeight)
- endIndex = startIndex + 可见条数 + buffer
- offsetY = startIndex * itemHeight
以下为VirtualListPage页,用来
<div class="virtual-list-page">
<div class="page-container">
<h1 class="page-title">虚拟列表演示</h1>
<p class="page-desc">
当前共 {{ list.length }} 条数据,仅渲染可见窗口内的条目(约十几条),滚动时动态更新,避免 DOM 过多导致卡顿。
</p>
<div class="list-wrap">
<VirtualList
ref="virtualListRef"
:list="list"
:item-height="56"
:buffer="6"
item-key="id"
>
<template #item="{ item, index }">
<div class="list-item-inner">
<span class="index">{{ index + 1 }}</span>
<span class="title">{{ item.title }}</span>
<span class="extra">{{ item.extra }}</span>
</div>
</template>
</VirtualList>
</div>
</div>
</div>
import { ref, onMounted } from 'vue'
import VirtualList from '../components/VirtualList.vue'
const virtualListRef = ref(null)
// 模拟数据:生成较多条,用于演示虚拟列表只渲染可见部分
const list = ref([])
function generateList() {
const total = 2000
const titles = [
'虚拟列表项',
'按需渲染示例',
'长列表优化',
'窗口化列表',
'高性能滚动',
]
const arr = []
for (let i = 0; i < total; i++) {
arr.push({
id: i + 1,
title: `${titles[i % titles.length]} #${i + 1}`,
extra: `第 ${i + 1} 条`,
})
}
list.value = arr
}
onMounted(() => {
generateList()
})VirtualList组件
计算公式(固定行高)
- startIndex = floor(scrollTop / itemHeight) // 可见区域第一条的索引
- 可见条数 = ceil(viewportHeight / itemHeight)
- endIndex = startIndex + 可见条数 + buffer(上下各留 buffer 条)
- 渲染的列表 = list.slice(startIndex, endIndex + 1)
- 偏移量 offsetY = startIndex * itemHeight // 把“这一块”整体下移到正确位置
DOM 结构对应关系
- .virtual-list:固定高、overflow:auto,产生 scrollTop
- .virtual-list__phantom:高度 = totalHeight,不渲染内容,只把滚动条总长度撑开
- .virtual-list__content:position:absolute;内部只放 visibleList;用 transform: translateY(offsetY)
让这一小段内容在视觉上出现在“从第 startIndex 条开始”的位置
<div
ref="listRef"
class="virtual-list"
@scroll="onScroll"
>
<!-- 占位层:高度为 totalHeight,撑开滚动区域,使滚动条长度与“总条数”对应 -->
<div
class="virtual-list__phantom"
:
/>
<!-- 可见项容器:绝对定位,通过 top + transform 将当前窗口内的项渲染到正确视觉位置 -->
<div
class="virtual-list__content"
:
>
<div
v-for="item in visibleList"
:key="getItemKey(item)"
class="virtual-list__item"
:
>
<slot name="item" :item="item" :index="item.__index" />
</div>
</div>
</div>
</template>
import { ref, computed, watch } from 'vue'
const props = defineProps({
/** 完整数据列表 */
list: {
type: Array,
default: () => [],
},
/** 每项高度(像素),固定高度简化计算 */
itemHeight: {
type: Number,
default: 56,
},
/** 上下各多渲染几条,避免快速滚动时出现空白 */
buffer: {
type: Number,
default: 5,
},
/** 列表项的唯一 key 的字段名,若为函数则 (item) => key */
itemKey: {
type: [String, Function],
default: 'id',
},
})
/** 列表容器 DOM,用于取 scrollTop 和 clientHeight */
const listRef = ref(null)
/** 当前滚动距离(视口顶部已滚过的高度) */
const scrollTop = ref(0)
/** 视口高度(可见区域高度) */
const viewportHeight = ref(400)
/** 监听滚动,更新 scrollTop(并可在需要时更新 viewportHeight) */
function onScroll() {
if (!listRef.value) return
scrollTop.value = listRef.value.scrollTop
viewportHeight.value = listRef.value.clientHeight
}
/** 总高度 = 条数 × 每条高度,用于占位层高度 */
const totalHeight = computed(() => props.list.length * props.itemHeight)
/** 当前可见区域“第一条”的索引(从 0 开始) */
const startIndex = computed(() => {
const index = Math.floor(scrollTop.value / props.itemHeight)
return Math.max(0, index - props.buffer)
})
/** 当前需要渲染的“最后一条”的索引(含) */
const endIndex = computed(() => {
const visibleCount = Math.ceil(viewportHeight.value / props.itemHeight)
const end = startIndex.value + visibleCount + props.buffer
return Math.min(props.list.length - 1, end)
})
/** 当前应渲染的可见列表(带 __index 便于 slot 使用) */
const visibleList = computed(() => {
const start = startIndex.value
const end = endIndex.value
return props.list.slice(start, end + 1).map((item, i) => ({
...item,
__index: start + i,
}))
})
/** 可见块整体下移的像素数,使第一条对齐到视口内正确位置 */
const offsetY = computed(() => startIndex.value * props.itemHeight)
function getItemKey(item) {
if (typeof props.itemKey === 'function') {
return props.itemKey(item)
}
return item[props.itemKey] ?? item.__index
}
/** 若外部传入的 list 引用变化,可在这里做初始高度测量等(此处固定高度可省略) */
watch(
() => props.list.length,
() => {
if (listRef.value) {
viewportHeight.value = listRef.value.clientHeight
}
},
{ immediate: true }
)
defineExpose({
listRef,
scrollToIndex(index) {
if (!listRef.value) return
listRef.value.scrollTop = index * props.itemHeight
},
})
</script>
<style scoped>
.virtual-list {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
}
/* 占位层:撑开滚动高度,不参与定位 */
.virtual-list__phantom {
position: absolute;
left: 0;
top: 0;
width: 1px;
pointer-events: none;
visibility: hidden;
}
/* 可见项容器:绝对定位在顶部,通过 transform 下移 */
.virtual-list__content {
position: absolute;
left: 0;
top: 0;
right: 0;
box-sizing: border-box;
}
.virtual-list__item {
display: flex;
align-items: center;
box-sizing: border-box;
border-bottom: 1px solid #e2e8f0;
}
</style> 滚动时流程
用户滚动 → 触发 scroll 事件 → 根据新的 scrollTop 重新计算 startIndex/endIndex→ 更新 visibleList 和 offsetY → Vue 更新 DOM,只重绘这一小段,性能稳定