大量dom导致浏览器渲染了全部dom导致页面严重卡顿、交互延迟、CPU 占用飙升、内存急剧增大,滚动频繁掉帧等问题,细想一下:用户并不会同时看到所有数据,所以我们只需借助虚拟滚动,只渲染用户可视区域内的数据,减少大量 DOM 和渲染压力,提升页面流畅度及用户体感。
一、具体实现
- 外层容器:固定高度 +
overflow-y: auto,这是用户视图 - 占位 div:高度设为
总条数 × 行高,它不显示任何内容,只负责撑出滚动条 - 真实渲染区:
position: absolute+transform: translateY(),根据滚动位置动态移动
用户滚动时,监听scrollTop,算出当前视图下应该展示哪几条数据,然后:
- 更新真实渲染区的数据
- 更新
translateY,模拟跟着滚动条走
这样用户感觉自己在滚一个长列表,实际上 DOM 节点始终只有十几个。
如图:
二、核心实现
// 1. 起始索引:根据滚动距离算出第一个可见项 startIndex = Math.floor(scrollTop / itemHeight) // 2. 结束索引:起始 + 可视区能容纳的行数 endIndex = startIndex + Math.ceil(containerHeight / itemHeight) // 3. 偏移量:真实渲染区相对于顶部的位置 offset = startIndex * itemHeight例如:scrollTop = 500,itemHeight = 50,containerHeight = 500
startIndex = 500 / 50 = 10(从第 10 条开始渲染)
endIndex = 10 + 10 = 20(渲染到第 20 条)
offset = 10 × 50 = 500(真实渲染区向下移动 500px)
三、完整代码
vue3+TS
<template> <div ref="containerRef" class="virtual-list" :style="{ height: `${containerHeight}px` }" @scroll="handleScroll" > <!-- 占位:撑出滚动条 --> <div class="virtual-list__phantom" :style="{ height: `${totalHeight}px` }" /> <!-- 真实渲染区:transform 定位 --> <div class="virtual-list__content" :style="{ transform: `translateY(${offset}px)` }" > <div v-for="item in visibleData" :key="item.id" class="virtual-list__item" :style="{ height: `${itemHeight}px` }" > <slot :item="item">{{ item.name }}</slot> </div> </div> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue'; interface Item { id: number | string; [key: string]: any; } const props = withDefaults( defineProps<{ data: Item[]; itemHeight?: number; containerHeight?: number; buffer?: number; }>(), { itemHeight: 50, containerHeight: 500, buffer: 5, } ); const containerRef = ref<HTMLElement | null>(null); const scrollTop = ref(0); // 总高度 const totalHeight = computed(() => props.data.length * props.itemHeight); // 可视区能容纳的行数 const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight) ); // 起始索引(减 buffer 避免向上滚动时白屏) const startIndex = computed(() => Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer) ); // 结束索引(加 2 倍 buffer 让上下都有缓冲) const endIndex = computed(() => Math.min( props.data.length, startIndex.value + visibleCount.value + props.buffer * 2 ) ); // 需要渲染的数据切片 const visibleData = computed(() => props.data.slice(startIndex.value, endIndex.value) ); // 偏移量(!关键,渲染区模拟在正确位置) const offset = computed(() => startIndex.value * props.itemHeight); function handleScroll(e: Event) { scrollTop.value = (e.target as HTMLElement).scrollTop; } </script> <style scoped> .virtual-list { position: relative; overflow-y: auto; border: 1px solid #ddd; } .virtual-list__phantom { position: absolute; top: 0; left: 0; right: 0; z-index: -1; } .virtual-list__content { position: absolute; top: 0; left: 0; right: 0; will-change: transform; } .virtual-list__item { display: flex; align-items: center; padding: 0 16px; border-bottom: 1px solid #eee; box-sizing: border-box; } </style>使用方式:
<VirtualList :data="list" :item-height="50" :container-height="500"> <template #default="{ item }"> <span>#{{ item.id }}</span> <span style="margin-left: 20px">{{ item.name }}</span> </template> </VirtualList>