写点什么

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

作者:JYeontu
  • 2025-04-21
    广东
  • 本文字数:3570 字

    阅读完需:约 12 分钟

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

说在前面

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

效果展示


体验地址

http://jyeontu.xyz/jvuewheel/#/JDynamicVirtualListView

组件实现

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

实现原理


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




  • 占位层


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


  • 可视窗口


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


  • 数据容器


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


  • translateY 高度


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



视窗渲染机制

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


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

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


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

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


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 数组中最后一项的 topheight 相加得到,用于设置占位层高度。

二分查找定位

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

发布于: 刚刚阅读数: 2
用户头像

JYeontu

关注

技术~运动~分享 公众号:前端也能这么有趣 2023-02-07 加入

喜欢算法,GDCPC打过卡;热爱羽毛球,大运会打过酱油。毕业两年,三年前端开发经验,做过unity游戏开发,目前担任H5前端开发,算法业余爱好者。公众号:『前端也能这么有趣』

评论

发布
暂无评论
实现一个虚拟滚动列表组件_vue.js_JYeontu_InfoQ写作社区