当前位置: 首页 > news >正文

前端——渲染10万条数据不卡顿?虚拟滚动的核心原理与实战

一个真实的故事

去年双十一,运营同学找到我:“大促页面加载要15秒,老板炸了。”

我打开页面一看,一个无限滚动列表,一次性渲染了8000多个商品卡片。Chrome DevTools显示:

  • DOM节点数:25,847个
  • 内存占用:156MB
  • 首次内容绘制:12.8秒

这不是个例。我翻了下项目,类似的列表渲染问题至少有5处。

于是我用一个周末,把整个项目的列表都改成了虚拟滚动。结果是:首屏渲染从15秒降到了0.3秒

今天,我就把这个方案完整分享出来。


一、问题的本质

浏览器渲染大量DOM节点时,主要消耗在:

  1. 创建节点:每个div、span都是内存开销
  2. 布局计算:5000个节点,浏览器就要计算5000次位置
  3. 重绘重排:滚动时触发布局重新计算

核心思路:既然用户只能看到20-30个节点,为什么要把剩下的都渲染出来?


二、最简单的虚拟滚动(固定高度)

原理图解

text

┌─────────────────────────┐ │ 容器 (高度400px) │ │ ┌─────────────────────┐ │ │ │ 第0-4项 (可见区域) │ │ ← 只渲染这些 │ │ 第5-9项 │ │ │ └─────────────────────┘ │ │ │ │ ┌─────────────────────┐ │ │ │ 占位元素 (撑开高度) │ │ ← 看不见但存在 │ │ 高度 = 总数 × 单项高度 │ │ │ └─────────────────────┘ │ └─────────────────────────┘

完整代码

vue

<template> <div class="virtual-list" ref="container" @scroll="handleScroll" > <!-- 占位元素:撑开滚动条高度 --> <div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }" ></div> <!-- 可视区域:实际渲染的内容 --> <div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }" > <div v-for="item in visibleData" :key="item.id" class="virtual-list-item" :style="{ height: itemHeight + 'px' }" > <slot :item="item"></slot> </div> </div> </div> </template> <script> export default { name: 'VirtualList', props: { items: { type: Array, required: true }, itemHeight: { type: Number, default: 60 }, bufferSize: { type: Number, default: 5 // 缓冲区域,避免滚动时出现白屏 } }, data() { return { scrollTop: 0, containerHeight: 0 } }, computed: { totalHeight() { return this.items.length * this.itemHeight }, visibleCount() { return Math.ceil(this.containerHeight / this.itemHeight) + this.bufferSize }, startIndex() { return Math.max(0, Math.floor(this.scrollTop / this.itemHeight) - this.bufferSize) }, endIndex() { return Math.min(this.items.length, this.startIndex + this.visibleCount + this.bufferSize) }, visibleData() { return this.items.slice(this.startIndex, this.endIndex) }, offsetY() { return this.startIndex * this.itemHeight } }, mounted() { this.containerHeight = this.$refs.container.clientHeight window.addEventListener('resize', this.handleResize) }, beforeDestroy() { window.removeEventListener('resize', this.handleResize) }, methods: { handleScroll(e) { this.scrollTop = e.target.scrollTop }, handleResize() { this.containerHeight = this.$refs.container.clientHeight }, scrollToIndex(index) { if (index >= 0 && index < this.items.length) { this.$refs.container.scrollTop = index * this.itemHeight } } } } </script> <style scoped> .virtual-list { height: 100%; overflow-y: auto; position: relative; } .virtual-list-phantom { position: absolute; left: 0; top: 0; right: 0; z-index: -1; } .virtual-list-content { position: absolute; left: 0; right: 0; top: 0; } </style>

使用方式

vue

<template> <VirtualList :items="dataList" :item-height="80"> <template #default="{ item }"> <div class="video-card"> <img :src="item.cover" /> <div class="info"> <h3>{{ item.title }}</h3> <p>{{ item.viewCount }}次播放</p> </div> </div> </template> </VirtualList> </template> <script> import VirtualList from '@/components/VirtualList.vue' export default { components: { VirtualList }, data() { return { dataList: [] // 假设有10万条数据 } } } </script>

三、进阶版:动态高度虚拟滚动

真实场景中,每个列表项的高度往往不固定(评论内容有长有短)。

核心挑战

  • 无法预先知道每个item的高度
  • 需要动态测量并更新位置
  • 滚动时计算量更大

实现方案

vue

<template> <div class="dynamic-virtual-list" ref="container" @scroll="handleScroll" > <div class="dynamic-phantom" :style="{ height: totalHeight + 'px' }" ></div> <div class="dynamic-content" :style="{ transform: `translateY(${offsetY}px)` }" > <div v-for="item in visibleItems" :key="item.id" class="dynamic-item" :ref="`item-${item.id}`" > <slot :item="item"></slot> </div> </div> </div> </template> <script> export default { name: 'DynamicVirtualList', props: { items: { type: Array, required: true }, estimatedHeight: { type: Number, default: 100 // 预估高度 } }, data() { return { positions: [], // 每个item的位置信息 scrollTop: 0, containerHeight: 0, heightCache: new Map() } }, computed: { totalHeight() { if (!this.positions.length) return 0 return this.positions[this.positions.length - 1].bottom }, startIndex() { // 二分查找找到起始索引 let start = 0 let end = this.positions.length - 1 while (start <= end) { const mid = Math.floor((start + end) / 2) if (this.positions[mid].bottom < this.scrollTop) { start = mid + 1 } else if (this.positions[mid].top > this.scrollTop) { end = mid - 1 } else { return mid } } return start }, endIndex() { const viewBottom = this.scrollTop + this.containerHeight let index = this.startIndex while (index < this.positions.length && this.positions[index].top < viewBottom) { index++ } return index + 5 // 多渲染5个缓冲 }, visibleItems() { return this.items.slice(this.startIndex, this.endIndex) }, offsetY() { return this.positions[this.startIndex]?.top || 0 } }, mounted() { this.initPositions() this.containerHeight = this.$refs.container.clientHeight this.$nextTick(() => { this.measureHeights() }) }, updated() { this.measureHeights() }, methods: { initPositions() { this.positions = this.items.map((_, index) => ({ top: index * this.estimatedHeight, bottom: (index + 1) * this.estimatedHeight, height: this.estimatedHeight })) }, measureHeights() { this.visibleItems.forEach(item => { const element = this.$refs[`item-${item.id}`] if (element && element[0]) { const newHeight = element[0].getBoundingClientRect().height const oldHeight = this.heightCache.get(item.id) if (oldHeight !== newHeight) { this.updatePosition(item.id, newHeight) this.heightCache.set(item.id, newHeight) } } }) }, updatePosition(id, newHeight) { const index = this.items.findIndex(item => item.id === id) if (index === -1) return const oldHeight = this.positions[index].height const heightDiff = newHeight - oldHeight if (heightDiff !== 0) { for (let i = index; i < this.positions.length; i++) { this.positions[i].top += heightDiff this.positions[i].bottom += heightDiff if (i === index) { this.positions[i].height = newHeight } } } }, handleScroll(e) { this.scrollTop = e.target.scrollTop }, scrollToIndex(index) { if (this.positions[index]) { this.$refs.container.scrollTop = this.positions[index].top } } }, watch: { items: { handler() { this.initPositions() this.heightCache.clear() }, deep: true } } } </script>

四、性能对比实测

我写了一个测试页面,渲染10,000条数据:

指标传统渲染虚拟滚动提升
首屏渲染时间2,847ms87ms97%
DOM节点数10,001个31个99.7%
内存占用78MB4.2MB94.6%
滚动帧率24fps60fps150%
页面卡顿感明显

结论:虚拟滚动在性能上的提升是质的飞跃。


五、实际项目中的坑

坑1:图片加载导致高度变化

现象:滚动时位置跳动

原因:图片未加载时高度为0,加载后撑开

解决方案

vue

<img :src="item.cover" @load="onImageLoad" style="width: 100%; height: auto;" /> <script> methods: { onImageLoad() { // 强制重新测量高度 this.$nextTick(() => { this.$refs.virtualList.measureHeights() }) } } </script>

坑2:快速滚动时出现白屏

解决方案:增加缓冲区大小

javascript

bufferSize: 10 // 默认5,改为10

坑3:动态数据插入/删除

javascript

// 插入新数据后,重新计算后续位置 addItem(index, item) { this.items.splice(index, 0, item) this.updatePositionsFrom(index) }

六、生产环境推荐方案

如果你不想自己造轮子,可以考虑这些成熟的库:

特点适用场景
vue-virtual-scrollerVue官方推荐,功能完善大多数场景
tanstack-virtual框架无关,性能极致React/Vue/Solid
react-windowReact生态首选React项目

bash

# Vue项目推荐 npm install vue-virtual-scroller # 使用示例 <template> <RecycleScroller :items="list" :item-size="50" key-field="id" > <template #default="{ item }"> <div>{{ item.text }}</div> </template> </RecycleScroller> </template>

写在最后

虚拟滚动的核心思想很简单:只渲染用户能看到的

但实现起来,固定高度容易,动态高度复杂。如果你刚开始接触,建议先用固定高度版本,等理解了原理再上动态高度。

记住这三个数字:

  • 列表长度 < 200:不用虚拟滚动
  • 200 < 列表长度 < 1000:可以不用
  • 列表长度 > 1000:必须用
http://www.jsqmd.com/news/653830/

相关文章:

  • 别再纠结Pointwise还是Pairwise了:手把手教你为你的搜索/推荐场景选对LTR方法
  • Fish-Speech-1.5在VMware虚拟机中的部署方案
  • 2026年靠谱的郑州短视频Tiktok运营/郑州短视频制作/郑州短视频运营/郑州短视频获客服务榜单 - 行业平台推荐
  • 负载均衡策略算法与实现方式
  • 谷歌外贸seo优化怎么做?新站上线前必须配置的7个页面标签
  • 别再让电费偷偷溜走!手把手教你用SVG和SPC搞定小区三相不平衡(附真实数据对比)
  • ComfyUI-Manager架构优化方案:实现AI工作流组件管理的性能调优与系统集成
  • 从零搭建四路红外PID循迹小车:硬件选型与核心代码解析
  • 为微信小程序赋能:集成nli-distilroberta-base实现文本逻辑检查功能
  • 2026年知名的云南医院格力空调工程/云南格力空调/云南格力空调官方授权实力商家榜 - 品牌宣传支持者
  • 别再复制粘贴了!手把手教你用Visual Studio 2022创建可复用的.NET Standard类库(附完整项目结构)
  • 别再为GPU发愁了!手把手教你用Kaggle免费额度跑通YOLOv8训练(附数据集路径避坑指南)
  • CentOS 7时间同步踩坑实录:阿里云NTP服务配置与常见问题解决
  • 终极指南:如何使用DLSS Swapper一键管理所有游戏的DLSS版本,提升游戏性能
  • Qwen3-Reranker-4B一文详解:Qwen3-Reranker-4B在MIRACL多语言检索基准表现
  • Potree点云可视化实战指南:从数据加载到高级分析
  • 5分钟搞定Figma中文界面:设计师必备的终极汉化方案
  • DeepSeek-R1推理模型实战:手把手教你写代码解数学题
  • 2026年热门的郑州出口网站/郑州网站设计/郑州网站制作/网站综合排名榜 - 行业平台推荐
  • UE5 UMG 动态数据可视化:打造高性能曲线图控件
  • 新手必看:用ResNet18镜像快速搭建图像分类服务,附完整操作步骤
  • 直流母线电压利用率提升15.4%?深入Simulink仿真,揭秘SVPWM相比传统SPWM的实际优势到底在哪
  • Qwen3.5-2B图片识别功能实测:上传任意图片,AI帮你描述内容
  • 从BERT到Qwen3再到自主演化Agent:2026奇点大会首次披露AI对话机器人技术演进路线图(含2027–2030三级跃迁时间窗与卡点攻关清单)
  • Phi-4-reasoning-vision-15B应用场景:智能硬件产品说明书截图结构化解析与FAQ生成
  • 手把手调试5G PUCCH HARQ-ACK反馈:利用Wireshark和UE日志分析资源选择问题
  • 2026年评价高的碳纤维板/碳纤维盒子/惠州碳纤维板源头厂家推荐 - 品牌宣传支持者
  • 从零到一:基于ROS与LIAOKE机器人实战SLAM建图与Navigation导航
  • 2026年知名的风电篷布机舱轮毂防护/PVC加厚风电篷布厂家对比推荐 - 行业平台推荐
  • 若依框架的表单构建器,比你想象的更强大:除了拖拽,这些高级玩法和避坑点你知道吗?