在业务里碰到过一次很典型的场景:后台管理系统里的日志列表,一天就能攒下几万条数据,接到页面上直接一次性渲染。页面大概卡了三四秒才出来,滚动的时候帧率掉到个位数,CPU直接拉满,风扇响得跟起飞一样。后来花了两天时间把列表改成虚拟滚动,问题才彻底解决。
这个项目标题里提到的“虚拟滚动(vue)”,说白了就是解决一个非常朴素的问题:DOM节点太多了浏览器扛不住。如果你也遇到过列表渲染几千条、几万条数据时页面卡死、滚动掉帧、内存飙升的问题,这篇文章就是给你写的。我会从浏览器为什么卡开始讲,然后给出一套可以直接抄的 Vue 实现方案,再把动态高度、触底加载、白屏这些高频坑逐一拆开。
1. 为什么列表一多,浏览器就扛不住
1.1 先算一笔账:一万条 DOM 到底有多重
很多同学看到“一次渲染一万条数据”,第一反应是“不就一万个节点吗,能有多大事”。咱们算笔账就清楚了。
拿一个最常见的表格行为例:一天日志记录包含时间、用户、操作类型、备注摘要,大概是四到五个单元格。一万条数据就是五万个单元格节点,再加上外层容器、行元素,实际 DOM 节点数轻松超过六万。
Chrome 的 DOM 节点数量统计有个经验值:页面节点总数超过 1500 个就该警惕,超过 5000 个就要认真优化。六万个节点是什么概念?光是创建这些节点的内存开销,就已经让页面在低端机器上喘不过气了。
我实际测过一次:一万条记录、六万个 DOM 节点,页面加载时主线程阻塞 3.8 秒,滚动时帧率只有 4~6 FPS。用户拖动滚动条的时候,页面就像被按住了脖子,滚动条已经拖到底了,内容还在半空中晃晃悠悠地追。这种体验你我都遇过,没人想用第二次。
1.2 卡顿的真正瓶颈在哪儿
要解决卡顿,得先搞清楚浏览器到底在忙什么。
页面第一次渲染时,浏览器要做这几件事:解析 HTML 构建 DOM 树,解析 CSS 构建样式树,两棵树合成渲染树,然后进行布局(layout)、绘制(paint)和合成(composite)。
- 布局(Layout):浏览器要计算每一个节点的位置和尺寸。六万个节点,每一个都要参与计算。
- 绘制(Paint):生成每个节点的绘制指令。节点越多,绘制指令越庞大。
- 合成(Compositor):把绘制好的图层合成到屏幕上。图层太多时,合成开销同样不可忽视。
最要命的是重排(reflow)。只要用户滚动页面,浏览器就要重新计算可视区域内所有节点的新位置。这个计算量跟节点总数成正比——你 DOM 越大,每次滚动时的计算成本就越高,帧率自然就往下掉。
1.3 什么时候真的需要上虚拟滚动
不是说列表一长就必须上虚拟滚动,但有些信号出现了,基本就该动手了。
项目里我一般用这几个标准做判断:
- 数据条数超过 1000 条。1000 条以内的列表,一次渲染一般还能接受,交互也不算太卡。
- 滚动明显掉帧。用 Chrome DevTools 的 Performance 面板录制一段滚动操作,如果 FPS 持续在 30 以下,说明渲染管线已经吃不消了。
- 页面内存占用异常偏高。任务管理器里看浏览器进程,一个页面占了 1GB 以上内存,多半就是 DOM 数量失控了。
- 用户操作有明显延迟。点击按钮、输入文字要等几百毫秒才有响应,说明主线程被渲染任务长期占用。
出现任意一到两条,优先考虑虚拟滚动。但我也要泼一盆冷水:虚拟滚动只解决“渲染数量过多”的问题,不解决“数据获取本身慢”的问题。如果后端接口返回一万条数据就要两秒钟,该做的分页还是要做,两者不能互相替代。
2. 虚拟滚动的核心设计思路
2.1 一句话说清原理:只渲染看得见的
虚拟滚动的思路其实就是“按需渲染”——不管你的数据总量有多大,用户屏幕上能看到的区域始终只有一屏那么大。那就只把这一屏需要展示的条目渲染出来,其余不可见的部分用空白占位。
用一个最简单的生活场景来类比:你手里有一本一千页的词典,要查一个词。你不会把整本词典摊开摆在桌面上看,而是翻到那一页,看那一页的内容就够了。翻书的花费远小于把整本书背下来,虚拟滚动也是这个逻辑。
2.2 三个核心要素:可视区、缓冲区、总高度
一个最简单的虚拟滚动实现,需要三样东西:
- 可视区容器:页面上实际展示列表的那个框,它有一个固定的高度,超出这个高度的内容会被裁掉,
overflow-y: auto控制滚动。 - 内容占位层:因为只渲染部分条目,列表的真实高度会缩短。为了让滚动条的位置和大小符合用户的预期,需要用一个占位层把整个列表的总高度撑出来——这个高度就是“数据总量 × 每条数据的高度”。
- 可视条目集合:根据当前滚动位置,计算出应该显示哪些数据,渲染出这一小部分即可。
这三者配合起来的流程是:用户滚动 → 监听滚动位置 → 根据滚动位置算出起止下标 → 更新渲染集合。看起来不复杂,但实现时有不少细节值得斟酌。
2.3 为什么用 transform 而不是滚动定位
虚拟滚动里有一个很常见的设计:可视区域的条目不是渲染完后用 margin-top 或 padding-top 把内容推到正确位置,而是通过transform: translate3d(0, offset, 0)来做整体移位。
原因在于transform 不会触发页面重排,只会触发重绘和合成。重排的代价远大于重绘,而 transform 则被浏览器做了专门优化,通常在合成器线程中直接处理,主线程几乎不参与。
用 margin-top 撑开内容位置,在每次滚动更新时都可能导致整页重排;用 transform 则是直接告诉浏览器“画的时候往上平移一段距离就行”,性能差距非常明显。这个方案早就是虚拟滚动的主流选择了,新实现没必要再从 margin-top 走一遍弯路。
3. Vue 2 / Vue 3 实现虚拟滚动的完整方案
3.1 手写一个基础版虚拟滚动组件
考虑到现在新项目基本都切 Vue 3 了,我给一份基于组合式 API的实现。这段代码是固定高度数据列表的场景,也是虚拟滚动里最典型、最好入门的一种。
<template> <div ref="scrollContainer" class="virtual-list__container" @scroll="handleScroll" > <!-- 占位层:撑起滚动条,高度 = 数据总量 * 单项高度 --> <div class="virtual-list__phantom" :style="{ height: totalHeight + 'px' }" ></div> <!-- 真实渲染层:通过 transform 移动到正确位置 --> <div class="virtual-list__content" :style="{ transform: `translate3d(0, ${offsetY}px, 0)` }" > <div v-for="item in visibleItems" :key="item.id" class="virtual-list__item" :style="{ height: rowHeight + 'px' }" > <slot :item="item">{{ item.name }}</slot> </div> </div> </div> </template> <script setup> import { computed, onMounted, onBeforeUnmount, ref } from 'vue' const props = defineProps({ // 全量数据 items: { type: Array, default: () => [] }, // 每一项的固定高度(px) rowHeight: { type: Number, default: 40 }, // 视口可见高度(px),不传则自动测量容器高度 viewportHeight: { type: Number, default: 600 } }) // 滚动容器 const scrollContainer = ref(null) // 当前滚动的偏移距离 const scrollTop = ref(0) // 实际使用的视口高度 const actualViewport = computed(() => { return props.viewportHeight }) // 总高度 = 数据总条数 × 每项高度 const totalHeight = computed(() => { return props.items.length * props.rowHeight }) // 起始索引:当前滚动位置 / 单项高度,向下取整 const startIndex = computed(() => { return Math.floor(scrollTop.value / props.rowHeight) }) // 结束索引:起始索引 + 视口内可容纳的条数 + 缓冲区条数 const visibleCount = computed(() => { return Math.ceil(actualViewport.value / props.rowHeight) }) const endIndex = computed(() => { // 这里多加了一个 buffer,避免快速滚动时出现白屏 return Math.min( props.items.length, startIndex.value + visibleCount.value + 5 ) }) // 当前渲染的数据切片 const visibleItems = computed(() => { return props.items.slice(startIndex.value, endIndex.value) }) // 渲染层的偏移量:起始索引 × 单项高度 const offsetY = computed(() => { return startIndex.value * props.rowHeight }) // 滚动事件处理 function handleScroll() { if (!scrollContainer.value) return scrollTop.value = scrollContainer.value.scrollTop } onMounted(() => { const el = scrollContainer.value if (el) { // 读取容器的实际滚动高度 scrollTop.value = el.scrollTop } }) </script> <style scoped> .virtual-list__container { height: 100%; overflow-y: auto; position: relative; -webkit-overflow-scrolling: touch; } .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; } </style>使用的时候这样传参即可:
<template> <div style="height: 600px; border: 1px solid #eee"> <VirtualList :items="list" :row-height="40"> <template #default="{ item }"> <div class="row"> <span>{{ item.id }}</span> <span>{{ item.name }}</span> </div> </template> </VirtualList> </div> </template> <script setup> import VirtualList from '@/components/VirtualList.vue' const list = Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, name: `数据项-${i + 1}` })) </script>这套代码的核心逻辑不复杂,却包含了一个很容易踩的坑:我在计算endIndex时故意加了“5”这个值。这 5 条数据就是缓冲区,目的是防止用户快速滚动时,下一屏的内容还没渲染出来导致短暂白屏。这个细节你在简化的 demo 里经常看不到,但生产环境必须加上,不然很容易被业务方挑刺。
3.2 组件化封装与 props 设计考量
上面的实现把核心逻辑放在一个组件里,这种封装思路是刻意为之的。在实际项目里,列表的每一项往往不是固定的一种结构,比如有的是纯文本行,有的带图片,有的带操作按钮。
如果虚拟滚动组件只接收数据数组并渲染固定结构,复用性会很差。所以我选择在渲染时使用插槽(slot),让调用方自己决定每一项长什么样。这样虚拟滚动组件只关心“如何只渲染可视区域”这件事,不关心每一项的内容细节,职责边界非常清晰。
props 这里有一个设计取舍值得说明:视口高度我既支持外部传入,也支持组件自动测量。固定高度场景比较少,大多数情况下虚拟滚动容器是放在布局里的弹性区域,高度需要自动计算。组件化方案里可以在onMounted里通过getBoundingClientRect()读取容器实际高度,同时监听ResizeObserver来响应容器尺寸变化,这里为了把核心逻辑讲到最简,没有全部铺开,但你封装时一定要补上。
3.3 提升滚动流畅度的两个细节
基础版能跑,但离“丝滑”还有距离。在实际项目中我会额外做两件事。
第一件:滚动事件用 rAF 合并频率。
scroll事件触发频率很高,每滚动一像素就可能触发几次。如果每次都去计算、更新视图,主线程压力会比较大。合理做法是用requestAnimationFrame把计算合并到下一帧:
function handleScroll() { if (rafId) return rafId = requestAnimationFrame(() => { scrollTop.value = scrollContainer.value.scrollTop rafId = null }) }这样做的好处是,即使滚动事件触发一百次,实际计算也只在一帧内执行一次,保证计算频率和屏幕刷新率一致,不会做无用的中间计算。
第二件:给内容层加 will-change。
虚拟滚动里内容层会频繁变换 transform,建议加一个will-change: transform提示浏览器预先做优化。这个属性不要乱加,因为会额外占用 GPU 内存,但在这个场景下是合适的:
.virtual-list__content { will-change: transform; }实测结果:同样的数据量和容器尺寸,加完这两个优化后,滚动掉帧情况明显改善,在低端机器上的体感差距非常明显。
3.4 直接用现成库能省多少事
手写实现适合学习原理、应对简单场景。但如果是真实业务,我更推荐直接使用成熟的第三方库,尤其是以下几个:
- vue-virtual-scroller:社区最常用的 Vue 虚拟滚动库,支持固定高度和动态高度,功能最全,适配 Vue 2 和 Vue 3。
- vue-virtual-scroll-list:API 简洁,体积小,适合快速接入。
- vueuc 的 VVirtualList:Naive UI 作者维护的,质量很高,和 Vue 3 深度绑定。
选库的一个重要标准是看动态高度的支持程度。如果业务里的每条数据高度不确定,手写需要做大量测量和缓存工作,而成熟的库一般已经把这类场景做好了,直接用能省不少时间。
但也要提醒一句:库不是万能的。接入库之前,最好把上一节讲的虚拟滚动原理搞明白。一旦库出问题(比如滚动错位、白屏),你需要有能力打开源码去排查,而不是对着黑盒干瞪眼。
4. 动态高度场景的难点与应对
4.1 固定高度和动态高度的本质差别
上面那个基础组件,所有条目的rowHeight都是一样的,计算起来就很简单:起始索引 = 滚动位置 ÷ 行高。但真实业务里,行高往往是变化的——有的行就一句话,有的行内容撑开成三行,还有的行带了图片。一旦每条数据的高度不确定,上面那套除法公式就失效了。
动态高度是虚拟滚动实现里的一个大坎,也是很多团队接虚拟滚动时踩坑最深的地方。
如果强行用固定高度去渲染动态内容,会出现几个典型问题:
- 内容被截断:容器高度固定 40px,但这条数据实际需要 80px,超出的部分显示不出来。
- 滚动条总高度错误:总高度是按照固定值算的,但实际总高度远大于或小于这个测算值,滚动条位置对不上。
- 滚动时错位:某一项的实际高度比预期高,后续所有条目的位置预测都失效,越滚越偏。
4.2 预估高度加缓存修正方案
目前业界相对成熟的动态高度虚拟滚动方案,是“预估高度 + 实测修正 + 缓存位置”。核心流程分三步:
第一步,给每个条目一个预估高度。比如大部分文本行取 40px,带图片的取 120px。预估不需要精确,但尽量接近真实值,误差越小滚动越稳。
第二步,渲染后读取每一项的真实高度。通过 ref 拿到渲染出来的 DOM,执行offsetHeight或getBoundingClientRect(),记录该条目的真实高度。
第三步,修正缓存并调整总高度和各项位置。把真实高度存入一个 Map,后续滚动计算时优先使用缓存里的真实高度,没有缓存才用预估高度。同时基于真实高度重新计算该项的偏移量,修正后续所有条目的位置。
核心代码大致是这个思路:
// 缓存每一项的真实高度 const heightCache = reactive(new Map()) // 保存每一项的偏移位置 const positionMap = new Map() function measureItem(index) { const el = itemRefs.value[index] if (!el) return const realHeight = el.offsetHeight heightCache.set(index, realHeight) recalcPosition(index) } function recalcPosition(startIndex) { let offset = startIndex > 0 ? positionMap.get(startIndex - 1).bottom : 0 for (let i = startIndex; i < props.items.length; i++) { const height = heightCache.get(i) || estimatedHeight const top = offset const bottom = offset + height positionMap.set(i, { top, bottom, height }) offset = bottom } totalHeight.value = offset }这个方案的核心思想是:先用粗糙的估计撑起滚动条,再用真实测量迭代修正。滚动过程中用户感受到的轻微跳动,就是修正过程中的正常现象,随着缓存逐渐完善,跳动会越来越少。
4.3 触底加载与下拉刷新结合时的注意点
生产环境里的列表,一般不会是静态全量数据,更多是“分页加载”或“触底加载”的形态。虚拟滚动跟这两者结合时,有几个特别的坑。
坑一:触底判断不能用页面级滚动条。很多同学习惯用window.scrollY + window.innerHeight >= document.body.scrollHeight来判断触底。虚拟滚动里,列表容器的滚动是独立在某个 div 里的,正确做法是监听容器自身的 scroll 事件,判断scrollTop + clientHeight >= scrollHeight - 阈值。我一般设 100px 作为预加载阈值,提前加载下一批数据,比到顶了再加载体验好很多。
坑二:数据插入位置会影响滚动位置。如果你的列表是“追加到底部”,滚动位置保持不动即可,这没什么问题。但如果是“顶部插入”(比如新的日志从头部插入),原来的可视内容会往下推,滚动位置需要同步修正。方案统一做法是:记录当前可视列表的第一个数据 id,在数据插入后,重新找到这个 id 的索引,把它的位置设置回原来的偏移量。
坑三:数据更新不能全量 reset。刚接手虚拟滚动代码时,我犯过一个错误:分页返回后直接把 items 整个替换。结果滚动位置瞬间回到起点,用户滑到第十页再翻回第一页,又要重新滑很久。正确做法是保留旧数据,把新数据追加到数组尾部,并更新占位层总高度。
5. 常见问题与排查技巧实录
5.1 列表底部出现大片空白
这个现象在虚拟滚动里出现频率非常高。表现是:滚动到接近底部时,明明还有数据,但渲染区域下方出现了大段空白,再滚一点才出现新内容。
排查思路按顺序走:
- 检查 endIndex 是否算对了。拿“滚动位置 ÷ 行高 + 可视条数 + 缓冲区”算一下,看 endIndex 是否已经覆盖到当前视线所在的真实条目索引。如果 endIndex 算少了,底部就必然有一段内容没被渲染出来。
- 检查占位层高度是否正确。总高度 = 数据条数 × 每项高度。如果总高度算小了,滚动条底部会提前到顶,数据没渲染完全。
- 检查是“白屏”还是“空白”。白屏是内容层没有数据,空白是内容层的数据没有移动到正确位置。后者通常是 transform 偏移量算错,检查 offsetY 是否为“起始索引 × 行高”。
5.2 滚动条位置跳动或尺寸异常
滚动条跳来跳去,多半是总高度的计算和实际渲染内容不一致。
在固定高度场景下,问题一般出在行高设置不统一。比如你以为每行 40px,实际上有一条数据内容多了一点,实际行高到了 60px,总高度就比计算值大了。用户在滚动时能看到内容位置和滚动条的对应关系明显错位。
排查时先确认:每一条数据是否都严格应用了设定的行高。很多组件库自带的边距、padding 会撑大实际高度,导致计算失真。在固定高度场景里,给行元素设置统一的行高、去掉 margin,可以规避不少问题。如果是动态高度场景,排查重点就放在缓存修正逻辑是否生效,确认 positionMap 是否在数据更新时被重置了。
5.3 使用了虚拟滚动仍然会卡
用户反映“比原来还卡”,这种情况我遇到过两次。
第一次是列表项内部的组件太重。虚拟滚动只是减少了 DOM 数量,但如果可视区域内每一条数据都包含一个复杂的图表、一个富文本编辑器,那就算只渲染二十条,计算量依然很大。这其实不是虚拟滚动能解决的范围,需要配合列表项拆分组件的懒渲染、延迟渲染来做。
第二次是没有加 key 导致的复用问题。虚拟滚动的数据切片是高频变化的,如果列表项的 key 不稳定(比如用了 index),Vue 的 diff 算法会大量走“销毁重建”,性能远低于“复用移动”。必须给列表项一个稳定且唯一的业务 id 作为 key,这一条一定要检查。
5.4 排查有没有真正生效
有同学反馈“加了虚拟滚动的代码,但 DOM 节点数还是很多”。这种情况先看 DevTools 的 Elements 面板,检查列表容器下是不是只有几十个.virtual-list__item。如果看到的 item 数量接近全量数据条数,说明组件没有按预期渲染,很可能是你修改了items数组之后,Vue 的响应式更新没有触发visibleItems重新计算。
顺便提一个辅助手段:Chrome DevTools 的 Performance Monitor 面板里有一个 DOM Node Count 指标,可以实时看到页面节点数。如果虚拟滚动生效,这个数字应该维持在较低的稳定区间,不会随数据量增大而线性上涨。
5.5 Vue 2 和 Vue 3 实现的差异点
还有个现实问题:公司老项目跑在 Vue 2 上,怎么改?
核心思路和 Vue 3 版本完全一致,只是语法从 Options API 改成 Composition API 或者继续用 Options API 都行。有几个实现上的差异值得注意:
- 响应式更新监听:Vue 2 里对数组下标赋值不走响应式,所以修改缓存 Map 时要格外小心。数组用
this.$set或整体替换方式更新。 - 作用域插槽:Vue 2 里是
slot-scope,Vue 3 是v-slot,封装组件时语法不同。 - SSR 兼容:虚拟滚动依赖 DOM 测量,服务端渲染时没有真实 DOM,需要在 mounted 之后再执行初始化逻辑,这一点 Vue 2 和 Vue 3 都要注意。
如果老项目用的组件库自带虚拟滚动(比如 Element Plus 新版表格自带),优先用组件库的能力,实在没有再看手写方案。
6. 写在最后的一点心得
这套方案上线之后,性能数据变化很明显:同样一万条数据,原来页面加载要 3.8 秒、滚动帧率只有 4~6 FPS,改造后页面加载降到 0.4 秒左右,滚动帧率稳定在 55 帧以上,内存占用也掉了将近一半。
我自己做下来最想说的一个体会是:虚拟滚动不是银弹,别再以为它是“万能列表优化方案”。它适合纯展示型、数据量大、滚动频度高的场景。但如果你需要逐行编辑、行内嵌复杂交互组件、或对滚动位置精度有很高要求,虚拟滚动会带来额外的复杂度,这时候要先想清楚值不值。
如果你也是从“列表卡顿”这个问题搜到这个页面,我的建议是:先算清楚你的数据量和 DOM 量,再评估是否值得引入虚拟滚动。如果确实需要,先跑通固定高度基础版,再逐步加上动态高度、触底加载这些能力。遇到问题时,优先怀疑 index 计算和总高度这两块,大部分坑都出在那里。