当前位置: 首页 > news >正文

虚拟滚动组件优化记录

虚拟滚动组件优化记录

📘 版本说明

本系列文档记录了虚拟滚动(Virtual Scroll)组件的多版本迭代和优化过程。用于展示如何处理大规模数据列表渲染,提升性能体验。


🌀 第一版:基础虚拟列表(transform 优化)

✅ 实现特点

  • 使用 transform: translateY(...) 实现虚拟滚动偏移。
  • 不为每项设置绝对定位,使用 ul 偏移整体列表。
  • 渲染性能优于传统的每项 transformabsolute 定位方式。
  • 支持 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 个版本逐步推进,从静态到动态、从偏移到懒加载,体现了性能优化背后的设计思维:只做该做的,提前准备好用户可能看到的。

后续封装成组件,供其他页面复用

http://www.xdnf.cn/news/128359.html

相关文章:

  • Linux基础使用-笔记
  • MQTT 之 EMQX
  • 运维的概述
  • 大数据去重
  • 【element plus】解决报错error:ResizeObserver loop limit exceeded的问题
  • 长城智驾重复造轮子
  • LLM微调与LoRA技术
  • 深入探索RAG(检索增强生成)模型的优化技巧
  • 数字人接大模型第一步:表情同步
  • 【Java Card】CLEAR_ON_DESELECT和CLEAR_ON_RESET的区别
  • 卷积神经网络(二)
  • 10.接口而非实现编程
  • 2024武汉邀请赛B.Countless Me
  • 常见的限流算法
  • 对patch深入理解下篇:Patch+LSTM实现以及改进策略整理
  • web 分页查询 分页插件 批量删除
  • UE5 调整字体、界面大小
  • 方案研读:106页华为企业架构设计方法及实例【附全文阅读】
  • DMA介绍
  • SFINAE(Substitution Failure Is Not An Error)
  • YCDISM2025-更新
  • 2772.使数组中的所有元素都等零 妙用差分!
  • chili3d调试笔记9 参数化建模+ai生成立方体
  • C++基础概念补充4—命名空间
  • 1.2 java的语法以及常用包(入门)
  • 关于Tecnomatix Plant Simulation 3D模型保存过慢的问题解决方案
  • 优考试V4.20机构版【可注册】
  • 发送网络请求
  • Linux用户管理实战:创建用户并赋予sudo权限的深度解析
  • LLM 大模型快速入门