在现代 Web 应用中,长列表或海量数据渲染是常见场景,如商品列表、消息列表或后台表格。直接渲染全部数据会导致页面卡顿甚至浏览器崩溃。虚拟滚动应运而生,通过只渲染可视区域的 DOM 元素,实现高性能渲染。本文旨在帮助开发者理解虚拟滚动的原理、掌握实现流程,并在实际项目中提升页面性能与用户体验。
下面我将会阐述实现虚拟滚动的一些步骤和注意事项
虚拟滚动的实现步骤
再次之前我们可以将基础样式写好并且旁边加上一个调试栏目
1. 准备数据与容器
1.1 获取完整数据列表
即将需要渲染的数据放进一个数组中,后续增添数据可以直接push进来,初始仅添加测试数据的状态如下
1.2 确定每个卡片项高度itemHeight
const calcItemHeight = (item, itemWidth) => {
// 基础高度
let height = 200 // 图片高度
// 根据文本内容计算额外高度
const titleHeight = Math.ceil((item.book_name || '').length / 20) * 20 + 20
const authorHeight = 20
const typeHeight = 20
const priceHeight = 24
const descriptionHeight = Math.ceil((item.book_description || '').length / 30) * 16 + 20
const sellerHeight = 16
const statusHeight = 20
height += titleHeight + authorHeight + typeHeight + priceHeight + descriptionHeight +
sellerHeight + statusHeight + 50 // padding
return Math.max(height, 320) // 最小高度320px
}
1.3 获取滚动容器的高度containerHeight
用ref获取到容器dom
<div class="virtual-scroller-container" ref="containerRef">
<!-- 容器内容 -->
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
// 容器引用
const containerRef = ref(null)
// 滚动处理
const handleScroll = () => {
if (!containerRef.value) return
const { scrollTop: currentScrollTop, scrollHeight: currentScrollHeight, clientHeight } = containerRef.value
// 更新调试信息
scrollTop.value = currentScrollTop
scrollHeight.value = currentScrollHeight
containerHeight.value = clientHeight
console.log('滚动位置:', currentScrollTop, '总高度:', currentScrollHeight, '可视高度:', clientHeight)
// 检查是否需要加载更多数据
if (currentScrollHeight - (currentScrollTop + clientHeight) < 100 && hasMore.value && !loading.value) {
console.log('滚动触底,开始加载更多数据')
loadMore()
}
}
</script>
1.4 创建 占位容器,高度 = itemHeight * itemCount,用于模拟整个列表
<!-- 虚拟滚动占位器 -->
<div class="virtual-placeholder" :style="{ height: `${itemHeight * items.length}px` }"></div>
<!-- 实际渲染的可视区域项 -->
<div class="visible-items" :style="{ transform: `translateY(${startOffset}px)` }">
<!-- 渲染可视区域的项 -->
<div v-for="item in visibleItems" :key="item.id" class="item">
<!-- 项内容 -->
</div>
</div>
</template>
2.计算可视区域索引
2.1获取当前滚动位置
计算位置并预更新数据
const handleScroll = () => {
if (!containerRef.value) return
const { scrollTop: currentScrollTop, scrollHeight: currentScrollHeight, clientHeight } = containerRef.value
// 更新调试信息
scrollTop.value = currentScrollTop // 当前滚动位置
scrollHeight.value = currentScrollHeight
containerHeight.value = clientHeight
console.log('滚动位置:', currentScrollTop, '总高度:', currentScrollHeight, '可视高度:', clientHeight)
// 检查是否需要加载更多数据
if (currentScrollHeight - (currentScrollTop + clientHeight) < 100 && hasMore.value && !loading.value) {
console.log('滚动触底,开始加载更多数据')
loadMore()
}
}
2.2计算可视区域开始索引
起始索引是向下取整的,结束索引是向上取整的,即高度小于一张卡片的高度时视作一张
const startIndex = Math.floor(scrollTop / itemHeight)
// 计算可视区域结束索引
const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight), items.length - 1)
2.3计算可视条数
const visibleCount = Math.ceil(containerHeight / itemHeight)
2.4增加缓冲条数 buffer,防止快速滚动出现空白
const buffer = 5 // 缓冲条数
const bufferedStartIndex = Math.max(0, startIndex - buffer)
const bufferedEndIndex = Math.min(items.length - 1, endIndex + buffer)
3.提取可渲染数据
const visibleItems = items.slice(bufferedStartIndex, bufferedEndIndex + 1)
// 计算偏移量
const startOffset = bufferedStartIndex * itemHeight
4.绑定滚动事件
onMounted(() => {
fetchData(1)
nextTick(() => {
setupIntersectionObserver()
// 添加滚动监听器作为备用
if (containerRef.value) {
containerRef.value.addEventListener('scroll', handleScroll)
}
// 测试滚动功能
console.log('容器高度:', containerRef.value?.clientHeight)
console.log('容器滚动高度:', containerRef.value?.scrollHeight)
// 定期更新渲染数量
setInterval(updateRenderedCount, 1000)
})
})
onUnmounted(() => {
if (observer.value) {
observer.value.disconnect()
}
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll)
}
})
成品完成示例图如下,可以看到渲染卡片比例仅占20%,假如继续下滑滚轮获取更多数据,比例还会进一步降低,因为我们只渲染了可见范围(视窗+缓冲)部分的卡片,极大的节省了性能,降低了开销
5.高级优化建议
5.1 使用 Intersection Observer API 作为滚动事件的补充:
<div ref="scrollContainer" class="scroll-container">
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="item in visibleData"
:key="item.id"
:style="{ position: 'absolute', top: item.top + 'px', height: itemHeight + 'px' }"
>
{{ item.name }}
</div>
<!-- 底部观察器 -->
<div ref="bottomObserver" class="bottom-observer"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const bottomObserver = ref(null)
const scrollContainer = ref(null)
const hasMore = ref(true)
const loading = ref(false)
function loadMore() {
if (!hasMore.value || loading.value) return
loading.value = true
console.log('触底加载更多数据...')
setTimeout(() => {
loading.value = false
}, 1000)
}
let observer = null
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
loadMore()
}
},
{
root: scrollContainer.value,
threshold: 0.1 // 当底部元素 10% 进入可视区时触发
}
)
observer.observe(bottomObserver.value)
})
onUnmounted(() => {
observer?.disconnect()
})
</script>
<style>
.scroll-container {
height: 500px;
overflow-y: auto;
border: 1px solid #ccc;
}
.bottom-observer {
height: 1px;
}
</style>
5.2 动态高度计算:对于内容高度不固定的项,实现动态高度计算和缓存。
<div ref="scrollContainer" class="scroll-container" @scroll="handleScroll">
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="(item, i) in visibleData"
:key="item.id"
ref="itemRefs"
:data-index="startIndex + i"
class="item"
:style="{ position: 'absolute', top: itemPositions[startIndex + i] + 'px' }"
>
{{ item.text }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
const data = ref(Array.from({ length: 1000 }, (_, i) => ({ id: i, text: '内容' + i })))
const scrollContainer = ref(null)
const itemRefs = ref([])
const itemHeights = reactive({}) // 缓存每个元素高度
const itemPositions = reactive({}) // 累积偏移表
const containerHeight = 500
const scrollTop = ref(0)
const itemCount = data.value.length
// 动态计算高度与偏移
function recalcPositions() {
let acc = 0
for (let i = 0; i < itemCount; i++) {
const height = itemHeights[i] || 40 // 默认高度
itemPositions[i] = acc
acc += height
}
}
// 可视数据计算
const visibleData = ref([])
let startIndex = 0
function updateVisible() {
let accHeight = 0
for (let i = 0; i < itemCount; i++) {
accHeight += itemHeights[i] || 40
if (accHeight >= scrollTop.value) {
startIndex = i
break
}
}
visibleData.value = data.value.slice(startIndex, startIndex + 20)
}
// 滚动事件
const handleScroll = () => {
scrollTop.value = scrollContainer.value.scrollTop
updateVisible()
}
onMounted(async () => {
await nextTick()
// 动态测量元素高度
itemRefs.value.forEach((el) => {
const index = Number(el.dataset.index)
itemHeights[index] = el.offsetHeight
})
recalcPositions()
updateVisible()
})
</script>
<style>
.scroll-container {
height: 500px;
overflow-y: auto;
border: 1px solid #ccc;
}
.item {
background: #f6f8fa;
margin-bottom: 8px;
padding: 8px;
}
</style>
5.3 防抖处理:对滚动事件进行防抖处理,避免频繁计算。
export function debounce(fn, delay = 100) {
let timer = null
return function (...args) {
clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}