Vue虚拟滚动原理与实现:解决大数据量列表渲染性能瓶颈
发布时间:2026/9/14 19:16:33 作者:尧图编辑部 阅读量:1,286

1. 问题定位为什么列表一旦数据量上来浏览器就卡成PPT先聊个我上周刚踩完的坑。后台管理系统里有一个操作日志列表接口一次返回两万条记录前端拿到数据直接v-for渲染结果Chrome标签页风扇瞬间起飞滚动页面的时候掉帧掉到肉眼可见的卡顿连输入框敲字都延迟半拍。后来用 Performance 面板录了一段发现脚本执行时间占了总耗时的62%渲染和绘制占了28%光 DOM 节点就有四万多个。这个问题的根源不在于 Vue 本身而是浏览器渲染引擎的工作方式决定的。每一个 DOM 节点在页面里都是一个真实的渲染对象节点越多浏览器需要做的样式计算、布局排版、绘制合成的工作量就越大。尤其当你在一个页面里塞进去几千个列表项每项的类名、内联样式、文本内容都会参与计算滚动的时候浏览器还会频繁触发回流和重绘直接拖垮主线程。更麻烦的是DOM 节点一多事件监听也成了隐患。传统做法是给每个列表项绑定一个事件两万个节点就是两万个监听器即使 Vue 做了组件化复用事件委托没做好照样GG。有基础的朋友可能会想用v-if配合懒加载行不行不行懒加载只是延后渲染数据量一旦上来了DOM 节点数量并没有实质减少。还有一种思路是分页但用户的需求是滚动浏览一次只看20条会损失体验。所以这时候就需要虚拟滚动这个方案。虚拟滚动要解决的核心问题只有一个不管你底层有多少条数据页面上同时只渲染视口内能看到的那几十个节点。你有一万条数据页面里永远只有30个DOM节点在干活滚动的时候动态替换这些节点的内容把渲染成本从 O(N) 降到 O(可视区节点数)。2. 虚拟滚动的核心设计思路与原理拆解2.1 从“全量渲染”到“按需渲染”的思维转变刚开始接触虚拟滚动的人最困惑的一件事就是滚动条的长度怎么模拟页面里明明只有30个DOM节点滚动条撑不起来怎么办答案是用两层容器来骗过浏览器。外层是一个高度固定的滚动容器也就是用户真正能看到和操作的滚动区域内层分别放置一个“撑高容器”和一个“可视区容器”。撑高容器的高度等于全部数据的总高度负责把滚动条的轨道撑到正常长度让用户觉得滚动范围就是两万条数据的总长度。可视区容器通过绝对定位或者 transform 的方式固定在滚动容器内只渲染当前视口范围内的数据。滚动的过程就是三个数字的联动滚动偏移量、起始索引、结束索引。用户每滚动一点就计算当前滚出去了多少像素除以单行高度就能得到当前应该从数组的哪个位置开始截取再加上视口高度能容纳的行数就得到了结束位置。计算出索引区间后把这批数据渲染出来同时给可视区容器设置一个等于起始行偏移量的translateY或top偏移数据就能严丝合缝地出现在正确的位置上。这里有个关键点起始索引不是直接等于滚动距离除以行高。因为用户可能是连续滚动的每次滚动的距离不一定正好是行高的整数倍所以要用Math.floor向下取整保证第一行永远完整地出现在视口顶部。这样能避免滚动过程中出现整个列表上下跳动的问题。2.2 三个关键参数的计算方法动手写代码之前建议先把下面三个参数算清楚这是整个虚拟滚动组件的基石。第一个参数是可视区高度也就是外层滚动容器的高度。这个值可以由外层容器的 CSS 高度决定也可以在组件 mount 后通过clientHeight获取。如果你的列表是自适应高度窗口的建议用ResizeObserver监听容器尺寸变化窗口拉伸时自动更新可视区高度。第二个参数是预估行高。固定的行高是虚拟滚动最容易实现的情况比如每行 60px那么可视区能渲染的行数就是可视区高度 / 60。但实际情况中列表项的高度可能是参差不齐的有的行有两行文字有的行只有一行。这就需要对行高做预估取一个平均值比如 80px。预估行高的误差会导致滚动条的轨道长度与实际数据总高度不一致这也是后面第4章节要解决的动态高度问题的根源。第三个参数是缓冲数量。不管怎么计算滚动瞬间的渲染都会有一定的延迟如果刚好滚动到可视区边缘就开始加载用户会看到白屏一闪而过。解决方法是上下各多渲染几行比如bufferSize 5当前计算范围是 10 到 50实际渲染 5 到 55。多出来的行会藏在视口外的滚动区域内用户滚到那边的时候数据已经渲染好了体感上就非常顺滑。2.3 为什么虚拟滚动能显著降低渲染压力这个方案的优势直接体现在数据上。假设你有 10 万条数据每条是一个完整的 DOM 节点全量渲染会有至少 10 万个 HTML 元素Chrome 的渲染树构建时间随节点数线性增长。而虚拟滚动把可视区设置为 10 行只维护 102×buffer 个节点也就是二三十个。即使加上浏览器的内部缓存和重绘区域最多也就几十个节点参与渲染。还有一个很多人容易忽略的优势内存占用。Vue 框架对每个渲染出来的组件都会创建对应的组件实例存有各种响应式绑定、生命周期钩子、虚拟节点引用。全量渲染一万个列表项就意味着创建了一万个组件实例哪怕数据只有几十KB内存里的实例对象也能轻松吃掉几百MB。虚拟滚动下同一时间只有几十个组件的实例存活滚动过程中旧组件被销毁、新组件被创建内存始终维持在一个低位长时间运行的页面也不会变成吃内存的怪兽。3. 手写一个Vue虚拟滚动组件完整可复现3.1 组件构造函数与响应式数据设计直接上一份能用的代码基于 Vue 3 Composition API 实现适合列表项高度固定的场景。如果你的项目用的是 Vue 2在数据定义和生命周期钩子名上做一点小改动即可。template div refscrollContainer classvs-container scrollhandleScroll div classvs-phantom :style{ height: totalHeight px }/div div classvs-content :style{ transform: translateY(${offsetY}px) } div v-foritem in visibleData :keyitem.id classvs-item :style{ height: rowHeight px } slot :itemitem/slot /div /div /div /templateimport { ref, reactive, computed, onMounted } from vue export default { name: VirtualList, props: { // 数据源可以是任意长度 list: { type: Array, required: true }, // 每行高度 rowHeight: { type: Number, default: 60 }, // 可视区高度不传则自动撑满容器 height: { type: Number, default: null }, // 缓冲行数 bufferSize: { type: Number, default: 5 }, }, setup(props, { slots }) { const scrollContainer ref(null) // 当前滚动偏移量单位px const scrollTop ref(0) // 可视区实际高度 const viewportHeight ref(props.height || 0) // 可视区可渲染的行数向上取整保证边界完整 const visibleCount computed(() { return Math.ceil(viewportHeight.value / props.rowHeight) }) // 起始索引缓冲向上多算几行 const startIndex computed(() { const currentStart Math.floor(scrollTop.value / props.rowHeight) return Math.max(0, currentStart - props.bufferSize) }) // 结束索引缓冲向下多算几行 const endIndex computed(() { return Math.min( props.list.length, startIndex.value visibleCount.value props.bufferSize * 2 ) }) // 当前真正需要渲染的数据 const visibleData computed(() { return props.list.slice(startIndex.value, endIndex.value) }) // 撑高容器总高度 const totalHeight computed(() { return props.list.length * props.rowHeight }) // 内容区域的偏移量 const offsetY computed(() { return startIndex.value * props.rowHeight }) // 滚动处理函数 const handleScroll () { scrollTop.value scrollContainer.value.scrollTop } onMounted(() { if (!props.height) { viewportHeight.value scrollContainer.value.clientHeight } }) return { scrollContainer, visibleData, totalHeight, offsetY, handleScroll, } }, }.vs-container { position: relative; height: 100%; overflow-y: auto; } .vs-phantom { position: absolute; top: 0; left: 0; right: 0; z-index: -1; } .vs-content { position: absolute; top: 0; left: 0; right: 0; }这个组件的使用方式很灵活父组件只需要传一个列表数组和行高列表项的内容通过插槽自定义。比如VirtualList :listrowList :row-height72 template v-slot{ item } div classlog-item span classlog-username{{ item.user }}/span span classlog-action{{ item.action }}/span span classlog-time{{ item.time }}/span /div /template /VirtualList3.2 关键代码的意图与为什么这样做第一撑高容器为什么要用position: absolute配合z-index: -1因为滚动容器的overflow-y: auto会裁剪所有子元素如果撑高容器是普通文档流中的元素它会直接撑开父级的高度导致父级撑到一万条数据那么高滚动条还是由它控制虚拟滚动就失效了。使用绝对定位把撑高容器从文档流中剥离同时让它的高度参与父级滚动高度计算才是正确的做法。第二内容区为什么要用transform: translateY而不是top: xx px两者都能达到偏移的效果但transform只触发合成器层面不触发重排重绘性能更好。老版本的浏览器对transform支持不够好如果你的项目要兼容 IE 或很老的 Chrome可以改用top定位但新项目一律建议transform。第三数据截取用slice而不是手动遍历。slice返回的是一份浅拷贝的数组Vue 对数组的响应式追踪会精确到每个元素新数组里的对象还是原来的引用不会触发大规模的依赖更新。如果手动遍历生成新数组容易引发 Vue 的响应式细粒度更新造成额外的性能损耗。3.3 mount 后的高度获取时机组件挂载后clientHeight才能正确获取到容器的实际高度。如果你传入的height是 0 或者null需要等真实 DOM 渲染完成后再取。Vue 3 中onMounted钩子能保证 DOM 已经插入文档但有一个坑如果外层元素本身还没有被布局完成clientHeight依然可能是 0。这时候可以加一个nextTick的保险import { nextTick } from vue onMounted(async () { await nextTick() viewportHeight.value scrollContainer.value.clientHeight })如果你的项目里有图片等异步资源导致容器高度变化建议在onMounted里面加一个全局resize监听或者直接用ResizeObserver这样列表的响应性会更好。4. 实操过程中的几个坑与性能优化技巧4.1 滚动白屏与闪烁bufferSize 不是越大越好虚拟滚动最常见的Bug就是滚动过程中出现白屏。比如你快速拖动滚动条到列表中部浏览器还没来得及渲染新的可视区域页面就会显示空白。解决这个问题的标准做法就是加缓冲行数。bufferSize太小滚动一只脚就踩到边界白屏出现的概率高bufferSize太大DOM 节点数量增加又失去了虚拟滚动的意义。我实践下来的经验是固定行高场景下bufferSize取 5 到 10 就非常理想了。假如你的行高是 60px视口高度是 600px可视区域内是 10 行加 10 行缓冲就是 20 个节点即使快速滚动到中间也有足够的时间渲染目标区域。另一个解决办法是给滚动容器设置content-visibility: auto。这个 CSS 属性可以跳过视口外元素的渲染进一步降低卡顿。不过要注意它的兼容性问题Safari 直到最近版本才支持用之前查一下需要支持的浏览器列表。4.2 数据总量不一致导致滚动条跳动如果你的数据源是异步加载的比如先加载第一页 1000 条滚动到底部后再追加 1000 条这时候totalHeight会突变滚动条位置可能瞬间跳回顶部。这个问题很多人踩过。解决办法是把撑高容器的高度计算从props.list.length改成实例上的一个totalCount变量追加数据时手动更新而不是每次都通过 computed 重新计算。或者如果你确信所有数据一次性加载完成把它们放在同一个数组里再传入组件这样totalHeight就是稳定值。4.3 事件处理与对象复用列表项内部如果有按钮或者点击操作常规写法是clickhandleClick(item)但虚拟滚动下组件频繁创建销毁闭包会导致旧数据残留。尽量把事件委托到滚动容器上通过事件对象里的>