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

说在前面

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

效果展示

体验地址

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

相关推荐
BillKu9 分钟前
Vue3 + TypeScript中provide和inject的用法示例
javascript·vue.js·typescript
培根芝士15 分钟前
Electron打包支持多语言
前端·javascript·electron
Baoing_41 分钟前
Next.js项目生成sitemap.xml站点地图
xml·开发语言·javascript
mr_cmx41 分钟前
Nodejs数据库单一连接模式和连接池模式的概述及写法
前端·数据库·node.js
a东方青1 小时前
vue3学习笔记之属性绑定
vue.js·笔记·学习
东部欧安时1 小时前
研一自救指南 - 07. CSS面向面试学习
前端·css
沉默是金~1 小时前
Vue+Notification 自定义消息通知组件 支持数据分页 实时更新
javascript·vue.js·elementui
涵信1 小时前
第十二节:原理深挖-React Fiber架构核心思想
前端·react.js·架构
在下千玦1 小时前
#去除知乎中“盐选”付费故事
javascript
ohMyGod_1231 小时前
React-useRef
前端·javascript·react.js