虚拟列表的实现

244 阅读4分钟

什么是虚拟列表

虚拟列表 是对 长列表 优化的一种手段,其实就是只对 可见区域 进行渲染,对 不可见区域 则不进行渲染或者设置缓冲区部分渲染,这样就可以实现高效的渲染

为什么需要虚拟列表

有一道非常经典的面试题就是如果后端一次性返回 十万条数据 需要前端来渲染,应该怎么做?我们总不能将这数据全部渲染到页面,那样肯定会造成页面渲染卡顿,甚至直接卡死,所以我们就需要想办法,不一次性渲染那么多,那就 虚拟列表 就可以很好的解决这个问题

实现

定高虚拟列表

图示

初始化状态

image.png

滚动后状态

可以得到滚动高度 scrollTop

image.png

分析

首先我们先看下样式结构

<div class="virtual-list" ref="virtualList">
  <div class="list-phantom"></div>
  <div class="list-content">
    <!-- item-1 -->
    <!-- item-2 -->
        ......
    <!-- item-n -->
  </div>
</div>
  • virtual-list可见区域 的容器
  • list-phantom容器的占位 设置高度为列表的总高度,用于形成滚动条
  • list-content列表渲染区域

思考一下: 因为我们只需要渲染可见区域的元素即可,所以需要截取总数据,得到需要展示的数据,那么截取需要展示的数据,就需要知道开始索引以及结束索引,又怎么得到开始索引以及结束索引呢?

因为我们这里是定高的,所以我们是知道 默认高度,这样就可以得到 列表的总高度。可以根据滚动的高度得到开始索引结束索引 可以根据 开始索引 加上 可见区域的元素数量 得到,怎么得到 可见区域的元素数量呢?很简单用 可见区域的高度 除于 元素默认高度 可以得到 可见区域的元素数量

  • 默认的高度:itemHeight
  • 列表的总高度:totalHeight = itemHeight * data.length
  • 可见区域高度:visibleHeight = $refs.virtualList.clientHeight
  • 可见区域元素数量:visibleCount = Math.ceil(visibleHeight / itemHeight)
  • 开始索引:startIndex = Math.floor(scrollTop / itemHeight)
  • 结束索引:endIndex = Math.min(startIndex + visibleCount, data.length)
  • 偏移量:offsetY = scrollTop - (scrollTop % itemHeight) 一定需要减去取模值,不然看到的效果相当于直接替换元素,没有滚动效果

实现

<template>
  <div class="container">
    <div class="virtual-list" ref="virtualList" @scroll="updateVisibleItems">
      <div class="list-phantom" :style="{ height: totalHeight + 'px' }"></div>
      <div
        class="list-content"
        :style="{ transform: `translateY(${offsetY}px)` }"
      >
        <div v-for="item in visibleItems" :key="item.id" class="card">
          <img :src="item.image" :alt="item.title" />
          <div class="content">
            <h3>{{ item.title }}</h3>
            <p>{{ item.description }}</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      data: [], // 模拟总数据
      scrollTop: 0, // 滚动条位置
      totalHeight: 0, // 列表总高度
      visibleHeight: 0, // 可见区域的高度
      itemHeight: 140, // 默认每个项目的高度
    };
  },
  created() {
    this.data = this.generateData(1000); // 模拟数据
  },
  mounted() {
    this.totalHeight = this.data.length * this.itemHeight; // 获取列表总高度
    this.visibleHeight = this.$refs.virtualList.clientHeight; // 获取可见区域的高度
    this.updateVisibleItems(); // 初始化可见项目
  },
  computed: {
    // 计算可见区域的数量
    visibleCount() {
      return Math.ceil(this.visibleHeight / this.itemHeight);
    },
    // 计算起始索引
    startIndex() {
      return Math.floor(this.scrollTop / this.itemHeight);
    },
    // 计算结束索引
    endIndex() {
      return Math.min(this.startIndex + this.visibleCount, this.data.length);
    },
    // 获取可见项目
    visibleItems() {
      return this.data.slice(this.startIndex, this.endIndex);
    },
    // 计算偏移量
    offsetY() {
      return this.scrollTop - (this.scrollTop % this.itemHeight); // 减去取模值非常重要!!!
    },
  },
  methods: {
    generateData(count) {
      return Array.from({ length: count }, (_, index) => ({
        id: index,
        title: `标题 ${index + 1}`,
        description: `这是第 ${index + 1} 个项目的描述文本。`,
        image: `https://picsum.photos/200/200?random=${index}`,
      }));
    },
    updateVisibleItems() {
      this.scrollTop = this.$refs.virtualList.scrollTop;
    },
  },
};
</script>

<style scoped>
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.virtual-list {
  height: 600px;
  overflow: auto;
  position: relative;
}

.list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.list-content {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
}

.card {
  display: flex;
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 8px;
  background: #fff;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin-bottom: 10px;
}

.card img {
  width: 100px;
  height: 100px;
  object-fit: cover;
  border-radius: 4px;
  margin-right: 15px;
}

.content {
  flex: 1;
}

.content h3 {
  margin-bottom: 8px;
  color: #333;
}

.content p {
  color: #666;
  font-size: 14px;
  line-height: 1.6;
}
</style>

效果

1735011634598-1735011532239-20241224_113554.gif

动态高度虚拟列表

图示

image.png

分析

因为每一项的高度都是不确定的,所以就无法计算出 开始索引,也就意味着 结束索引 你也没法知道,并且不知道每一项高度也就无法计算出 总高度 以及可见区域高度

我们先给一个每一项初始高度,便于预估出总高度,出现滚动条,然后当元素渲染出来后获取真实高度并记录下来。

定义 itemPositons 记录每一项的信息

itemPositons() {
  return this.data.map((_, index) => ({
    id: index,
    height: this.estimatedItemSize,
    top: index * this.estimatedItemSize,
    bottom: (index + 1) * this.estimatedItemSize
  }))
}

这样就可以得到 列表总高度

// 最后一项的 bottom 的位置就是总高度,当然也可以使用 `reduce` 方法计算出总高度
totalHeight = itemPositons[itemPositons.length - 1].bottom 

动态高度获取 开始索引 就不一样了,也可以说更简单些

getStartIndex() {
  // 找到第一个元素bottom大于滚动高度的元素索引,表示出现在可见区域第一个
  return itemPositons.findIndex((item) => item.bottom > scrollTop) 
}

增加上下缓冲区域,计算缓冲区域数量

beforeBuffer() {
  return Math.min(this.startIndex, this.buffer)
}
afterBuffer() {
  return Math.min(this.data.length - this.endIndex, this.buffer)
}

得到需要渲染到的列表

visibleItems() {
  return this.data.slice(
    this.startIndex - this.beforeBuffer,
    Math.min(this.endIndex + this.afterBuffer, this.data.length)
  )
}

增加缓冲区域后需要重新计算偏移量

setOffsetY() {
  if (this.startIndex === 0) return 0

  // 计算缓冲的高度
  const size =
    this.itemPositons[this.startIndex].top -
    (this.itemPositons[this.startIndex - this.beforeBuffer]
      ? this.itemPositons[this.startIndex - this.beforeBuffer].top
      : 0)

  // 偏移量需要减去缓冲的高度
  this.offsetY = this.itemPositons[this.startIndex].top - size
}

并且我们这里使用了 IntersectionObserver 来代替了 scroll 事件,解决了滚动事件频繁触发的问题,造成很多没必要的计算。IntersectionObserver 可以监听目标元素是否出现在可视区域内,在监听的回调事件中执行可视区域数据的更新。

还使用了 ResizeObserver 来优化在 updated钩子函数中频繁重新计算位置信息,而 ResizeObserver 只有当目标元素尺寸变化后才会重新计算元素位置信息

实现

<template>
  <div class="container">
    <div class="virtual-list" ref="virtualList">
      <div class="list-phantom" :style="{ height: totalHeight + 'px' }"></div>
      <div class="list-content" :style="{ transform: `translateY(${offsetY}px)` }">
        <div
          v-for="item in visibleItems"
          :key="item.id"
          class="card"
          ref="itemRefs"
          :id="`item-${item.id}`"
        >
          <img :src="item.image" :alt="item.title" />
          <div class="content">
            <h3>{{ item.title }}</h3>
            <p>{{ item.description }}</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      data: this.generateData(100),
      estimatedItemSize: 120, // 预估每行高度
      buffer: 3, // 缓冲个数
      totalHeight: 0,
      visibleHeight: 0,
      scrollTop: 0,
      startIndex: 0,
      endIndex: 0,
      offsetY: 0,
      intersectionObserve: null,
      resizeObserver: null
    }
  },
  computed: {
    itemPositons() {
      return this.data.map((_, index) => ({
        id: index,
        height: this.estimatedItemSize,
        top: index * this.estimatedItemSize,
        bottom: (index + 1) * this.estimatedItemSize
      }))
    },
    visibleCounts() {
      return Math.ceil(this.visibleHeight / this.estimatedItemSize)
    },
    beforeBuffer() {
      return Math.min(this.startIndex, this.buffer)
    },
    afterBuffer() {
      return Math.min(this.data.length - this.endIndex, this.buffer)
    },
    visibleItems() {
      return this.data.slice(
        this.startIndex - this.beforeBuffer,
        Math.min(this.endIndex + this.afterBuffer, this.data.length)
      )
    }
  },
  mounted() {
    this.visibleHeight = this.$refs.virtualList.clientHeight
    this.totalHeight = this.itemPositons[this.itemPositons.length - 1].bottom
    this.startIndex = 0
    this.endIndex = this.startIndex + this.visibleCounts
    this.createResizeObserve()
    this.createIntersectionObServe()
  },
  updated() {
    this.resizeObserveItems()
    this.intersectionObserveItems()
  },
  methods: {
    /**
     * 监听元素尺寸变化
     * 此函数遍历所有item元素,并使用resizeObserver对其进行观察
     */
    resizeObserveItems() {
      this.$refs.itemRefs.forEach((node) => {
        this.resizeObserver.observe(node)
      })
    },

    /**
     * 创建ResizeObserver实例
     * 当观察到元素尺寸变化时,调用getRealityDomPositions更新DOM位置信息
     */
    createResizeObserve() {
      this.resizeObserver = new ResizeObserver(() => {
        this.getRealityDomPositions()
      })
    },

    /**
     * 监听元素是否在可视范围内
     * 此函数遍历所有item元素,并使用intersectionObserve对其进行观察
     */
    intersectionObserveItems() {
      this.$refs.itemRefs.forEach((node) => {
        this.intersectionObserve.observe(node)
      })
    },

    /**
     * 创建IntersectionObserver实例
     * 当元素进入可视范围时,调用updateVisibleItems更新可见项
     * @param {entries} 监听到的元素信息数组
     */
    createIntersectionObServe() {
      this.intersectionObserve = new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            // 如果进入可视范围
            if (entry.isIntersecting) {
              this.updateVisibleItems()
            }
          })
        },
        {
          root: this.$refs.virtualList, // 监听的根元素
          rootMargin: '0px', // 触发回调函数的距离
          threshold: 0 // 触发回调函数的比例
        }
      )
    },

    /**
     * 更新所有元素的实际位置
     * 此函数遍历所有item元素,获取其当前高度,并根据高度变化更新位置信息
     */
    getRealityDomPositions() {
      // 获取所有项的引用
      const nodes = this.$refs.itemRefs

      // 遍历每一项以更新其位置信息
      nodes.forEach((node) => {
        // 获取当前项的高度
        const height = node.getBoundingClientRect().height
        // 提取当前项的ID
        const id = +node.id.split('-')[1]
        // 查找当前项在位置信息数组中的索引
        const index = this.itemPositons.findIndex((item) => item.id === id)
        // 获取当前项的旧高度
        const oldHeight = this.itemPositons[index].height
        // 计算高度差
        const diffValue = height - oldHeight

        // 如果高度有变化,则更新位置信息
        if (diffValue) {
          // 更新当前项的高度
          this.itemPositons[index].height = height
          // 调整当前项的底部位置
          this.itemPositons[index].bottom += diffValue

          // 更新后续所有项的顶部和底部位置
          for (let i = index + 1; i < this.itemPositons.length; i++) {
            this.itemPositons[i].top = this.itemPositons[i - 1].bottom
            this.itemPositons[i].bottom += diffValue
          }
        }
      })

      // 更新总高度为最后一项的底部位置
      this.totalHeight = this.itemPositons[this.itemPositons.length - 1].bottom
    },

    /**
     * 更新可见元素信息
     * 此函数根据当前滚动位置,计算出可见元素的索引范围,并设置偏移量
     */
    updateVisibleItems() {
      this.scrollTop = this.$refs.virtualList.scrollTop
      this.startIndex = this.getStartIndex()
      this.endIndex = this.startIndex + this.visibleCounts
      this.setOffsetY()
    },

    /**
     * 计算并设置偏移量
     * 此函数根据当前可见元素的起始索引,计算出列表的偏移量
     */
    setOffsetY() {
      // 如果起始索引为0,则不需要进行后续计算,直接返回0
      if (this.startIndex === 0) return 0

      // 计算当前起始项与前一个缓冲项之间的高度差
      const size =
        this.itemPositons[this.startIndex].top -
        (this.itemPositons[this.startIndex - this.beforeBuffer]
          ? this.itemPositons[this.startIndex - this.beforeBuffer].top
          : 0)

      // 根据计算出的高度差,设置纵轴偏移量,以保证列表滚动的流畅性
      this.offsetY = this.itemPositons[this.startIndex].top - size
    },

    /**
     * 获取可见元素的起始索引
     * 此函数根据当前滚动位置,找到第一个进入可视范围的元素索引
     */
    getStartIndex() {
      return this.itemPositons.findIndex((item) => item.bottom > this.scrollTop)
    },
    generateData(count) {
      return Array.from({ length: count }, (_, index) => {
        const height = this.getRandomHeight()
        return {
          id: index,
          title: `标题 ${index + 1}`,
          description: `这是第 ${index + 1} 个项目的描述文本。`.repeat(
            Math.floor(Math.random() * 3) + 1
          ),
          image: `https://picsum.photos/${height}/${height}?random=${index}`
        }
      })
    },
    // 获得100-300的随机数
    getRandomHeight() {
      return Math.floor(Math.random() * 200) + 100
    }
  }
}
</script>

<style scoped>
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.virtual-list {
  height: 600px;
  overflow: auto;
  position: relative;
}

.list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.list-content {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
}

.card {
  display: flex;
  align-items: center;
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 8px;
  background: #fff;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin-bottom: 10px;
}

.card img {
  object-fit: cover;
  border-radius: 4px;
  margin-right: 15px;
}

.content {
  flex: 1;
}

.content h3 {
  margin-bottom: 8px;
  color: #333;
}

.content p {
  color: #666;
  font-size: 14px;
  line-height: 1.6;
}
</style>

效果

1735028982893-1735028890560-20241224_162600.gif

里面可能存在很多问题,望大佬能够指出,非常感谢