利用vue3写一个简单的无限滚动

4,191 阅读2分钟

起因:这几天公司大屏的时候有一个需求,就是如题的无限滚动,大概的效果是这样的:

QQ录屏20230217094036.gif

首先是思路,无限滚动的原理和虚拟列表类似,只不过虚拟列表是通过控制scrollTop来控制渲染的元素,而我们的无限滚动是按照我们预想的时间渲染元素。而滚动的效果可以通过控制数据层top与视窗的overflow: hidden来实现

  1. 方案1: 利用css动画不断修改数据层top,实现滚动效果,同时通过控制动画时间以及渲染数据的起始索引来同步修改渲染的数据。
  2. 方案2:利用关键帧(requestAnimationFrame)不断修改数据层top并在恰当的时机修改渲染数据的起始索引。

代码实现:

假设我们一共有8条数据,数据的格式是['我是第0条','我是第一条'...],而我们需要渲染的数据就是截取原数据的某一部分,所以我们需要的参数如下:

  1. 开始索引: stratIndex
  2. 结束索引: endIndex
  3. 实际渲染的数据: showData

showData的获取:

  1. 假设我们的每一条数据高度都是30px,首先初始索引startIndex当然是0毋庸置疑,现在要讨论的就是我们截取的endIndex,我们可以通过Math.floor(视窗的高度/30)来获取到视窗能展示的数据条数,但是这时候获取的条数可能是带有小数的,因此将值取整并+1才是我们视窗内需要渲染的数据索引,而数组的slice方法的endIndex是截取的结束索引,因此需要再+1。
const viewContain = ref<HTMLDivElement|null>(null)
const startIndex = ref<number>(0)
const endIndex = computed(() => {
    return startIndex.value + Math.floor(viewContain.value?.clientHeight! / 30) + 2
})
  1. 有了开始的索引和结束索引之后,就是最为重要的渲染数据了,这里要分为两种情况讨论:
  • 当结束索引小于数组最大索引时, 直接截取即可
  • 当结束索引大于数组最大索引时, 我们需要显示的数据应该是起始索引到数组最后一项以及数组第0项到(endIndex - 数组最大索引)
const showData = computed(() => {
    if(endIndex.value <= apiData.value.length - 1){
      return apiData.value.slice(startIndex.value, endIndex.value)
    }else {
      return apiData.value.slice(startIndex.value).concat(apiData.value.slice(0, endIndex.value - (apiData.value.length - 1)))
    }
})

到这里,我们最关键的showData的逻辑已经完成了,现在只需要在适当的时机切换我们的startIndex.value即可。

两种方案的实现:

  • 通过css动画实现
<template>
  <div class="view-contain" ref="viewContain">
    <div :class="{'data-contain': true}" ref="showContain">
      <div class="data-contain-item" v-for="(item, idx) in showData" :key="item">
        {{ item.content }}
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
  import { computed, onMounted, ref } from 'vue';
  //生成伪数据
  const apiData = ref<any[]>([])
  for (let i = 0; i < 8; i++) {
    apiData.value.push({content: `我是第${i}条数据`, id: i})
  }
  const viewContain = ref<HTMLDivElement|null>(null)
  const showContain = ref<HTMLDivElement|null>(null)
  const startIndex = ref<number>(0)
  const endIndex = computed(() => {
    return startIndex.value + Math.floor(viewContain.value?.clientHeight! / 30) + 2
  })
  
  const showData = computed(() => {
    if(endIndex.value <= apiData.value.length - 1){
      return apiData.value.slice(startIndex.value, endIndex.value)
    }else {
      return apiData.value.slice(startIndex.value).concat(apiData.value.slice(0, endIndex.value - (apiData.value.length - 1)))
    }
  })
  
  onMounted(() => {
    //组件挂载后,通过定时器每秒让startIndex加1
    setInterval(() => {
      if(startIndex.value === apiData.value.length -1){
        startIndex.value = 0
      }else {
        startIndex.value += 1
      }
    }, 1000)
  })

</script>

<style scoped lang="scss">
  @keyframes scroll-data{
    0% {
      top: 0
    }
    100% {
      top: -30px
    }
  }
  .view-contain{
    width: 500px;
    height: 80px;
    overflow: hidden;
    position: relative;
    .data-contain{
      position: absolute;
      top: 0;
      width: 100%;
      display: flex;
      flex-direction: column;
      animation: scroll-data infinite 1s linear;
      &-item{
        width: 100%;
        height: 30px;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    }
  }
</style>

缺陷:小伙伴们复制到自己的项目后可能会发现有闪屏的现象,具体原因我也没查明,但是我觉得是css定时器和js定时器不同步或计时不准。

  • 通过requestAnimationFrame实现
<template>
  <div class="view-contain" ref="viewContain">
    <div :class="{'data-contain': true}" ref="showContain">
      <div class="data-contain-item" v-for="(item, idx) in showData" :key="item">
        {{ item.content }}
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
  import { computed, onMounted, ref } from 'vue';

  const apiData = ref<any[]>([])
  for (let i = 0; i < 8; i++) {
    apiData.value.push({content: `我是第${i}条数据`, id: i})
  }
  const viewContain = ref<HTMLDivElement|null>(null)
  const showContain = ref<HTMLDivElement|null>(null)
  const startIndex = ref<number>(0)
  const endIndex = computed(() => {
    return startIndex.value + Math.floor(viewContain.value?.clientHeight! / 30) + 2
  })
  
  const showData = computed(() => {
    if(endIndex.value <= apiData.value.length - 1){
      return apiData.value.slice(startIndex.value, endIndex.value)
    }else {
      return apiData.value.slice(startIndex.value).concat(apiData.value.slice(0, endIndex.value - (apiData.value.length - 1)))
    }
  })
  
  onMounted(() => {
    let start = null;
    // 当前的起始索引
    let frameStartIdx = 0
    function step(timestamp) {
      if (start === null) {
        start = timestamp;
      }
      const elapsed = timestamp - start!;
      // elapsed为起始帧和当前帧差值,由于我们想的是每1s滚动过一条,因此每1ms运动的距离为0.03px
      const count = Math.min(0.03 * elapsed, 30);
      startIndex.value = frameStartIdx
      showContain.value!.style.top = `-${count}px`;
      if (count === 30) {
      // 当运动了30px后,重置我们的showContain位置
        showContain.value!.style.top = '0'
        if(frameStartIdx === apiData.value.length - 1){
      // 起始帧数大于原数据最大索引后重置
          frameStartIdx = 0
          startIndex.value = frameStartIdx
        }else {
          frameStartIdx += 1
          startIndex.value = frameStartIdx
        }
        start = null
      }
      // 递归调用,实现无限滚动
      requestAnimationFrame(step);
    }
    requestAnimationFrame(step)
  })

</script>

<style scoped lang="scss">
  .view-contain{
    width: 500px;
    height: 80px;
    overflow: hidden;
    position: relative;
    .data-contain{
      position: absolute;
      top: 0;
      width: 100%;
      display: flex;
      flex-direction: column;
      &-item{
        width: 100%;
        height: 30px;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    }
  }
</style>

  • 使用requestAnimationFrame后没有发现闪屏问题。
第一次写文章可能写的不太清楚,有问题的话大家可以在评论区一起讨论一起进步,谢谢大家!