说在前面
前端开发中,当我们需要展示大量数据列表时,直接渲染所有数据项往往会导致性能问题,页面加载缓慢、滚动卡顿等。为了解决这一问题,虚拟列表技术应运而生。今天我们一起来简单实现一个虚拟滚动列表。
效果展示
体验地址
http://jyeontu.xyz/jvuewheel/#/JDynamicVirtualListView
组件实现
虚拟列表的核心思想是只渲染可视区域内的数据项,当用户滚动列表时,动态更新可视区域内的数据项。这样可以避免一次性渲染大量数据项,减少 DOM 操作,提高性能。
实现原理
如上图,主要有这几个重要部分来组成。
占位层的高度为总高度,用于模拟整个列表的高度,确保滚动条的长度和位置正确。
浏览器页面上窗口中可以看到的区域。
包含可视窗口及缓冲区数据元素。
可视区域通过 transform 进行偏移,将数据元素定位到可视窗口。
视窗渲染机制
仅渲染可视区域及缓冲区的元素(通常为可见元素数量的 2-3 倍),非可视区域的 DOM 元素通过占位符替代,如:
<div :style="{ height: totalHeight + 'px' }"></div> <!-- 占位层 -->
复制代码
动态定位
使用 CSS transform: translateY() 实时调整可视区位置,避免触发浏览器重排:
:style="{ transform: `translateY(${offsetHeight}px)` }"
复制代码
可视区域数据切片
通过滚动位置动态计算 startIndex 和 endIndex,实现数据动态加载:
visibleData() { const endIndex = this.startIndex + this.visibleCount + this.buffer; return this.dataCopy.slice( Math.max(0, this.startIndex - this.buffer), Math.min(endIndex, this.data.length) );}
复制代码
模板部分
<template> <div class="dynamic-virtual-list" ref="scrollBox" @scroll.passive="handleScroll" > <!-- 占位层 --> <div :style="{ height: totalHeight + 'px' }"></div> <!-- 可视区域 --> <div class="visible-items" :style="{ transform: `translateY(${offsetHeight}px)` }" will-change="transform" > <div v-for="item in visibleData" :key="'visibleData-' + item.index" ref="itemRefs" class="list-item" > <slot :item="item"></slot> </div> </div> </div></template>
复制代码
dynamic-virtual-list 是列表的容器,绑定了滚动事件 handleScroll。
占位层的高度为总高度,用于模拟整个列表的高度,确保滚动条的长度和位置正确。
可视区域通过 transform 进行偏移,只渲染当前可视区域内的数据项。
props 入参
props: { data: { type: Array, required: true }, // 原始数据 estimatedHeight: { type: Number, default: 50 }, // 预估高度 buffer: { type: Number, default: 3 }, // 缓冲项数 visibleCount: { type: Number, default: 5 },}
复制代码
计算元素位置及高度
初始化位置数组
initPositions() { this.positions = this.data.map((d, i) => ({ top: i * this.estimatedHeight, height: this.estimatedHeight, }));}
复制代码
根据预估高度 estimatedHeight 先为每个数据项初始化位置信息,存储在 positions 数组中。
更新真实高度
updateItemSizes() { this.$refs.itemRefs.forEach((el, ind) => { const index = this.visibleData[ind].index; const realHeight = el.getBoundingClientRect().height; if (Math.abs(realHeight - this.positions[index].height) > 2) { this.positions[index].height = realHeight; // 更新后续项top值 for (let i = index + 1; i < this.positions.length; i++) { this.positions[i].top = this.positions[i - 1].top + this.positions[i - 1].height; } } });},
复制代码
遍历可视区域内的数据项,获取其真实高度。如果真实高度与预估高度差异超过 2 像素,则更新 positions 数组中对应项的高度,并更新后续项的 top 值。
总高度计算
totalHeight() { if (!this.positions.length) return 0; const last = this.positions[this.positions.length - 1]; return last.top + last.height;}
复制代码
计算整个列表的总高度,通过 positions 数组中最后一项的 top 和 height 相加得到,用于设置占位层高度。
二分查找定位
findNearestIndex(scrollTop) { let low = 0, high = this.positions.length - 1; while (low <= high) { const mid = Math.floor((low + high) / 2); const midVal = this.positions[mid].top; if (midVal === scrollTop) return mid; else if (midVal < scrollTop) low = mid + 1; else high = mid - 1; } return high < 0 ? 0 : high;}
复制代码
使用二分查找算法,根据滚动位置找到屏幕可视窗口中的第一个元素。
滚动事件处理
handleScroll() { const now = Date.now(); if (now - this.lastScrollTime < 20) { if (this.scrollTimer) clearTimeout(this.scrollTimer); this.scrollTimer = setTimeout(() => { this._doScrollUpdate(); }, 30); return; } this._doScrollUpdate(); this.lastScrollTime = now;},_doScrollUpdate() { requestAnimationFrame(() => { this.scrollTop = this.$refs.scrollBox.scrollTop; this.startIndex = this.findNearestIndex(this.scrollTop); this.$nextTick(this.updateItemSizes); });}
复制代码
节流处理滚动事件,避免频繁触发更新操作。如果两次滚动事件的时间间隔小于 20 毫秒,则设置一个 30 毫秒的定时器,在定时器到期后执行 _doScrollUpdate 方法。
_doScrollUpdate 方法使用 requestAnimationFrame 在浏览器下次重绘之前执行更新操作。获取当前的滚动位置,调用 findNearestIndex 方法找到起始索引,并在 DOM 更新后更新数据项的真实高度。
更新偏移高度
updateOffsetHeight() { if (this.visibleData[0].index === 0) return 0; const scrollTop = this.scrollTop; let index = this.startIndex; const diff = scrollTop - this.positions[index].top; let height = 0; for (const item of this.visibleData) { if (item.index >= index) break; height += this.positions[item.index].height; } this.offsetHeight = scrollTop - height - diff;},
复制代码
根据当前的滚动位置和起始索引,计算出可视区域的偏移高度,即 translateY 的高度。
组件使用
<template> <div class="content" style="width: 100%; padding: 1rem"> <JDynamicVirtualList :data="bigData" :estimated-height="80" class="list-container" > <template v-slot:default="{ item }"> <div class="custom-item" :style="{ height: item.height + 'px' }" > {{ item.content }} </div> </template> </JDynamicVirtualList> </div></template><script>export default { data() { return { bigData: Array.from({ length: 5000 }, (_, i) => ({ content: `Item - ${i}`, height: 100 + Math.random() * 100, })), }; },}</script>
复制代码
组件库
组件文档
目前该组件也已经收录到我的组件库,组件文档地址如下:http://jyeontu.xyz/jvuewheel/#/JDynamicVirtualListView
组件内容
组件库中还有许多好玩有趣的组件,如:
悬浮按钮
评论组件
词云
瀑布流照片容器
视频动态封面
3D 轮播图
web 桌宠
贡献度面板
拖拽上传
自动补全输入框
图片滑块验证
等等……
组件库源码
组件库已开源到 gitee,有兴趣的也可以到这里看看:https://gitee.com/zheng_yongtao/jyeontu-component-warehouse
🌟觉得有帮助的可以点个 star~
🖊有什么问题或错误可以指出,欢迎 pr~
📬有什么想要实现的组件或想法可以联系我~
公众号
关注公众号『 前端也能这么有趣 』,获取更多有趣内容。
发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。
评论