记录实现vue2不定高虚拟列表

294 阅读1分钟
<template>
  <div ref="scrollContainer" class="scroll-container" @scroll="throttle(handleScroll)()">
    <div ref="listContainer" class="list-container" :style="listStyle">
      <div
        v-for="(item, index) in visibleItems"
        :id="String(item.id)"
        :key="index"
        class="item"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

<script>
// 1、根据预估的item高度给每个item设置top和bottom(这里的top和bottom是item距离listContainer顶部的位置)、设置列表的总高度
// 2、根据滚动容器的高度和预测高度计算出最多显示几个item
// 3、当真实的item渲染时更新每个item的top和bottom、列表的总高度
// 4、当容器滚动时,找到item.bottom<=scrollTop && item.top>scrollTop的index并设置startIndex,并更新列表的显示范围,同时更新item的top、bottom和列表的总高度
const binarySearch = (list, value) => {
  let left = 0
  let right = list.length - 1
  let templateIndex = -1
  while (left < right) {
    const midIndex = Math.floor((left + right) / 2)
    const midValue = list[midIndex].bottom
    if (midValue === value) return midIndex + 1
    else if (midValue < value) left = midIndex + 1
    else if (midValue > value) {
      if (templateIndex === -1 || templateIndex > midIndex) templateIndex = midIndex
      right = midIndex
    }
  }
  return templateIndex
}
function throttle(fn, delay = 300) {
  let canRun = true
  return function() {
    if (!canRun) return
    canRun = false
    setTimeout(() => {
      fn.apply(this, arguments)
      canRun = true
    }, delay)
  }
}
function loadData(preLen = 0, length = 1000) {
  return Array.from({ length }, (_, index) => ({
    id: preLen + index,
    content: `Item ${preLen + index + 1}` + (Math.random() > 0.5 ? Array.from({ length: Math.floor(Math.random() * 300) }).fill('测试xxx').join(',') : 'aaaa')
  }))
}
// 预测高度
const estimatedHeight = 50
export default {
  data() {
    return {
      allItems: loadData(),
      positions: [],
      maxShowCount: 0,
      startIndex: 0,
      preLen: 0
    }
  },
  computed: {
    visibleItems() {
      const endIndex = Math.min(this.startIndex + this.maxShowCount, this.allItems.length)
      return this.allItems.slice(this.startIndex, endIndex)
    },
    listStyle() {
      const len = this.positions.length
      const offsetDis = this.startIndex > 0 ? this.positions[this.startIndex - 1].bottom : 0
      const height = len > 0 ? this.positions[len - 1].bottom - offsetDis : 0
      return {
        height: `${height}px`,
        transform: `translateY(${offsetDis}px)`
      }
    }
  },
  watch: {
    startIndex() {
      this.$nextTick(() => {
        this.updatePositions()
      })
    },
    allItems(newVal, oldVal) {
      if (newVal.length < oldVal.length) {
        this.positions = []
        this.preLen = 0
        this.startIndex = 0
        this.$refs.scrollContainer.scrollTop = 0
      }
      this.initPositions()
      this.$nextTick(() => {
        this.updatePositions()
      })
    }
  },
  mounted() {
    // 最大显示item数,+1是为了产生平滑滚动的效果
    this.maxShowCount = Math.ceil(this.$refs.scrollContainer.offsetHeight / estimatedHeight) + 1
    this.initPositions()
    this.$nextTick(() => {
      this.updatePositions()
    })
  },
  methods: {
    throttle,
    initPositions() {
      const diffLen = this.allItems.length - this.preLen
      const currentLen = this.positions.length
      const preTop = currentLen > 0 ? this.positions[currentLen - 1].top : 0
      const preBottom = currentLen > 0 ? this.positions[currentLen - 1].bottom : 0
      for (let i = 0; i < diffLen; i++) {
        const item = this.allItems[this.preLen + i]
        this.positions.push({
          index: item.id,
          top: preTop ? preTop + i * estimatedHeight : item.id * estimatedHeight,
          bottom: preBottom ? preBottom + (i + 1) * estimatedHeight : (item.id + 1) * estimatedHeight,
          height: estimatedHeight
        })
      }
      this.preLen = this.allItems.length
    },
    updatePositions() {
      const nodes = [...this.$refs.listContainer.childNodes]
      if (nodes.length === 0 || this.positions.length === 0) return
      nodes.forEach(node => {
        const item = this.positions[+node.id]
        // 设置item真实的高
        item.height = node.clientHeight
      })
      // 获取真实的高后,需要更新从startIndex开始(包含startIndex)的所有item的位置信息
      const startId = +nodes[0].id
      for (let i = startId; i < this.positions.length; i++) {
        const item = this.positions[i]
        item.top = this.positions[i - 1] ? this.positions[i - 1].bottom : 0
        item.bottom = item.top + item.height
      }
    },
    handleScroll() {
      const { scrollTop, clientHeight, scrollHeight } = this.$refs.scrollContainer
      this.startIndex = binarySearch(this.positions, scrollTop)
      const bottom = scrollHeight - clientHeight - scrollTop
      // 剩余高度小于20加载更多数据
      if (bottom <= 20) {
        this.$emit('loadMore')
      }
    }
  }
}
</script>

<style scoped>
.scroll-container {
  height: 300px;
  overflow-y: auto;
}

.item {
  border-bottom: 1px solid #ddd;
  text-align: center;
  padding: 20px;
}
</style>