实现一个虚拟滚动列表组件

说在前面

前端开发中,当我们需要展示大量数据列表时,直接渲染所有数据项往往会导致性能问题,页面加载缓慢滚动卡顿 等。为了解决这一问题,虚拟列表 技术应运而生。今天我们一起来简单实现一个虚拟滚动列表

效果展示

体验地址

jyeontu.xyz/jvuewheel/#...

组件实现

虚拟列表的核心思想是只渲染可视区域内的数据项,当用户滚动列表时,动态更新可视区域内的数据项。这样可以避免一次性渲染大量数据项,减少 DOM 操作,提高性能。

实现原理

如上图,主要有这几个重要部分来组成。


  • 占位层

占位层的高度为总高度,用于模拟整个列表的高度,确保滚动条的长度和位置正确。

  • 可视窗口

浏览器页面上窗口中可以看到的区域。

  • 数据容器

包含可视窗口及缓冲区数据元素。

  • translateY高度

可视区域通过 transform 进行偏移,将数据元素定位到可视窗口。


视窗渲染机制

仅渲染可视区域及缓冲区的元素(通常为可见元素数量的2-3倍),非可视区域的DOM元素通过占位符替代,如:

javascript 复制代码
<div :style="{ height: totalHeight + 'px' }"></div> <!-- 占位层 -->
动态定位

使用CSS transform: translateY() 实时调整可视区位置,避免触发浏览器重排:

javascript 复制代码
:style="{ transform: `translateY(${offsetHeight}px)` }"
可视区域数据切片

通过滚动位置动态计算 startIndexendIndex,实现数据动态加载:

javascript 复制代码
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)
    );
}

模板部分

html 复制代码
<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入参

javascript 复制代码
props: {
  data: { type: Array, required: true }, // 原始数据
  estimatedHeight: { type: Number, default: 50 }, // 预估高度
  buffer: { type: Number, default: 3 }, // 缓冲项数
  visibleCount: { type: Number, default: 5 },
}

计算元素位置及高度

初始化位置数组
javascript 复制代码
initPositions() {
    this.positions = this.data.map((d, i) => ({
        top: i * this.estimatedHeight,
        height: this.estimatedHeight,
    }));
}

根据预估高度 estimatedHeight 先为每个数据项初始化位置信息,存储在 positions 数组中。

更新真实高度
javascript 复制代码
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 值。

总高度计算

javascript 复制代码
totalHeight() {
  if (!this.positions.length) return 0;
  const last = this.positions[this.positions.length - 1];
  return last.top + last.height;
}

计算整个列表的总高度,通过 positions 数组中最后一项的 topheight 相加得到,用于设置占位层高度。

二分查找定位

javascript 复制代码
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;
}

使用二分查找算法,根据滚动位置找到屏幕可视窗口中的第一个元素。

滚动事件处理

javascript 复制代码
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 更新后更新数据项的真实高度。

更新偏移高度

javascript 复制代码
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 的高度。

组件使用

html 复制代码
<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>

组件库

组件文档

目前该组件也已经收录到我的组件库,组件文档地址如下: jyeontu.xyz/jvuewheel/#...

组件内容

组件库中还有许多好玩有趣的组件,如:

  • 悬浮按钮
  • 评论组件
  • 词云
  • 瀑布流照片容器
  • 视频动态封面
  • 3D轮播图
  • web桌宠
  • 贡献度面板
  • 拖拽上传
  • 自动补全输入框
  • 图片滑块验证

等等......

组件库源码

组件库已开源到gitee,有兴趣的也可以到这里看看:gitee.com/zheng_yongt...


  • 🌟觉得有帮助的可以点个star~

  • 🖊有什么问题或错误可以指出,欢迎pr~

  • 📬有什么想要实现的组件或想法可以联系我~


公众号

关注公众号『 前端也能这么有趣 』,获取更多有趣内容。

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

相关推荐
musk121210 分钟前
electron 打包太大 试试 tauri , tauri 安装打包demo
前端·electron·tauri
翻滚吧键盘39 分钟前
js代码09
开发语言·javascript·ecmascript
万少1 小时前
第五款 HarmonyOS 上架作品 奇趣故事匣 来了
前端·harmonyos·客户端
OpenGL1 小时前
Android targetSdkVersion升级至35(Android15)相关问题
前端
rzl021 小时前
java web5(黑马)
java·开发语言·前端
Amy.Wang2 小时前
前端如何实现电子签名
前端·javascript·html5
海天胜景2 小时前
vue3 el-table 行筛选 设置为单选
javascript·vue.js·elementui
今天又在摸鱼2 小时前
Vue3-组件化-Vue核心思想之一
前端·javascript·vue.js
蓝婷儿2 小时前
每天一个前端小知识 Day 21 - 浏览器兼容性与 Polyfill 策略
前端
百锦再2 小时前
Vue中对象赋值问题:对象引用被保留,仅部分属性被覆盖
前端·javascript·vue.js·vue·web·reactive·ref