虚拟滚动的理解

既可以滚动加载,也不会额外增加DOM数量,随着滚动变化原有几个DOM元素的值

思考

1. 容器该如何布局 ?

2. 如何动态变化可视区域内dom的值 ?

3. 滚动条的长度如何控制?

4. 如何判断上拉触底了 ?

列表容器的布局

1. 新建一个组件ListScroll,容器内部含:滚动条盒子、展示的列表盒子、加载提示盒子

2. 注意最外层的容器viewport高度设置100%,让父组件去决定ListScroll组件的高度

3. 注意让展示的列表盒子绝对定位铺满容器viewport,滚动条的盒子高度需要动态计算

4. 监听容器viewport的滚动,要动态设置列表盒子的translateY属性,让盒子往上移动

动态变化展示数量内的dom元素的值

1. 涉及到的元素的几何属性:容器的viewport的offsetHeight高度,容器的viewport的scrollTop滚动的高度

2. 这个方案需要设置每行数据的高度的 rowHeight

3. 定义startIndex和endIndex,用来截取数据的

4. 通过容器的viewport的scrollTop滚动的高度 / 每行的高度,能获得当前的startIndex

5. 列表展示的数据,是展示源数据通过startIndex和endIndex截取后的数据

滚动条的长度

滚动条的长度 = 源数据的长度 * 每行的高度

判断上拉触底

监听滚动时,当容器的viewport的offsetHeight高度 + 容器的viewport的scrollTop滚动的高度 等于 列表的总长度 则表示触底了

完整代码的实现

ListScroll组件

// components/ListScroll.vue

<template>
  <div class="viewport" ref="viewport" @scroll="onScroll">
    <!-- 滚动条 -->
    <div class="scrollbar" :style="{height: listHeight + 'px'}"></div>
    <!-- 展示的列表 -->
    <div class="list" ref="list" :style="{ transform: `translateY(${transformOffset}px)` }">
      <div class="row" :style="{height: rowHeight + 'px'}" v-for="(item, index) in showList" :key="index">
        <slot :record="item"></slot>
      </div>
    </div>
    <!-- 加载 -->
    <div class="loading_wrap" v-show="loading">
      <div class="loading">
        <div class="container"></div>
      </div>
      <div>正在加载中</div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    // 数据源
    list: {
      type: Array,
      default: () => []
    },
    // 每行的高度
    rowHeight: {
      type: Number,
      default: 200
    },
    // 显示数量
    viewCount: {
      type: Number,
      default: 10
    },
    // 控制loading
    loading: {
      type: Boolean,
      default: false
    }
  },
  data () {
    return {
      viewHeight: 0, // 可视区域的高度
      startIndex: 0, // 开始索引
      endIndex: 0, // 结束索引
      transformOffset: 0, // 列表的偏移量
      isLoading: false // 控制loading
    }
  },
  mounted () {
    this.initData()
  },
  computed: {
    // 展示的数据
    showList () {
      return this.list.slice(this.startIndex, this.endIndex)
    },
    // 列表的总高度
    listHeight () {
      return this.list.length * this.rowHeight
    }
  },
  methods: {
    // 初始化一些数据
    initData () {
      this.endIndex = this.viewCount
      this.viewHeight = this.$refs.viewport.offsetHeight
    },

    // 列表滚动
    onScroll () {
      const scrollTop = this.$refs.viewport.scrollTop // 获取试图往上滚动的高度
      const currentIndex = Math.floor(scrollTop / this.rowHeight) // 计算当前的索引
      // 只在需要变化的时 才重新赋值
      if (this.startIndex !== currentIndex) {
        this.startIndex = currentIndex
        this.endIndex = this.startIndex + this.viewCount// 结束索引
        this.transformOffset = scrollTop - (scrollTop % this.rowHeight)
      }
      // 触底了
      if ((this.viewHeight + scrollTop) === this.listHeight) {
        // 发送触底加载事件
        this.$emit('bottomLoad')
      }
    }
  }
}
</script>

<style lang="less" scoped>
/*
------最外层容器---------*/
.viewport {
  width: 100%;
  height: 100%; // 这个的高度让父组件去决定
  background-color: #fff;
  position: relative;
  overflow-y: auto;
}
/*
------列表展示层容器---------*/
.list {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
/*
------每行容器---------*/
.row {
  overflow: hidden;
}
/*
------loading样式---------*/
.loading_wrap {
  display: flex;
  justify-content: center;
  align-items: center;
  color: #999;
  padding: 20px 0;
  .loading {
    box-sizing: border-box;
    width: 20px;
    height: 20px;
    border: 2px solid #ddd;
    border-radius: 50%;
    animation: rotate 1s linear infinite;
    margin-right: 10px;
  }
  .container {
    position: relative;
    top: 50%;
    left: 50%;
    width: 10px;
    height: 10px;
    background-color: #fff;
  }
}
/*
------loading动画---------*/
@keyframes rotate {
  from {
    transform-origin: center center;
    transform: rotate(0deg);
  }
  to {
    transform-origin: center center;
    transform: rotate(360deg);
  }
}
</style>

页面引入ListScroll组件

<template>
  <div class="page">
    <h3>长列表渲染</h3>
    <ListScroll
      class="list_scroll"
      :list="listData"
      :loading="isLoading"
      @bottomLoad="onBottomLoad">
      <template v-slot="{ record }">
        <div class="row_content" @click="handleClick(record)">
          <div>{{ record }}</div>
          <img class="image" src="https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2F2076f7ae-d134-4dc4-a865-af1b2029d400%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1680249943&t=7646a71b62c810256a2b414e96106808" />
        </div>
      </template>
    </ListScroll>
  </div>
</template>

<script>
import ListScroll from '@/components/ListScroll.vue'
export default {
  data () {
    return {
      listData: [], // 总数据
      isLoading: false // 展示loading
    }
  },
  mounted () {
    this.getListData()
  },
  components: {
    ListScroll
  },
  methods: {
    // 获取数据
    getListData () {
      const count = 20 + this.listData.length
      const start = this.listData.length
      this.isLoading = true
      setTimeout(() => {
        for (let i = start; i < count; i++) {
          this.listData.push(i)
        }
        this.isLoading = false
      }, 500)
    },
    // 监听触底事件
    onBottomLoad () {
      console.log('触底了')
      if (this.listData.length >= 100) {
        console.log('数据加载完了~')
        return
      }
      // 加载数据
      this.getListData()
    },
    // 监听点击每行
    handleClick (record) {
      console.log(record, 'record')
    }
  }
}
</script>

<style lang="less" scoped>
.page {
  display: flex;
  flex-direction: column;
  height: 100vh;
  .list_scroll {
    flex: 1;
  }
}
.row_content {
  width: 100%;
  height: 100%;
  .image {
    display: block;
    width: 300px;
    height: 160px;
    object-fit: cover;
  }
}
</style>

总结

个人感觉这种长列表虚拟滚动的方案,也是很适用于平常的列表触底加载业务,以往很多同学都是通过pageSize设置为10来只先渲染10条数据,导致频繁的的下拉会多次发送请求。通过这种方案,我们可以一次性先获取50条的数据,但是控制只在页面展示10个DOM元素,这是不是也巧妙的减少了发送请求获取数据的次数了呢 ?

~~ end ~~

Logo

前往低代码交流专区

更多推荐