Vue3 大规模列表的渲染优化:虚拟滚动结合增量加载的架构设计

📅 2026/7/14 12:10:07
Vue3 大规模列表的渲染优化:虚拟滚动结合增量加载的架构设计
Vue3 大规模列表的渲染优化虚拟滚动结合增量加载的架构设计一、DataGrid 组件的性能极限挑战一个数据分析平台的数据表格组件需要渲染 10 万行 x 50 列的数据。首次渲染时页面白屏 8 秒Chrome 的 Performance 面板显示Recalculate Style耗时 2.3 秒DOM 节点数超过 500 万个。即使加了v-memo和shallowRef滚动时的帧率也只有 8-12 FPS。常规的优化手段到这个量级已经失效。解决方案需要从渲染架构层面重新设计——不是让 Vue 更高效地处理 10 万行而是让 Vue 一次只处理屏幕可见范围内的 20-30 行。这就是虚拟滚动的核心思想。二、虚拟滚动的核心原理flowchart TB subgraph 视口可见区域 V[用户可见行 (row 125-145)] end subgraph 缓冲区 BU[缓冲行 (row 105-165)] end subgraph 虚拟占位 T[顶部占位 divbr/高度 124 * rowHeight] B[底部占位 divbr/高度 (total - 164) * rowHeight] end S[滚动容器] -- T T -- V V -- B V -- |可见区域| DOM[实际的 40 个 DOM 节点] T -- |不可见| PLACEHOLDER[纯高度占位, 零 DOM 节点] B -- |不可见| PLACEHOLDER虚拟滚动在 DOM 中只保留可见区域 缓冲区的行约 40-60 行其余行用占位 div 的高度模拟滚动条位置。无论数据量是 1 万还是 100 万行DOM 节点数恒定。2.1 核心实现script setup langts import { ref, computed, onMounted, onBeforeUnmount } from vue; interface Props { items: any[]; rowHeight: number; bufferSize?: number; } const props withDefaults(definePropsProps(), { bufferSize: 10, }); const containerRef refHTMLElement(); const scrollTop ref(0); const containerHeight ref(0); // 计算可见区域的起止索引 const visibleRange computed(() { const start Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.bufferSize); const visibleCount Math.ceil(containerHeight.value / props.rowHeight); const end Math.min( props.items.length, start visibleCount props.bufferSize * 2, ); return { start, end }; }); // 可见数据切片 const visibleItems computed(() { return props.items.slice(visibleRange.value.start, visibleRange.value.end); }); // 占位 div 的高度 const totalHeight computed(() props.items.length * props.rowHeight); const offsetY computed(() visibleRange.value.start * props.rowHeight); function onScroll(event: Event) { scrollTop.value (event.target as HTMLElement).scrollTop; } onMounted(() { if (containerRef.value) { containerHeight.value containerRef.value.clientHeight; // 监听容器尺寸变化 const observer new ResizeObserver((entries) { containerHeight.value entries[0].contentRect.height; }); observer.observe(containerRef.value); } }); /script template div refcontainerRef classvirtual-scroll-container scrollonScroll :style{ height: containerHeight px } !-- 总高度占位 -- div :style{ height: totalHeight px, position: relative } !-- 可见行容器偏移到正确位置 -- div :style{ transform: translateY(${offsetY}px) } div v-for(item, index) in visibleItems :keyitem.id || visibleRange.start index :style{ height: rowHeight px } classvirtual-row slot namerow :itemitem :indexvisibleRange.start index / /div /div /div /div /template使用transform: translateY而非padding-top的原因transform在合成器线程上执行不触发 Layout 重排。50 万行的列表在快速滚动时padding-top方案的重排开销是transform的 5-8 倍。2.2 动态行高的处理等高的虚拟滚动实现简单但实际业务中行高往往不统一如内容自动换行。动态行高的方案// 动态行高虚拟滚动需要维护行高缓存和预估行高 class DynamicHeightManager { private heightCache new Mapnumber, number(); private estimatedHeight 60; // 预估行高 // 二分查找根据累积高度确定 scrollTop 对应的起始行索引 getStartIndex(scrollTop: number): number { let accumulated 0; for (let i 0; i this.totalCount; i) { accumulated this.heightCache.get(i) || this.estimatedHeight; if (accumulated scrollTop) { return Math.max(0, i - this.bufferSize); } } return 0; } // 行渲染后更新实际高度 updateHeight(index: number, height: number): void { if (this.heightCache.get(index) ! height) { this.heightCache.set(index, height); // 行高变化会导致后续行的累积偏移改变需要重新计算 this.invalidateFromIndex(index 1); } } // 获取某行的 Y 偏移 getOffsetY(index: number): number { let offset 0; for (let i 0; i index; i) { offset this.heightCache.get(i) || this.estimatedHeight; } return offset; } }动态行高的代价是每次行高变化都可能触发偏移的级联重算。在行高变化频率高的场景如编辑器使用等距方案 CSSwhite-space: nowrap截断是更简单的选择。三、增量加载与虚拟滚动的协同虚拟滚动解决了渲染性能但 10 万条数据全部加载到内存中仍然消耗可观的内存约 200MB。增量加载 虚拟滚动的组合方案// 分页数据源按需加载数据片段 class PagedDataSource { private pageSize 1000; private loadedPages new Mapnumber, any[](); private totalCount 0; async getSlice(start: number, end: number): Promiseany[] { const startPage Math.floor(start / this.pageSize); const endPage Math.floor((end - 1) / this.pageSize); const pagesToLoad: number[] []; for (let p startPage; p endPage; p) { if (!this.loadedPages.has(p)) { pagesToLoad.push(p); } } // 并行加载缺失的页 if (pagesToLoad.length 0) { const results await Promise.all( pagesToLoad.map(p this.fetchPage(p)), ); results.forEach((data, i) { this.loadedPages.set(pagesToLoad[i], data); }); } // 从缓存中组装切片 const result: any[] []; for (let p startPage; p endPage; p) { const pageData this.loadedPages.get(p) || []; const pageStart p * this.pageSize; const sliceStart Math.max(0, start - pageStart); const sliceEnd Math.min(this.pageSize, end - pageStart); result.push(...pageData.slice(sliceStart, sliceEnd)); } return result; } private async fetchPage(page: number): Promiseany[] { const response await fetch( /api/data?page${page}size${this.pageSize} ); if (!response.ok) { throw new Error(加载第 ${page} 页数据失败: ${response.status}); } const data await response.json(); this.totalCount data.total; return data.items; } }四、边界与权衡首屏加载的骨架屏需求虚拟滚动加载数据时用户看到的是空白区域。需要显示骨架屏来暗示内容在加载中。搜索/过滤后的滚动位置数据过滤后totalCount变化滚动位置对应的数据会改变。需要在过滤时将scrollTop重置为 0。SEO 的代价虚拟滚动在 SSR 中无法渲染可见行之外的内容搜索引擎爬虫会看到一个近乎空的页面。对于需要 SEO 的页面虚拟滚动不适用——应该用分页或传统渲染。表格交互的复杂度虚拟滚动 行选择checkbox时DOM 回收会导致选中状态需要在数据层维护而非 DOMchecked状态增加了实现复杂度。五、总结虚拟滚动是解决大规模列表渲染问题的核心方案原理是将 DOM 节点数从数据行数缩减为可见行数 缓冲区。实现上从固定行高开始90% 的场景满足需求有特殊需要再扩展到动态行高。增量加载作为补充减少首屏数据下载和内存占用。实际落地的优先顺序先确认没有虚拟滚动真的不行——如果列表在 500 行以内直接渲染 v-memo足够。当确认需要虚拟滚动后先用现成的库vue-virtual-scroller 或 vxe-table验证效果再决定是否需要自研。自研虚拟滚动的最大工作量不在核心算法而在边缘场景动态行高、搜索过滤、跨行选择、无障碍访问的处理。