
一、问题为什么需要虚拟滚动当一个列表需要渲染 成百上千条数据 时传统的v-for全量渲染方案会创建等量的 DOM 节点。假设每条列表项高度 120px、包含 10 个子元素1000 条数据就意味着 10,000 个 DOM 节点。大量 DOM 节点带来的性能问题包括性能瓶颈首次渲染慢— 浏览器需要计算 10,000 个节点的布局、样式、绘制滚动卡顿— 每次滚动触发重排/重绘帧率暴跌内存占用高— 每个节点占用几十 KB万级节点轻松吃掉几百 MB数据更新慢— Vue 的 diff 算法需要遍历整个列表虚拟滚动的核心思想用户在任何时刻只能看到屏幕上的十几条数据其余的都是不可见的。既然不可见就不需要渲染 DOM。二、虚拟滚动核心原理图 1: 虚拟滚动核心原理 — 幻影层撑高度内容层只渲染可视区虚拟滚动由 三个关键层 组成1. 幻影层Phantom Div一个没有任何内容的div高度等于items.length × itemHeight。它的作用是撑起滚动容器的总高度让滚动条的比例和真实数据量一致。div classphantom :style{ height: totalHeight px }/div // totalHeight 1000条 × 120px 120,000px2. 内容层Content Div通过transform: translateY(offsetY)定位只包含当前可视区域的数据。通过 CSSwill-change: transform告知浏览器提前优化。div classcontent :style{ transform: translateY(${offsetY}px) } div v-foritem in visibleItems :keyitem.id {{ item.name }} /div /div3. 可视区间计算根据当前scrollTop和容器高度计算出应该渲染哪些条目startIndex floor(scrollTop / itemHeight) - bufferSize endIndex ceil((scrollTop containerHeight) / itemHeight) bufferSize offsetY startIndex × itemHeight其中bufferSize是上下缓冲区条数通常 3~5 条确保滚动时上下有预渲染内容避免快速滚动时出现白屏。三、Vue 3 实现一个通用虚拟列表组件下面是一个生产可用的VirtualList.vue组件支持slot自定义渲染、ResizeObserver自适应、requestAnimationFrame滚动节流。template div classvl-container refcontainerRef scrollonScroll div classvl-phantom :style{ height: totalHeight px }/div div classvl-content :style{ transform: translateY(${offsetY}px) } div v-foritem in visibleItems :keyitem._vKey classvl-item :style{ height: itemHeight px } slot :itemitem :indexitem._realIndex/slot /div /div /div /template script setup import { ref, computed, onMounted, onUnmounted, nextTick } from vue const props defineProps({ items: { type: Array, default: () [] }, itemHeight: { type: Number, default: 120 }, bufferCount: { type: Number, default: 5 } }) const containerRef ref(null) const scrollTop ref(0) const containerHeight ref(0) let ticking false const totalHeight computed(() props.items.length * props.itemHeight) const startIndex computed(() { let idx Math.floor(scrollTop.value / props.itemHeight) - props.bufferCount return Math.max(0, idx) }) const endIndex computed(() { let idx Math.ceil( (scrollTop.value containerHeight.value) / props.itemHeight ) props.bufferCount return Math.min(props.items.length, idx) }) const offsetY computed(() startIndex.value * props.itemHeight) const visibleItems computed(() { return props.items.slice(startIndex.value, endIndex.value) .map((item, i) ({ ...item, _realIndex: startIndex.value i })) }) const onScroll () { if (containerRef.value) { scrollTop.value containerRef.value.scrollTop } if (!ticking) { requestAnimationFrame(() { ticking false }) ticking true } } let resizeObserver null onMounted(() { nextTick(updateContainerHeight) resizeObserver new ResizeObserver(updateContainerHeight) if (containerRef.value) resizeObserver.observe(containerRef.value) }) onUnmounted(() { resizeObserver?.disconnect() }) /script设计要点slot 渲染— 通过#default{ item }让父组件决定每条数据的渲染方式组件本身不关心业务逻辑ResizeObserver— 监听容器尺寸变化窗口 resize 时自动重算可视区间RAF 节流— 滚动事件每秒触发几十次用requestAnimationFrame保证一帧只渲染一次四、多数据源合并方案实际项目中一个页面可能有 多个数据源如单站定位、TDOA、AOA、雷达、5G-A 等每个数据源独立接收 socket 推送。如果每个数据源各自渲染一个列表DOM 节点会成倍增长。解决方案合并成一条列表用虚拟滚动统一渲染。图 2: 多数据源合并架构 — 子组件接收数据父组件定时合并排序数据流设计// 父组件定时收集子组件数据 排序 const startMergeTimer () { setInterval(() { let all [ ...(singleStationRef.value?.getData() || []), ...(tdoaRef.value?.getData() || []), ...(aoaRef.value?.getData() || []), ...(radarRef.value?.getData() || []), ] // 按优先级升序同级按时间倒序 all.sort((a, b) { if (a.sourcePriority ! b.sourcePriority) return a.sourcePriority - b.sourcePriority return (b.timestamp || 0) - (a.timestamp || 0) }) mergedList.value all }, 500) }子组件改造三步走步骤改动目的1. 禁用自身渲染v-iffalse替换原来的条件组件仍然挂载、接收数据但不渲染列表2. 打标记 暴露方法数据加sourceType、sourcePriority、_vKeydefineExpose({ getData })父组件能识别数据来源并收集3. 去掉 scopedstyle langscss去掉scoped样式在 VirtualList slot 中也能生效五、超时自动清理机制每条数据代表一个持续推送的目标如一架无人机。当某个 socket 停止推送 后该数据超时未更新就需要自动从列表中删除。每个子组件在接收数据时记录时间戳定时检查是否超时// 子组件内定时清理过期数据 setInterval(() { let needRefresh false for (const key in onlyData.value) { if (Date.now() - onlyData.value[key].timestamp timeoutSeconds * 1000) { delete onlyData.value[key] // 彻底删除 needRefresh true } } if (needRefresh) { dataList.value Object.values(onlyData.value) } }, 1000)关键设计彻底删除 vs 过滤隐藏— 不要只做v-if过滤显示要用delete从源对象中删除否则数据永远占内存清理时机— 每秒检查一次即可不需要更频繁联动效果— 子组件删除数据后下一次 500ms 合并时父组件自动感知VirtualList 自然不再渲染该条目六、性能对比指标传统 v-for虚拟滚动提升1000 条数据 DOM 节点数~10,000~25400x首次渲染时间800ms50ms16x滚动帧率15-30 FPS58-60 FPS2-4x内存占用~300MB~15MB20x数据更新 diff遍历全量仅可视区N/A为什么定高是虚拟列表的前提定高让startIndex、endIndex、offsetY的计算成为 O(1) 的数学运算。如果高度不定就需要维护一个位置映射表每次二分查找复杂度变为 O(log n)。不定高度的替代方案如果业务上无法做到定高有两种方案1. 估算 实测修正— 先用估算高度渲染渲染后用ResizeObserver测量真实高度更新位置映射表2. 使用成熟库—vue-virtual-scroller、tanstack/vue-virtual都支持动态高度七、踩坑记录1. 子组件 v-if vs v-show改造子组件时必须用v-showfalse而不是v-iffalse。因为v-if会彻底销毁组件导致 socket 数据接收函数和定时器全部失效。2. 样式隔离问题Vue 的style scoped会给选择器加[data-v-xxx]属性限定。当卡片 HTML 从子组件搬到父组件的 slot 后scoped样式失效。解决方案是去掉子组件的scoped或用:deep()穿透。3. 上下间距过大虚拟列表的itemHeight是固定的如果卡片实际高度小于itemHeight中间就会出现空白。解决方案itemHeight设为卡片展开后的最大高度或使用动态高度方案。4. 翻译 key 作用域当卡片 HTML 搬到父组件 slot 后$t(xxx)的翻译 key 需要确保在父组件的 i18n 上下文中也能正确解析。如果子组件和父组件使用不同的 i18n 实例需要统一。八、总结虚拟滚动的本质是一个空间换时间的策略用少量 DOM 节点 数学计算模拟出完整列表的视觉效果。多数据源合并的关键在于子组件只负责数据接收和业务逻辑父组件负责统一渲染。核心公式实际 DOM 数 (containerHeight / itemHeight) bufferSize × 2 // 例(600 / 120) 5 × 2 15 个 DOM 节点渲染 1000 条数据这种方案特别适合实时推送场景如无人机监控、股票行情、物联网设备列表数据持续涌入的同时保持界面流畅。本文基于 Vue 3 Element Plus 实际项目经验总结。如有疑问欢迎讨论。