# 虚拟列表实现

如果一个列表数据量过大,一起渲染时就会出现明显的卡顿或者白屏,我们可以使用虚拟列表思想来进行优化,每次在视图内只渲染固定数量的元素,随着滚动列表动态更改渲染列表的元素,不用把每一个元素都渲染出来,而用户是感知不到的。

# 实现思路

如下图,我们实际只在DOM中渲染超出内容区域的一部分列表,称之为实际渲染列表,我们把渲染列表分为三个部分,内容区域和上下缓存列表,内容区域就是用户实际能看到的区域,上下两个缓存列表是为了不让用户感知数据在变化,上下两个缓存区域尽量要大一些,整体列表高度一般取内容区域的三倍为好,不要把数量限制的太死,这样在滚动时有足够的时间来操作DOM和进行加载。

我们在滚动时让渲染列表数量一直保持不变,往上滚动一个,渲染列表上面就删除一个,下面就要添加,往下滚动时,上面就添加一个,下面就删除,这样不论列表怎么滚动,我们页面中就这些固定的元素在渲染,从而解决了大量数据渲染卡顿的问题。

图片

实现虚拟列表有两个关键点:

一、如何知道渲染列表滚动时需要删除和添加多少元素?

二、渲染列表只是一小部分数据,页面实际的滚动条很短,如何维持滚动条一直滚动?

要想知道滚动了多少,我们可以通过scroll滚动条获取滚动距离,计算滚动了多少个元素,然后在开头或者结尾进行删除对应的数量,永远让渲染列表保持同样的数量,向上滚动一个元素的距离,上面就删掉一个,下面就添加一个,向下滚动则相反。

为了让滚动可以一直保持滚动,在删除元素时需要有东西填充对应的高度让滚动条保持不变,我们可以通过translateY()进行向下移动,删除多少个移动多少个距离就行,这样就填充了滚动条。

从上面两个关键点可以看出,实现虚拟滚动最关键的问题就是如何知道滚动了多少个元素的距离,只要知道这个就可以进行动态增删和填充滚动条。

# 固定高度元素实现

如果一个列表是固定高度,我们可以直接通过scrollTop来计算出滚动了多少个元素,然后直接对渲染列表进行增删和填充滚动条就行了。

我们先定义一个源数据列表,和一个渲染列表,源数据列表只用来保存和获取新数据,不做任何操作,渲染列表用来真正的在容器内渲染,我们通过计算startIndex和endIndex来从源数据列表动态截取渲染列表,随着滚动进行,这个两个索引一直在改变,渲染列表也随之不断更新。

<template>
  <div class="home" @scroll="scrollListener">
    <div class="home-list" :style="{ height: `${scrollList.length * itemHeight}px` }">
      <div :style="{ transform: `translateY(${offsetY}px)` }">
        <div class="list-item" :style="{ height: `${itemHeight}px` }" v-for="(item, index) in virtureList" :key="index">
          <span>{{ item }}</span>
        </div>
      </div>
    </div>
  </div>
</template>


<script>
export default {
  data() {
    return {
      scrollList: [...Array(1000).keys()], // 源数据
      scrollTop: 0, // 滚动距离
      itemHeight: 100, // 列表项高度
      pageSize: 10, // 缓存数量
    }
  },
  computed: {
    // 截取的开始索引
    startIndex() {
      return Math.max(Math.ceil(this.scrollTop / this.itemHeight) - this.pageSize, 0)
    },
    // 截取的结束索引
    endIndex() {
      return Math.min(Math.ceil(this.scrollTop / this.itemHeight) + this.pageSize * 2, this.scrollList.length - 1)
    },
    // 截取的数据,真正进行渲染的数据
    virtureList() {
      return this.scrollList.slice(this.startIndex, this.endIndex)
    },
    // 需要移动的距离,弥补滚动距离
    offsetY() {
      return this.startIndex * this.itemHeight
    }
  },
  methods: {
    // 滚动监听
    scrollListener(e) {
      // 获取滚动条高度
      this.scrollTop = e.target.scrollTop
      // 如果是分页加载,当滚动endIndex+pageSize大于等于源数据时
      // 就可以提前去获取数据了
    }
  }


}
</script>
<style scoped lang="scss">
.home {
  width: 400px;
  height: 600px;
  margin: 0 auto;
  overflow-y: auto;
  border: 1px solid #ccc;
  .home-list {
    width: 100%;
    .list-item {
      width: 100%;
      background-color: #ccc;
      border: 1px solid red;
    }
  }
}
</style>

在调用scroll时要进行节流,不用频繁的进行触发。还有一个思路,为了不频繁的在scroll里进行计算,我们可以把渲染列表分为二维数组,每个里面渲染一个容器的数据,保持最少3个元素进行动态切换。

# IntersectionObserver方法实现

通过实时监听滚动条计算太过耗费性能,我们可以通过 IntersectionObserver (opens new window) 方法观察元素来实现虚拟列表。

在渲染列表上,给第一个和最后一个元素上绑定一个id,然后通过IntersectionObserver方法观察这上下两个id,如果最后一个元素进入视图,则渲染列表上面需要删除一个,下面需要增加一个,同时id绑定到最新的添加元素上,反之依然。其实现思路就是一直不停更改绑定上下两个元素id,从而一直更改观察的元素对象,达到动态更新渲染列表的目的。

<template>
  <div class="home" ref="homeRef">
    <div class="home-list" ref="listRef">
      <div :style="{ transform: `translateY(${offsetY}px)` }">
        <div 
          class="list-item"
          :style="{ height: `${itemHeight}px` }"
          :id="index == 0 ? 'top' : (index == virtureList.length - 1 ? 'bottom' : '')"
          v-for="(item, index) in virtureList" 
          :key="index"
        >
          <span>{{ item }}</span>
        </div>
      </div>
    </div>
  </div>
</template>


<script>
/**
 * 通过IntersectionObserver实现虚拟列表
 * 通过startIndex、endIndex动态截取渲染列表,动态给第一个和最后一个更新id,对这两个元素进行观察
 * 通过IntersectionObserver观察进入和离开更新startIndex、endIndex,这样获取的渲染列表一直是更新的
 * 最后一个进入视图,说明滚动到底部,要进行添加元素,第一个元素进入视图,说明滚动到顶部,要进行删除元素
 * 这样可以实现等高的虚拟滚动
 * */ 
export default {
  name: 'HomeView',
  data() {
    return {
      scrollList: [...Array(1000).keys()],
      itemHeight: 100, // 列表项高度
      observer: null,
      startIndex: 0,
      endIndex: 20
    }
  },
  computed: {
    // 截取的数据,真正进行渲染的数据
    virtureList() {
      return this.scrollList.slice(this.startIndex, this.endIndex)
    },
    // 需要移动的距离,弥补滚动距离
    offsetY() {
      return this.startIndex * this.itemHeight
    }
  },
  mounted() {
    this.observeScrollEnd()
  },
  methods: {
    // 观察开始和结尾元素
    observeScrollEnd() {
      this.observer = new IntersectionObserver(entries => {
        console.log(entries)
        entries.forEach((entry) => {
          // 向下滚动到最后一个,开头增加,末尾增加
          if (entry.target.id === 'bottom') {
            if (entry.isIntersecting) {
              console.log('向下滚动')
              this.startIndex += 1
              this.endIndex += 1
              if (this.endIndex >= this.scrollList.length) {
                this.endIndex = this.scrollList.length
              }
            }
          }
          // 向上滚动到第一个
          if (entry.target.id === 'top') {
            if (entry.isIntersecting) {
              if (this.startIndex == 0) return
              console.log('向上滚动')
              this.endIndex -= 1
              this.startIndex -= 1
              if (this.endIndex <= 0) {
                this.startIndex = 0
              }
            }
          }
        })
      })


      const startRef = document.getElementById('top')
      const endRef = document.getElementById('bottom')
      // 监听开始和结束位置元素
      this.observer.observe(startRef)
      this.observer.observe(endRef)
    }
  }
}
</script>
<style scoped lang="scss">
.home {
  width: 400px;
  height: 600px;
  margin: 0 auto;
  overflow-y: auto;
  border: 1px solid #ccc;
  .home-list {
    width: 100%;
    .list-item {
      width: 100%;
      background-color: #ccc;
      border: 1px solid red;
    }
  }
}
</style>

# 动态高度元素实现

动态高度实现虚拟列表需要在元素渲染后获取到所有的高度存储起来,把所有的高度累加起来进行计算,我们通过IntersectionObserver方法来进行实现动态高度的虚拟列表。

IntersectionObserver方法实现最关心的就是计算translate()的高度,来填充滚动条距离,我们在渲染列表渲染好后记录每个元素的高度,在动态更改数据时,根据索引值累加索引之前所有的高度,计算出translate()的高度了。

<template>
  <div class="home" ref="homeRef">
    <div class="home-list" ref="listRef">
      <div :style="{ transform: `translateY(${offsetY}px)` }">
        <div 
          class="list-item"
          :style="{ height: `${item.height}px` }"
          :id="index == 0 ? 'top' : (index == virtureList.length - 1 ? 'bottom' : '')"
          :ref="(el) => renderItemsRef(el, item.id)"
          v-for="(item, index) in virtureList" 
          :key="index"
        >
          <span>{{ item.name }}</span>
        </div>
      </div>
    </div>
  </div>
</template>


<script>
/**
 * 通过IntersectionObserver实现动态高度虚拟列表
 * */ 
export default {
  data() {
    return {
      observer: null,
      scrollList: [],
      startIndex: 0,
      endIndex: 20,
      offsetMap: {}
    }
  },
  computed: {
    // 截取的数据,真正进行渲染的数据
    virtureList() {
      return this.scrollList.slice(this.startIndex, this.endIndex)
    },
    // 需要移动的距离,弥补滚动距离
    offsetY() {
      const heightList = Object.values(this.offsetMap)
      const offsetY = heightList.slice(0, this.startIndex).reduce((acc, cur) => acc + cur, 0)
      return offsetY
    }
  },
  created() {
    this.scrollList = [...Array(1000).keys()].map((el, i) => {
      return {
        id: i,
        name: `name${i}`,
        height: Math.floor(Math.random() * 100) + 50,
      }
    })
  },
  mounted() {
    this.observeScrollEnd()
  },
  methods: {
    // 获取渲染好的元素高度
    renderItemsRef(el, id) {
      this.$nextTick(() => {
        if (el && !this.offsetMap[id]) {
          this.offsetMap[id] = el.clientHeight
        }
      })
    },


    // 观察开始和结尾元素
    observeScrollEnd() {
      this.observer = new IntersectionObserver(entries => {
        console.log(entries)
        entries.forEach((entry) => {
          // 向下滚动到最后一个,开头增加,末尾增加
          if (entry.target.id === 'bottom') {
            if (entry.isIntersecting) {
              console.log('向下滚动')
              this.startIndex += 1
              this.endIndex += 1
              if (this.endIndex >= this.scrollList.length) {
                this.endIndex = this.scrollList.length
              }
            }
          }
          // 向上滚动到第一个
          if (entry.target.id === 'top') {
            if (entry.isIntersecting) {
              if (this.startIndex == 0) return
              console.log('向上滚动')
              this.endIndex -= 1
              this.startIndex -= 1
              if (this.endIndex <= 0) {
                this.startIndex = 0
              }
            }
          }
        })
      })


      const startRef = document.getElementById('top')
      const endRef = document.getElementById('bottom')
      // 监听开始和结束位置元素
      this.observer.observe(startRef)
      this.observer.observe(endRef)
    }
  }


}
</script>
<style scoped lang="scss">
.home {
  width: 400px;
  height: 600px;
  margin: 0 auto;
  overflow-y: auto;
  border: 1px solid #ccc;
  .home-list {
    width: 100%;
    .list-item {
      width: 100%;
      background-color: #ccc;
      border: 1px solid red;
    }
  }
}
</style>

# Vue插件使用

vue-virtual-scroller (opens new window)

支持Vue2/Vue3,支持各种类型列表、表格、瀑布流等,并且支持水平和垂直滚动,也支持动态列表项。

vue-virtual-scroll-list (opens new window)