前端虚拟列表实现

前言

虚拟列表是为了解决长列表的性能问题,采用虚拟列表可以避免渲染大量的 DOM。

原理

  1. 获取当前显示数据的开始下标以及结束下标
  2. 截取数据
  3. 通过 vue 渲染出项
  4. 通过样式 padding-top 和 padding-bottom 值加上列表项的总高度计算出滚动高度,并通过 padding-top 和 padding-bottom 来定位屏幕显示的项

 除了通过 padding-top 以及 padding-bottom 来实现外,还可以通过绝对定位加上 transform 实现,只不过如果使用这种方法,就需要多添加一个额外的元素,并设置此元素的高度为总高度。让页面撑开可滚动。

 不管哪种方法,原理都是一样的。

原理归原理,实践中情景也不一样,比如:项是固定高度的,项不是固定高度的。

固定高度

VirtualList.vue

<template>
  <div class="virtual">
    <div class="virtual-list" :style="{ paddingTop: offsetTop, paddingBottom: offsetBottom }">
      <virtual-list-item :key="index" v-for="(item, index) in dataToShow">
        <div>{{item.name}}</div>
      </virtual-list-item>
    </div>
  </div>
</template>

<script>
import VirtualListItem from './VirtualListItem'
export default {
  name: 'VirtualList',
  components: { VirtualListItem },
  data() {
    return {
      startIndex: 0,
      endIndex: 0,
      cache: [],
      remain: 5, // 缓冲量
      viewItemCount: 0, // 可视区可显示的条数
      scrollTop: 0,
      dataToShow: [],
      list: [],
      target: null,
      itemHeight: 50,
      totalCount: 31
    }
  },
  created() {
    for (let i = 0; i < this.totalCount; i++) {
      this.list.push({
        name: '列表项' + (i + 1)
      })
    }
    // 预处理所有的项的基本信息
    for (let key in this.list) {
      const top = key * this.itemHeight
      this.cache.push({
        index: +key, // 项在列表的对应的下标
        top: top,
        bottom: top + this.itemHeight //项底部与第一个元素顶部的距离
      })
    }
  },
  computed: {
    // 通过计算属性可以实现缓存
    holdHeight() {
      return this.list.length * this.itemHeight + 'px'
    },
    offsetTop() {
      return this.itemHeight * this.startIndex + 'px'
    },
    offsetBottom() {
      return this.itemHeight * (this.list.length - this.endIndex) + 'px'
    }
  },
  mounted() {
    this.target = document.querySelector('.virtual')
    const bRect = this.target.getBoundingClientRect()
    this.viewItemCount = Math.ceil(bRect.height / this.itemHeight) + this.remain // 渲染的个数
    this.endIndex = this.startIndex + this.viewItemCount // 最后一个的下标
    this.target.addEventListener('scroll', this.scrollHandler)
    this.loadData()
  },
  methods: {
    triggerUpdate(startIndex) {
      // 更新开始下标
      this.startIndex = startIndex
      // 更新结束下标
      this.endIndex = this.startIndex + this.viewItemCount
      // 判断结束下标有没有超过数据长度
      const over = this.endIndex - this.totalCount
      // 如果超过,根据实践来调整 startIndex 和 endIndex 的值
      if (over > 0) {
        this.startIndex = this.startIndex - over
        this.endIndex = this.endIndex - over
      }
      // 取数据
      this.loadData()
    },
    scrollHandler() {
      const { scrollTop } = this.target
      // 上滑
      if (scrollTop > this.scrollTop) {
        // 查找
        this.current = this.cache.find(item => item.bottom >= scrollTop)
        const { index } = this.current
        // 缓冲(滚动多少个项之后才再次渲染列表数据)
        if (index - this.startIndex > this.remain - 1) {
          this.triggerUpdate(index)
        }
      } else {
        // 下滑
        this.current = this.cache.find(item => item.top >= scrollTop)
        // 缓冲(滚动多少个项之后才再次渲染列表数据)
        if (this.current.index - this.startIndex < 1) {
          // 如果小于零,则取 0 作为起始下标
          this.triggerUpdate(Math.max(this.startIndex - this.remain, 0))
        }
      }
      this.scrollTop = scrollTop
    },
    loadData() {
      //截取要显示数据
      this.dataToShow = this.list.slice(this.startIndex, this.endIndex)
    }
  }
}
</script>


<style scoped>
.virtual {
  position: relative;
  height: 300px;
  overflow-y: auto;
}

VirtualListItem.vue

<template>
  <div class="virtual-item">
    <slot></slot>
  </div>
</template>


<script>
export default {
  name: 'VirtualListItem'
}
</script>


<style scoped>
.virtual-item {
  border-bottom: 1px solid #eee;
  box-sizing: border-box;
  padding: 12px;
}
</style>

通过两个组件组合实现,为的是方便使用。

动态高度

动态高度即每一项的高度可能都不一样,我们需要根据这些项动态计算出相关的值。

VirtualList.vue

<template>
  <div class="virtual">
    <div class="virtual-list" :style="{ paddingTop: offsetTop, paddingBottom: offsetBottom }">
      <virtual-list-item class="item" :key="index" v-for="(item, index) in dataToShow">
        <div>{{item.name}}</div>
      </virtual-list-item>
    </div>
  </div>
</template>


<script>
import VirtualListItem from './VirtualListItem'
export default {
  name: 'VirtualList',
  components: { VirtualListItem },
  data() {
    return {
      startIndex: 0, // 数据开始下标
      endIndex: 0, // 数据结束下标
      cacheMap: {}, // 缓存显示过的项的数据
      remain: 5,
      viewItemCount: 0, // 可视区可显示的条数
      scrollTop: 0,
      dataToShow: [],
      list: [],
      target: null,
      itemHeight: 50,
      totalCount: 155,
      bRect: null
    }
  },
  created() {
    // 生成随机长度的数据
    for (let i = 0; i < this.totalCount; i++) {
      this.list.push({
        name:
          '列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项列表项'.slice(
            0,
            Math.random() * 50
          ) +
          (i + 1)
      })
    }
  },
  computed: {
    // 通过计算属性可以实现缓存
    offsetTop() {
      let result = 0
      for (let i = 0; i < this.startIndex; i++) {
        result += this.cacheMap[i].height
      }
      return result + 'px'
    },
    offsetBottom() {
      return 50 + 'px'
    }
  },
  mounted() {
    this.target = document.querySelector('.virtual')
    this.bRect = this.target.getBoundingClientRect()
    this.viewItemCount =
      Math.ceil(this.bRect.height / this.itemHeight) + this.remain // 渲染的个数
    this.endIndex = this.startIndex + this.viewItemCount // 最后一个的下标
    this.target.addEventListener('scroll', this.scrollHandler)
    this.loadData()
  },
  methods: {
    triggerUpdate(startIndex) {
      // 更新开始下标
      this.startIndex = startIndex
      // 更新结束下标
      this.endIndex = this.startIndex + this.viewItemCount
      // 判断结束下标有没有超过数据长度
      const over = this.endIndex - this.totalCount
      // 如果超过,根据实践来调整 startIndex 和 endIndex 的值
      if (over > 0) {
        this.startIndex =
          this.startIndex - over > 0 ? this.startIndex - over : 0
        this.endIndex = this.endIndex - over
      }
      // 取数据
      this.loadData()
    },
    scrollHandler() {
      const { scrollTop } = this.target
      // 上滑
      if (scrollTop > this.scrollTop) {
        // 查找
        // this.current = this.cache.find(item => item.bottom >= scrollTop)
        for (let key in this.cacheMap) {
          if (this.cacheMap[key].bottom >= scrollTop) {
            this.current = this.cacheMap[key]
            break
          }
        }
        const index = this.current.index
        // 缓冲(滚动多少个项之后才再次渲染列表数据)
        if (index - this.startIndex > this.remain - 1) {
          this.triggerUpdate(index)
        }
      } else if (scrollTop < this.scrollTop) {
        // 下滑
        // this.current = this.cache.find(item => item.top >= scrollTop)
        for (let key in this.cacheMap) {
          if (this.cacheMap[key].top >= scrollTop) {
            this.current = this.cacheMap[key]
            break
          }
        }

        const index = this.current.index
        const startIndex = this.startIndex
        if (index - startIndex < 1) {
          // 如果小于零,则取 0 作为起始下标
          this.triggerUpdate(Math.max(startIndex - this.remain, 0))
        }
      }
      this.scrollTop = scrollTop
    },
    loadData() {
      //截取要显示数据
      this.dataToShow = this.list.slice(this.startIndex, this.endIndex)
      // 重新计算项的高度
      this.$nextTick(() => {
        const vi = document.querySelectorAll('.virtual-item')
        for (let i = 0, len = vi.length; i < len; i++) {
          // 取上一项的下标
          const tempIndex = this.startIndex + i - 1
          // 获取上一项的 top 值,如果不存在则默认取 0
          const top = this.cacheMap[tempIndex]
            ? this.cacheMap[tempIndex].bottom
            : 0
          // 判断是否已经为渲染过的数据,如果没有,则添加到数缓存中
          const height = vi[i].getBoundingClientRect().height
          if (!this.cacheMap[i + this.startIndex]) {
            const obj = {
              index: i + this.startIndex,
              top: top,
              height: height,
              bottom: top + height
            }
            this.cacheMap[i + this.startIndex] = obj
          }
        }
      })
    }
  }
}
</script>
<style scoped>
.virtual {
  position: relative;
  height: 300px;
  overflow-y: auto;
}
</style>

而 VirtualListItem.vue 文件代码不变,上面就是实现代码,但上面的代码中依然存在两个问题:

  1. 我们默认每一项的高度都为 50px 来进行初始化
  2. 当滚动到列表底部时,有可能无法触发下一轮的数据更新,而这个问题我们可以通过修改计算属性中的 paddingBottom 的值来解决这个问题。但这又会带来一个小小的副作用。就是滚动到底部时会出现多余的滚动距离,这个距离就是由于设置了 paddingBottom 造成的。

不过上面的这两个问题,一般都可以根据实践业务进行调整,可以做到避免出现 bug。

声明

1.原创文章,不经本站同意,不得以任何形式转载,如有不便,请多多包涵!

2.本文永久链接:http://yunkus.com/post/5e1c2304dcdde09f

3.如果觉得本文对你有帮助,或者解决了你的问题,不妨扫一扫右边的二维码打赏支持,你的一分一毫,可能会让世界变得更美好。

微信
扫一扫,赏我
支付宝
扫一扫,赏我