虚拟滚动组件优化记录
虚拟滚动组件优化记录
📘 版本说明
本系列文档记录了虚拟滚动(Virtual Scroll)组件的多版本迭代和优化过程。用于展示如何处理大规模数据列表渲染,提升性能体验。
🌀 第一版:基础虚拟列表(transform 优化)
✅ 实现特点
- 使用
transform: translateY(...)
实现虚拟滚动偏移。 - 不为每项设置绝对定位,使用
ul
偏移整体列表。 - 渲染性能优于传统的每项
transform
或absolute
定位方式。 - 支持 180000 条数据无性能瓶颈。
📜 组件代码
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'interface ListItem {id: numbercontent: stringindex: number
}// 列表数据(10,000 条)
const list = ref<ListItem[]>(Array.from({ length: 180000 }, (_, i) => ({id: i,content: `Item ${i}`,index: i,}))
)const virtualList = ref<HTMLElement | null>(null) // 容器 ref
const itemHeight = 50 // 每项固定高度(单位:px)
const offsetBufferCount = 5 // 上下缓冲条数
const scrollTop = ref(0) // 当前滚动位置// 计算可见数据
const visiableData = computed(() => {if (!virtualList.value) return []const startIdx = Math.floor(scrollTop.value / itemHeight)const containerHeight = virtualList.value.clientHeightconst visibleCount = Math.ceil(containerHeight / itemHeight)return list.value.slice(Math.max(0, startIdx - offsetBufferCount),Math.min(list.value.length, startIdx + visibleCount + offsetBufferCount))
})// 计算 ul 的偏移量(关键优化点!)
const ulOffsetY = computed(() => {const startIdx = Math.floor(scrollTop.value / itemHeight)return Math.max(0, startIdx - offsetBufferCount) * itemHeight
})// 容器总高度(用于撑开滚动条)
const containerHeight = computed(() => list.value.length * itemHeight)// 监听滚动事件(防抖优化)
let rafId: number | null = null
const handleScroll = () => {if (!virtualList.value) returnscrollTop.value = virtualList.value.scrollTop
}onMounted(() => {if (!virtualList.value) returnvirtualList.value.addEventListener('scroll', () => {if (rafId) cancelAnimationFrame(rafId)rafId = requestAnimationFrame(handleScroll)})
})onUnmounted(() => {if (rafId) cancelAnimationFrame(rafId)if (virtualList.value) {virtualList.value.removeEventListener('scroll', handleScroll)}
})
</script><template><div class="virtual-container" ref="virtualList"><!-- 占位容器(高度 = 总列表高度) --><div :style="{ height: `${containerHeight}px` }"><!-- ul 整体偏移(性能关键!) --><ul :style="{ transform: `translateY(${ulOffsetY}px)` }"><liv-for="item in visiableData":key="item.id"class="item":style="{ height: `${itemHeight}px` }">{{ item.content }}</li></ul></div></div>
</template><style scoped>
.virtual-container {width: 100%;height: 500px; /* 容器固定高度 */overflow: auto;position: relative;border: 1px solid #ccc;
}/* ul 绝对定位 + 偏移 */
ul {position: absolute;top: 0;left: 0;width: 100%;margin: 0;padding: 0;list-style: none;
}/* li 基础样式 */
.item {width: 100%;box-sizing: border-box;border-bottom: 1px solid #eee;padding: 10px;
}/* 滚动条美化(可选) */
.virtual-container::-webkit-scrollbar {width: 8px;
}
.virtual-container::-webkit-scrollbar-thumb {background: #888;border-radius: 4px;
}
</style>
📜 效果演示
但是当我们查看dom会发现,容器高度很高!!!! , 渲染一个高度过大的 div 会导致:
-
内存耗尽:浏览器需要为元素分配内存(尤其是包含复杂内容时)。
-
渲染卡顿或崩溃:渲染引擎(如 Blink、Gecko)可能无法处理超长布局。
🚧 第二版:使用使用 IntersectionObserver 动态管理占位高度
优化点说明
-
动态高度限制:
使用 visibleHeight 跟踪当前可视区域高度
dynamicContainerHeight 计算属性确保占位div不会过高
IntersectionObserver: 监听容器可视状态,当容器可见时,动态调整最大高度为可视区域的2倍
-
CSS优化:
添加 will-change: transform 提示浏览器优化
使用 contain: content 限制列表项的渲染边界
添加 -webkit-overflow-scrolling: touch 改善移动端滚动
-
性能平衡:
保持原始虚拟滚动的核心逻辑不变
只在必要时限制高度,不影响正常滚动体验
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'interface ListItem {id: numbercontent: stringindex: number
}// 列表数据(10,000 条)
const list = ref<ListItem[]>(Array.from({ length: 180000 }, (_, i) => ({id: i,content: `Item ${i}`,index: i,}))
)const virtualList = ref<HTMLElement | null>(null) // 容器 ref
const itemHeight = 50 // 每项固定高度(单位:px)
const offsetBufferCount = 5 // 上下缓冲条数
const scrollTop = ref(0) // 当前滚动位置
const visibleHeight = ref(0) // 动态可视区域高度// 计算可见数据
const visiableData = computed(() => {if (!virtualList.value) return []const startIdx = Math.floor(scrollTop.value / itemHeight)const containerHeight = virtualList.value.clientHeightconst visibleCount = Math.ceil(containerHeight / itemHeight)return list.value.slice(Math.max(0, startIdx - offsetBufferCount),Math.min(list.value.length, startIdx + visibleCount + offsetBufferCount))
})// 计算 ul 的偏移量
const ulOffsetY = computed(() => {const startIdx = Math.floor(scrollTop.value / itemHeight)return Math.max(0, startIdx - offsetBufferCount) * itemHeight
})// 容器总高度(用于撑开滚动条)
const containerHeight = computed(() => list.value.length * itemHeight)// 动态占位高度(限制最大高度)
const dynamicContainerHeight = computed(() => {return Math.min(containerHeight.value, visibleHeight.value || containerHeight.value)
})// 监听滚动事件(防抖优化)
let rafId: number | null = null
const handleScroll = () => {if (!virtualList.value) returnscrollTop.value = virtualList.value.scrollTop
}// IntersectionObserver 回调
const handleIntersection = (entries: IntersectionObserverEntry[]) => {entries.forEach(entry => {if (entry.isIntersecting && virtualList.value) {// 设置可视区域高度的5倍作为最大高度 (实际2倍就可以,5倍为了用户快速的滚动很远的位置)visibleHeight.value = virtualList.value.clientHeight * 5}})
}onMounted(() => {if (!virtualList.value) return// 初始化可视区域高度visibleHeight.value = virtualList.value.clientHeight * 2// 设置滚动监听virtualList.value.addEventListener('scroll', () => {if (rafId) cancelAnimationFrame(rafId)rafId = requestAnimationFrame(handleScroll)})// 设置IntersectionObserverconst observer = new IntersectionObserver(handleIntersection, {root: virtualList.value,threshold: 0.1})observer.observe(virtualList.value)// 在组件卸载时清理onUnmounted(() => {observer.disconnect()})
})onUnmounted(() => {if (rafId) cancelAnimationFrame(rafId)if (virtualList.value) {virtualList.value.removeEventListener('scroll', handleScroll)}
})
</script><template><div class="virtual-container" ref="virtualList"><!-- 动态高度的占位容器 --><div :style="{ height: `${dynamicContainerHeight}px`, position: 'relative' }"><!-- ul 整体偏移 --><ul :style="{ transform: `translateY(${ulOffsetY}px)` }"><liv-for="item in visiableData":key="item.id"class="item":style="{ height: `${itemHeight}px` }">{{ item.content }}</li></ul></div></div>
</template><style scoped>
.virtual-container {width: 100%;height: 500px; /* 容器固定高度 */overflow: auto;position: relative;border: 1px solid #ccc;-webkit-overflow-scrolling: touch; /* 改善移动端滚动 */
}/* ul 绝对定位 + 偏移 */
ul {position: absolute;top: 0;left: 0;width: 100%;margin: 0;padding: 0;list-style: none;will-change: transform; /* 提示浏览器优化 */
}/* li 基础样式 */
.item {width: 100%;box-sizing: border-box;border-bottom: 1px solid #eee;padding: 10px;contain: content; /* 优化渲染 */
}/* 滚动条美化 */
.virtual-container::-webkit-scrollbar {width: 8px;
}
.virtual-container::-webkit-scrollbar-thumb {background: #888;border-radius: 4px;
}
</style>
🚧 第三版:支持增量加载
✏️支持异步、分页等更多实际业务场景 ; 支持增量加载,节省首屏加载压力
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'interface ListItem {id: numbercontent: stringindex: number
}const list = ref<ListItem[]>([]) // 初始为空数组
const loading = ref(false) // 加载状态
const hasMore = ref(true) // 是否还有更多数据
const pageSize = 1000 // 每页加载数量
let currentPage = 0 // 当前页码// 虚拟列表相关ref和变量
const virtualList = ref<HTMLElement | null>(null)
const itemHeight = 50
const offsetBufferCount = 5
const scrollTop = ref(0)
const visibleHeight = ref(0)// 模拟API请求数据
const fetchData = async () => {if (loading.value || !hasMore.value) returnloading.value = true// 模拟网络延迟await new Promise(resolve => setTimeout(resolve, 500))// 生成新数据const newItems = Array.from({ length: pageSize }, (_, i) => {const index = currentPage * pageSize + ireturn {id: index,content: `Item ${index}`,index: index}})// 合并新数据list.value = [...list.value, ...newItems]currentPage++// 模拟总数据量为180000条if (list.value.length >= 180000) {hasMore.value = false}loading.value = false
}// 滚动时检查是否需要加载更多
const checkLoadMore = () => {if (!virtualList.value || !hasMore.value) returnconst { scrollHeight, scrollTop, clientHeight } = virtualList.value// 距离底部500px时加载更多if (scrollHeight - (scrollTop + clientHeight) < 500) {fetchData()}
}// 计算可见数据
const visiableData = computed(() => {if (!virtualList.value) return []const startIdx = Math.floor(scrollTop.value / itemHeight)const containerHeight = virtualList.value.clientHeightconst visibleCount = Math.ceil(containerHeight / itemHeight)return list.value.slice(Math.max(0, startIdx - offsetBufferCount),Math.min(list.value.length, startIdx + visibleCount + offsetBufferCount))
})// 计算列表偏移量
const ulOffsetY = computed(() => {const startIdx = Math.floor(scrollTop.value / itemHeight)return Math.max(0, startIdx - offsetBufferCount) * itemHeight
})// 容器总高度
const containerHeight = computed(() => list.value.length * itemHeight)// 动态高度(限制最大高度)
const dynamicContainerHeight = computed(() => {return Math.min(containerHeight.value, visibleHeight.value || containerHeight.value)
})// 滚动处理
let rafId: number | null = null
const handleScroll = () => {if (!virtualList.value) returnscrollTop.value = virtualList.value.scrollTopcheckLoadMore() // 滚动时检查是否需要加载
}onMounted(() => {if (!virtualList.value) return// 初始加载数据fetchData()visibleHeight.value = virtualList.value.clientHeight * 2virtualList.value.addEventListener('scroll', () => {if (rafId) cancelAnimationFrame(rafId)rafId = requestAnimationFrame(handleScroll)})const observer = new IntersectionObserver((entries) => {entries.forEach(entry => {if (entry.isIntersecting && virtualList.value) {visibleHeight.value = virtualList.value.clientHeight * 5}})}, {root: virtualList.value,threshold: 0.1})observer.observe(virtualList.value)onUnmounted(() => {observer.disconnect()})
})onUnmounted(() => {if (rafId) cancelAnimationFrame(rafId)if (virtualList.value) {virtualList.value.removeEventListener('scroll', handleScroll)}
})
</script><template><div class="virtual-container" ref="virtualList"><div :style="{ height: `${dynamicContainerHeight}px`, position: 'relative' }"><ul :style="{ transform: `translateY(${ulOffsetY}px)` }"><liv-for="item in visiableData":key="item.id"class="item":style="{ height: `${itemHeight}px` }">{{ item.content }}</li></ul></div><div v-if="loading" class="loading">加载中...</div></div>
</template><style scoped>
.virtual-container {width: 100%;height: 500px;overflow: auto;position: relative;border: 1px solid #ccc;-webkit-overflow-scrolling: touch;
}ul {position: absolute;top: 0;left: 0;width: 100%;margin: 0;padding: 0;list-style: none;will-change: transform;
}.item {width: 100%;box-sizing: border-box;border-bottom: 1px solid #eee;padding: 10px;contain: content;
}.loading {padding: 10px;text-align: center;color: #666;
}.virtual-container::-webkit-scrollbar {width: 8px;
}
.virtual-container::-webkit-scrollbar-thumb {background: #888;border-radius: 4px;
}
</style>
🚀 性能优化总结
🎯 核心目标
虚拟滚动的本质是减少真实 DOM 渲染数量,从而提升渲染性能、响应速度,特别是在处理数万甚至几十万条数据时,避免页面卡顿或崩溃。
🧠 关键优化点对比
优化维度 | 第一版 | 第二版 | 第三版 |
---|---|---|---|
DOM 节点数量控制 | ✅ 只渲染可见部分 | ✅ 相同机制 | ✅ 相同机制 |
滚动性能 | ✅ 使用 requestAnimationFrame | ✅ 相同机制 | ✅ 同样支持 |
占位高度 | ❌ 固定高度,可能过大 | ✅ IntersectionObserver 限制最大高度 | ✅ 动态控制容器最大高度 |
数据获取方式 | 🔁 静态一次性加载 | 🔁 静态一次性加载 | ✅ 支持增量加载,节省首屏加载压力 |
扩展能力 | 🚧 难扩展(如分页、懒加载) | 🚧 难扩展 | ✅ 支持异步、分页等更多实际业务场景 |
💬 总结
虚拟滚动并不是“新技术”,但却是现代前端工程化中极为重要的性能优化实践之一。
从列表到表格、从聊天窗口到文件系统浏览器,几乎所有大数据展示场景都可以应用虚拟滚动。
本系列通过 3 个版本逐步推进,从静态到动态、从偏移到懒加载,体现了性能优化背后的设计思维:只做该做的,提前准备好用户可能看到的。
后续封装成组件,供其他页面复用