学完这篇你能得到一个可以直接贴进项目用的 Vue 3 虚拟滚动组件,几万条数据也能保持滚动流畅。
第一步:先搞明白虚拟滚动到底在省什么
普通 v-for 渲染一万条数据,浏览器就要创建一万个 DOM 节点,滚动时每一帧都要重排重绘,卡顿是必然的。虚拟滚动的思路很简单:**列表容器高度按总条数算(用空白撑开),但只渲染可视区域内的那十几二十条 DOM**,滚动时动态替换渲染的内容,并让内容的偏移量跟着滚动距离走。
所以要准备三样东西:单条高度(固定高度最简单)、容器可视高度、当前 scrollTop。有了这三个就能算出该渲染哪一段。
第二步:算区间,固定行高版本
新建 VirtualList.vue,先算索引:
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
items: { type: Array, default: () => [] },
itemHeight: { type: Number, default: 60 },
buffer: { type: Number, default: 5 } // 上下各多渲染几条,防白屏
})
const containerRef = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(0)
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer)
)
const endIndex = computed(() =>
Math.min(
props.items.length,
startIndex.value + Math.ceil(containerHeight.value / props.itemHeight) + props.buffer * 2
)
)
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const offsetY = computed(() => startIndex.value * props.itemHeight)
const totalHeight = computed(() => props.items.length * props.itemHeight)
</script>
关键点:startIndex 减掉 buffer 后要 Math.max(0, ...) 兜底,否则滚动到顶时 slice 参数会变负数。
第三步:模板用一层空白撑高度
<template>
<div ref="containerRef" class="v-list" @scroll.passive="onScroll">
<div class="v-list__phantom" :style="{ height: totalHeight + 'px' }">
<div class="v-list__inner" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="(item, i) in visibleItems"
:key="startIndex + i"
class="v-list__item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="startIndex + i" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.v-list {
height: 100%; /* 必须有确定高度 */
overflow-y: auto;
position: relative;
}
.v-list__phantom { position: relative; }
.v-list__inner { will-change: transform; }
</style>
用 translateY 而不是 margin-top / top,因为 transform 走合成层,不触发重排,滚动时更稳。
注意:滚动容器的高度必须有明确来源(height: 100% 或固定 px)。如果靠内容撑开,容器高度就等于内容高度,永远不会出现滚动条,组件看起来就像没生效。
第四步:滚动事件节流 + 容器高度自适应
滚动事件每秒能触发几十上百次,直接改响应式数据会把主线程占满:
let ticking = false
function onScroll(e) {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
scrollTop.value = e.target.scrollTop
ticking = false
})
}
let ro = null
onMounted(() => {
containerHeight.value = containerRef.value.clientHeight
ro = new ResizeObserver(() => {
containerHeight.value = containerRef.value.clientHeight
})
ro.observe(containerRef.value)
})
onBeforeUnmount(() => ro && ro.disconnect())
加 ResizeObserver 是因为用户拖动窗口、切换横竖屏、侧栏折叠都会改变可视高度,不算准就会出现底部空白或渲染多余项。
第五步:动态行高怎么办
内容高度不固定(比如帖子标题长短不一)时,固定 itemHeight 的算法就失效了。可行做法是:
- 先按预估高度算出位置表和总高度;
- 每条渲染后用
getBoundingClientRect 拿到真实高度,写回位置表;
- 用二分查找在位置表里定位
startIndex;
- 位置表变化后重算总高度和偏移。
逻辑明显复杂,还要处理测量后引发的二次回流。如果项目里动态高度是刚需,直接用现成方案更划算。
第六步:不想手写就用现成的库
vue-virtual-scroller:<RecycleScroller> 支持固定和动态尺寸,功能最全;
@vueuse/core 的 useVirtualList:几行就能用,适合简单列表;
el-table-v2 / el-select-v2:Element Plus 已内置虚拟化,用组件库的话优先用它。
自研适合你想要完全控制渲染结构、或者有自定义吸顶、分页加载混排的场景。
注意:v-for 的 key 别直接用 index,要用 startIndex + i 或数据自身的 id。用局部 index 的话,滚动时同一个 key 被不同数据复用,会引发 DOM 复用异常和输入框内容错位。
小结
- 虚拟滚动 = 空白撑总高度 + 只渲染可视区 + transform 定位偏移;
- 三个核心量:单条高度、容器可视高度、
scrollTop,算 startIndex / endIndex / offsetY;
startIndex 要夹到 0 以上,endIndex 要夹到总数以内;
- 滚动用
requestAnimationFrame 节流,容器高度用 ResizeObserver 监听;
- 滚动容器必须有确定高度,否则整段逻辑白写;
- 动态行高自研成本高,优先评估现成库。