一、Vue3 虚拟滚动是啥?和普通滚动有啥不一样?
做前端开发的朋友,肯定遇到过「列表数据太多,页面卡到动不了」的情况,Vue3 里的虚拟滚动技术,就是专门解决这种长列表性能问题的,但好多同学对虚拟滚动一头雾水:它到底是干啥的?原理咋理解?自己咋在Vue3里实现?适合啥场景?今天咱就把这些问题一个个掰碎了讲,从概念到实战,帮你把虚拟滚动彻底搞明白~
先聊聊普通滚动的问题:比如做个后台管理系统,要渲染 1 万条表格数据,普通做法是把这 1 万条数据全转成 DOM 节点,渲染到页面上,但浏览器处理几千个 DOM 就开始卡了,1 万个 DOM 直接让页面变成「PPT」——滚动卡、点击没反应,用户体验崩碎。
那虚拟滚动是咋解决的?虚拟滚动(也叫虚拟列表)的核心逻辑是「只渲染用户能看到的部分」,举个例子:列表容器高度 400px,每个列表项高 30px,那容器里最多显示 13 个项(400/30≈13),虚拟滚动只会把这 13 个(甚至多渲染几个缓冲项)真正渲染成 DOM,其他没显示的项用「占位 div」撑高度(保证滚动条长度正确),这样 DOM 节点从 1 万变成 20 个左右,性能直接起飞~
对比普通滚动就能更清楚:普通滚动是「全部渲染」,DOM 数量爆炸;虚拟滚动是「按需渲染」,只给用户看得到的内容做 DOM,性能差距天差地别,但代价是需要写代码计算「该渲染哪些项」「怎么让滚动条正常」这些逻辑。
虚拟滚动原理咋理解?这几个核心点要吃透
得把原理拆成「视觉区域、滚动容器、数据分片、占位逻辑」这几个模块,用生活化的例子讲才好懂:
- 视觉区域:想象你透过一扇窗户看一列火车(长列表),窗户就是「可视区域」,只能看到火车的一部分,虚拟滚动里,窗户对应的就是页面上能看到的列表容器。
- 滚动容器:火车轨道(滚动容器)得足够长,才能让滚动条正常,所以要用一个占位 div,高度等于「所有列表项高度之和」,让浏览器计算出正确的滚动条长度。
- 数据分片:火车有很多车厢(列表项),窗户每次只能显示 10 个车厢,虚拟滚动要计算「现在窗户对准第几个车厢」,然后只把这 10 个车厢(数据分片)渲染成 DOM,其他车厢先不渲染。
- 位移对齐:渲染出来的 10 个车厢得「贴」在窗户对应的位置,比如窗户往下滑了 300px(对应 10 个车厢高度),那渲染的车厢得用 CSS 的
transform: translateY(300px)
移动到正确位置,用户看着才像连续的列表。
举个数字例子更直观:假设列表有 1000 项,每项高 30px,容器高 600px(能显示 20 项),占位 div 高度是 1000×30 = 30000px,滚动条长度对应 30000px,当用户滚动到 scrollTop=900px
时,计算起始项是 900/30=30
,所以渲染第 30 到第 50 项(20 个),然后把这 20 个项用 transform
移动 900px,这样用户看到的就是第 30 到 50 项,DOM 只有 20 个,性能拉满~
Vue3 里自己实现虚拟滚动,步骤和代码咋写?
这部分要详细讲实战,分步骤 + 代码示例 + 解释,让你能跟着做。
步骤 1:搭结构——三个核心容器
先写 template 结构,要包含「滚动容器、占位容器、可视区域容器」:
<template> <!-- 外层滚动容器:控制滚动,设置高度和 overflow --> <div class="virtual-scroll" ref="scrollRef"> <!-- 占位容器:撑开总高度,让滚动条长度正确 --> <div class="placeholder" :style="{ height: totalHeight + 'px' }"></div> <!-- 可视区域容器:绝对定位,放实际渲染的列表项,用 transform 调整位置 --> <div class="visible-box" ref="visibleRef" :style="{ transform: `translateY(${offsetY}px)` }"> <div v-for="item in visibleData" :key="item.id" class="list-item" > {{ item.content }} </div> </div> </div> </template>
解释结构逻辑:scrollRef
是滚动容器,overflow-y: auto
开启滚动;placeholder
用高度撑开整个列表的高度,让滚动条长度正确;visible-box
绝对定位,里面放实际渲染的列表项,用 translateY
调整位置,让这些项「对齐」到滚动后的位置。
步骤 2:初始化与滚动事件绑定
用 Vue3 的 setup
语法写逻辑,核心是「计算可视项数、绑定滚动事件、处理滚动时的渲染逻辑」:
<script setup> import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' // 1. 模拟大量数据(10000 条) const dataSource = ref([]) for (let i = 0; i < 10000; i++) { dataSource.value.push({ id: i, content: `第${i}条数据` }) } // 2. 固定项高度(假设每个项高 30px) const itemHeight = 30 // 3. 响应式变量:可视区域能显示的项数、起始/结束索引、位移等 const scrollRef = ref(null) // 滚动容器 DOM const visibleRef = ref(null) // 可视区域 DOM const visibleCount = ref(0) // 可视区域能放多少项 const startIndex = ref(0) // 渲染的起始索引 const endIndex = ref(0) // 渲染的结束索引 const offsetY = ref(0) // 可视区域的位移量 const visibleData = ref([]) // 实际要渲染的数据 // 总高度:所有项高度之和 const totalHeight = computed(() => dataSource.value.length * itemHeight) // 4. mounted 时初始化 onMounted(() => { // 获取滚动容器的高度 const containerHeight = scrollRef.value.clientHeight // 计算可视区域能放多少项(向上取整,防止有剩余高度) visibleCount.value = Math.ceil(containerHeight / itemHeight) // 绑定滚动事件 scrollRef.value.addEventListener('scroll', handleScroll) // 初始计算要渲染的数据 calculateVisibleData() }) // 5. 卸载时移除事件(避免内存泄漏) onUnmounted(() => { scrollRef.value.removeEventListener('scroll', handleScroll) }) // 6. 滚动事件处理函数 function handleScroll() { // 获取滚动条滚动的距离(scrollTop) const scrollTop = scrollRef.value.scrollTop // 计算起始索引:scrollTop / 每项高度(向下取整) startIndex.value = Math.floor(scrollTop / itemHeight) // 结束索引 = 起始索引 + 可视项数 endIndex.value = startIndex.value + visibleCount.value // 计算位移:起始索引 * 每项高度(让渲染的项对齐到滚动后的位置) offsetY.value = startIndex.value * itemHeight // 更新要渲染的数据 calculateVisibleData() } // 7. 数据切片:只取起始到结束的部分 function calculateVisibleData() { visibleData.value = dataSource.value.slice(startIndex.value, endIndex.value) } </script>
步骤 3:样式处理(scoped CSS)
给三个容器加样式,保证布局和滚动逻辑正确:
<style scoped> .virtual-scroll { height: 400px; /* 滚动容器的高度,可根据需求改 */ overflow-y: auto; /* 开启垂直滚动 */ position: relative; /* 让内部绝对定位的元素有参考 */ border: 1px solid #eee; } .placeholder { position: absolute; /* 占位容器绝对定位,不影响可视区域 */ width: 100%; /* 宽度铺满 */ left: 0; top: 0; } .visible-box { position: absolute; /* 可视区域绝对定位,方便用 transform 移动 */ width: 100%; left: 0; top: 0; } .list-item { height: 30px; /* 每项高度,和 js 里的 itemHeight 一致 */ line-height: 30px; /* 垂直居中,优化样式 */ border-bottom: 1px solid #eee; } </style>
代码解释:这里的关键是「占位容器」和「可视区域容器」的定位逻辑,占位容器用 absolute
撑开高度,让滚动条长度等于所有项的总高度;可视区域容器也是 absolute
,通过 transform: translateY(offsetY)
移动,让渲染的项刚好出现在用户滚动到的位置,滚动事件里,每次计算 startIndex
和 endIndex
,然后切片数据,只渲染这部分,DOM 数量就被牢牢控制住了~
虚拟滚动适合啥场景?这些情况用了才香!
得讲清楚适用和不适用场景,避免用错地方。
适合场景
- 超长长列表:比如后台表格要渲染 1 万 + 条数据,或者聊天记录加载几千条历史消息,普通渲染必卡,虚拟滚动能救。
- 项结构简单但数量大:比如下拉选择器有几千个选项,每个选项就一行文字,虚拟滚动能大幅减少 DOM。
- 滚动时数据不变:如果列表项在滚动时不需要频繁更新(比如静态数据展示),虚拟滚动性能优势明显。
举个实际例子:我之前做过一个物流系统,要展示某个仓库的 10 万条库存记录,用普通渲染直接页面崩溃,换成虚拟滚动后,DOM 只有几十条,滚动丝滑得很~
不适合场景
- 项高度不固定:如果每个列表项高度不一样(比如有的是 30px,有的是 60px),虚拟滚动需要额外计算每个项的高度,复杂度翻倍(后面讲解决方法)。
- 项有复杂交互/动态内容:比如列表项里有视频自动播放、实时更新的倒计时,虚拟滚动动态渲染会导致这些内容「消失」或「重置」(因为不可视区的项会被销毁,重新渲染时状态丢失)。
- 数据量小:比如列表只有几百条,普通渲染性能完全够,用虚拟滚动反而多写一堆代码,没必要。
实现时容易踩的坑,咋解决?
这部分讲实际开发中的问题,帮你避坑。
坑 1:动态项高度咋处理?
问题:如果列表项高度不固定(比如内容长度不同,有的换行),之前的「固定 itemHeight
」方法就失效了,滚动时会出现「内容重叠」「滚动位置错乱」。
解决方法:
- 先测量每个项的高度:可以在页面加载时,先渲染前几个项,用 Vue 的
nextTick
获取它们的offsetHeight
,保存到一个数组里(itemHeights
数组)。 - 动态计算总高度:总高度不再是
length * itemHeight
,而是itemHeights
数组的累加和。 - 滚动时用二分法找起始项:因为每个项高度不同,
scrollTop
对应的起始项不能简单用scrollTop / itemHeight
,得用二分法在itemHeights
的累加数组里找位置。
举个代码思路:
// 假设 itemHeights 是保存每个项高度的数组 const itemHeights = ref([]) // 累加高度数组:[30, 60, 90, ...] 表示前 n 项的总高度 const accumulatedHeights = ref([]) // 渲染项后,测量高度(用 nextTick) watch(visibleData, () => { nextTick(() => { const items = visibleRef.value.querySelectorAll('.list-item') items.forEach((item, index) => { const realIndex = startIndex.value + index itemHeights.value[realIndex] = item.offsetHeight // 同时更新 accumulatedHeights accumulatedHeights.value[realIndex] = (accumulatedHeights.value[realIndex - 1] || 0) + item.offsetHeight }) }) }) // 滚动时用二分法找 startIndex function findStartIndex(scrollTop) { let left = 0, right = accumulatedHeights.value.length - 1 while (left < right) { const mid = Math.floor((left + right) / 2) if (accumulatedHeights.value[mid] <= scrollTop) { left = mid + 1 } else { right = mid } } return left }
坑 2:快速滚动时白屏
问题:如果用户快速滚动鼠标滚轮,可视区域的项还没来得及渲染,就会出现「白屏」(滚动过的地方没有内容)。
解决方法:
- 多渲染缓冲项:
visibleCount
不取刚好能显示的数量,而是多取 20%(比如能显示 20 项,实际渲染 25 项),这样快速滚动时缓冲项能补上,减少白屏概率。 - 优化滚动事件:用节流(throttle)减少
handleScroll
的执行频率,但要注意节流时间不能太长,否则滚动不流畅,一般用 16ms(约 60 帧)的节流间隔。
坑 3:滚动位置计算错误
问题:滚动容器有 padding
、border
,导致 scrollTop
获取不准确,渲染的项位置偏移。
解决方法:
- 准确计算「可滚动区域」:用
scrollRef.value.scrollTop
的顶部距离容器顶部的距离),而容器的padding
要算在容器高度里,比如容器高度是clientHeight
(包含padding
),所以计算visibleCount
时用clientHeight
是对的。 - 调试时用
console.log
打印scrollTop
、startIndex
、offsetY
等值,看是否和预期一致。
不想自己写?Vue 生态有现成的虚拟滚动组件!
讲社区库,降低开发成本。
推荐库:vue-virtual-scroller(Star 数多,维护活跃)
用法示例:
-
安装:
npm install vue-virtual-scroller
-
全局注册(main.js):
import { createApp } from 'vue' import App from './App.vue' import { VirtualScroller } from 'vue-virtual-scroller' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const app = createApp(App) app.component('VirtualScroller', VirtualScroller) app.mount('#app')
3. 组件中使用:
```html
<template>
<VirtualScroller
class="my-scroller"
:items="bigData"
:item-size="30"
key-field="id"
>
<template #default="{ item }">
<div class="item">{{ item.content }}</div>
</template>
</VirtualScroller>
</template>
<script setup>
import { ref } from 'vue'
const bigData = ref(/* 10000 条数据 */)
</script>
<style scoped>
.my-scroller {
height: 400px;
}
.item {
height: 30px;
line-height: 30px;
}
</style>
库的优势:vue-virtual-scroller
帮我们封装了滚动计算、动态高度处理、性能优化这些复杂逻辑,比如要支持动态高度,只需要把 item-size
改成一个函数,返回每个项的高度;要做网格布局(比如瀑布流),也有对应的 GridScroller
组件,适合不想自己折腾底层逻辑,想快速实现虚拟滚动的同学。
但也要提醒:用现成库省事儿,但遇到特别定制化的需求(比如列表项有复杂动画、特殊滚动逻辑),还是得理解虚拟滚动原理,才能改库或者自己写逻辑~
虚拟滚动啥时候用?咋用?
最后总结,帮你理清思路:
- 用的时机:数据量 ≥ 1 万,普通渲染卡;列表项结构简单,交互少;需要优化性能。
- 实现方式:简单场景(固定高度
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。