说在前面
前端开发中,当我们需要展示大量数据列表时,直接渲染所有数据项往往会导致性能问题,页面加载缓慢、滚动卡顿等。为了解决这一问题,虚拟列表技术应运而生。今天我们一起来简单实现一个虚拟滚动列表。
效果展示
体验地址
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,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣
』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。
评论