一、问题:为什么需要虚拟滚动
当一个列表需要渲染 成百上千条数据 时,传统的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 class="phantom" :style="{ height: totalHeight + 'px' }"></div> // totalHeight = 1000条 × 120px = 120,000px2. 内容层(Content Div)
通过transform: translateY(offsetY)定位,只包含当前可视区域的数据。通过 CSSwill-change: transform告知浏览器提前优化。
<div class="content" :style="{ transform: `translateY(${offsetY}px)` }"> <div v-for="item in visibleItems" :key="item.id"> {{ item.name }} </div> </div>3. 可视区间计算
根据当前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 class="vl-container" ref="containerRef" @scroll="onScroll"> <div class="vl-phantom" :style="{ height: totalHeight + 'px' }"></div> <div class="vl-content" :style="{ transform: `translateY(${offsetY}px)` }"> <div v-for="item in visibleItems" :key="item._vKey" class="vl-item" :style="{ height: itemHeight + 'px' }"> <slot :item="item" :index="item._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-if="false"替换原来的条件 | 组件仍然挂载、接收数据,但不渲染列表 |
| 2. 打标记 + 暴露方法 | 数据加sourceType、sourcePriority、_vKey;defineExpose({ getData }) | 父组件能识别数据来源并收集 |
| 3. 去掉 scoped | <style lang="scss">去掉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 | ~25 | 400x |
| 首次渲染时间 | 800ms+ | <50ms | 16x |
| 滚动帧率 | 15-30 FPS | 58-60 FPS | 2-4x |
| 内存占用 | ~300MB | ~15MB | 20x |
| 数据更新 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-show="false"而不是v-if="false"。因为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 实际项目经验总结。如有疑问,欢迎讨论。