手动实现vue3的select下拉框的滚动加载和虚拟滚动,简单易懂

首先,滚动加载和虚拟滚动都是为了解决数据量大的渲染性能问题,但是也有区别,滚动加载并没有从根本上解决问题,只是滚动到可视区域底部动态加载,二虚拟滚动是永远只渲染固定数量(通常是可视区域内)的所以可以从根本上解决这种性能问题。

如果只想了解滚动加载只看第一部分即可。

这两种技术网上都有现成的插件,安装即可使用,本文为了让大家理解原理所以不用插件,纯手动实现

1.滚动加载

滚动加载的关键就是找到滚动区域并添加滚动监听事件

第一个坑:在 Element Plus 的 <el-select> 组件中,@scroll.native 确实不会起作用,因为它是一个封装的组件,不直接暴露原生的滚动事件。为了实现下拉框的滚动加载功能,你需要使用 @visible-change 事件来监听下拉框的显示状态,并在下拉框打开时添加滚动事件监听器。

第二个注意点就是如何判断滚动到可视区域的底部了,这个需要自行了解scrollHeight,scrollTop,clientHeight概念。

第三个坑:一定要给el-select添加一个poper-class,因为select的下拉框是在body上的,如果不使用 popper-class,滚动事件可能会被绑定到整个页面或父元素上,就不会精确的绑定要我们需要的区域。很多时候就是滚动区域没有精确绑定导致的问题。

代码如下:(可以使用全局自定义指令,这里定义格式有点麻烦,我没采用)

<template>
  <el-select
        v-model="value"
        class="myselect"
        placeholder="请选择!"
        popper-class="myselect-loadmore"
        @visible-change="handleVisibleChange"
      >
        <el-option
          v-for="(item, index) in itemList"
          :key="index"
          :label="item"
          :value="item"
        />
    </el-select>
</template>
<script lang="ts" setup>
  import { computed, nextTick, onMounted, ref } from 'vue';

  const value = ref(0)
  const itemList = ref([1,2,3,4,5,6,7,8,9,10,11,12]);
  const totalItems = ref(itemList.value.length);

  const handleVisibleChange = (visible) => {
    if (visible) {
        // 添加滚动事件监听
        const dropdown = document.querySelector('.myselect-loadmore .el-select-dropdown__wrap');
        dropdown.addEventListener('scroll', handleScroll);
    } else {
        // 移除滚动事件监听
        const dropdown = document.querySelector('.el-select-dropdown');
        dropdown.removeEventListener('scroll', handleScroll);
    }
 };
//防抖函数,由于滚动一次滑轮会触发多次scroll事件,需要控制一下触发的次数。
function debounce(func, delay) {
    let timeout;
    return function(...args) {
        const context = this;
        clearTimeout(timeout);
        timeout = setTimeout(() => func.apply(context, args), delay);
    };
}

const handleScroll = debounce((event) => {
  //判断是否到达可视区域底部,
  const bottom = event.target.scrollHeight === event.target.scrollTop + event.target.clientHeight;
  if (bottom) {
        //到达可视区域底部就给下拉列表增加1个值,真实情况可能在这里调用Ajax接口获取新值并添加进来
      const newItems = Array.from({ length: 1 }, (_, i) => totalItems.value + i + 1);
      itemList.value.push(...newItems);
      totalItems.value += newItems.length;
    }
}, 100);
</script>

2.虚拟滚动

虚拟滚动就是对长列表进行截断,然后把相应的截断的列表通过transform属性移动到可视区域内。虚拟滚动需要注意的地方

第一:本文描述的固定item,也就是item的高度是固定的,在可视区域已知的情况下,可以渲染的条目数是固定的,因此获取到起始索引加一下这个数目就得到了末尾索引。

第二:这里的虚拟滚动需要用到滚动事件的监听,因此在滚动加载的基础上改造一下即可,倘若不考虑滚动加载的数据也就是你拿到的下拉数据是完整的直接用首尾索引进行截取即可,那么也就不需要下面滚到底部额外添加数据的代码。

第三:通过 transform: translateY() 来改变元素位置,避免重排,提高渲染性能。

基于1的代码改造一下完整版如下:

<template>
  <el-select
        v-model="value"
        class="myselect"
        placeholder="请选择!"
        popper-class="myselect-loadmore"
        @visible-change="handleVisibleChange"
      >
     <div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">   //把要渲染的列表移动到可视区域内
        <el-option
          v-for="(item, index) in itemList"
          :key="index"
          :label="item"
          :value="item"
        />
    <div>
  </el-select>
</template>

 

<script lang="ts" setup>
  import { computed, nextTick, onMounted, ref } from 'vue';
//滚动加载相关变量
  const value = ref(0)
  const itemList = ref([1,2,3,4,5,6,7,8,9,10,11,12]);
  const totalItems = ref(itemList.value.length);
//虚拟滚动相关变量
  const scrollTop = ref(0);
//获取要截取数组的起始索引,这里的30是每一项的高度,也就是已知的,可根据实际情况设置。
  const startIndex = computed(() => Math.floor(scrollTop.value / 30)); 
//截取数组
  const visibleItems = computed(() => {
//这里的274是可视区域的高度,我这里直接写死了,可根据你实际情况来获取。
    const endIndex = Math.min(startIndex.value + Math.ceil(274 / 30), totalItems.value);
      return itemList.value.slice(startIndex.value, endIndex);
  });
//纵向移动的高度
  const offsetY = computed(() => startIndex.value * 30);

  const handleVisibleChange = (visible) => {
    if (visible) {
        // 添加滚动事件监听
        const dropdown = document.querySelector('.myselect-loadmore .el-select-dropdown__wrap');
        dropdown.addEventListener('scroll', handleScroll);
    } else {
        // 移除滚动事件监听
        const dropdown = document.querySelector('.el-select-dropdown');
        dropdown.removeEventListener('scroll', handleScroll);
    }
 };
//防抖函数,由于滚动一次滑轮会触发多次scroll事件,需要控制一下触发的次数。
function debounce(func, delay) {
    let timeout;
    return function(...args) {
        const context = this;
        clearTimeout(timeout);
        timeout = setTimeout(() => func.apply(context, args), delay);
    };
}

const handleScroll = debounce((event) => {
  //判断是否到达可视区域底部,
  const bottom = event.target.scrollHeight === event.target.scrollTop + event.target.clientHeight;
//记录一下滚动的距离方便计算起始索引
  scrollTop.value = event.target.scrollTop;
  if (bottom) {
        //到达可视区域底部就给下拉列表增加1个值,真实情况可能在这里调用Ajax接口获取新值并添加进来
      const newItems = Array.from({ length: 1 }, (_, i) => totalItems.value + i + 1);
      itemList.value.push(...newItems);
      totalItems.value += newItems.length;
    }
}, 100);
</script>

 

注意:上面的情况滚动过快会出现留白的情况,原因就是我做了防抖,导致滚动事件的触发比滚动操作要慢一些,如果要不做防抖,留白出现的几率会减少,但是也会出现滚动有点卡顿的感觉,因此可根据实际情况来决定是否添加防抖或者设置合适的延时时间。

3.不定长高度的情况

对于固定长度的很好计算首尾索引,直接除一下每一项的高度即可。对于不固定长度的只能硬算,看看每一项的高度加上之后是否超过可视区域。

第一个需要改动的是html部分:这里的下拉值就要使用对象的形式,要包含每个元素的高度属性,实际情况可能需要自己单独计算

<el-select
        v-model="value"
        class="myselect"
        placeholder="请选择!"
        popper-class="myselect-loadmore"
        @visible-change="handleVisibleChange"
      >
      <div class="virtual-list-inner" :style="{ height: totalHeight + 'px', position: 'relative' }">
        <el-option
          v-for="(item, index) in visibleItem"
          :key="index"
          :label="item.text"
          :value="item.text"
          :style="{
            position: 'absolute',
            top: item.top + 'px',
            height: item.height + 'px',
            border: '1px solid #ccc'
          }"
        />
      </div>
 </el-select>

解释:首先要给弹出框设计一个高度不然首次加载会撑不开,另外定位改成relative是为了方便el-option能相对于其进行定位。

第二点就是给el-option添加一个absolute定位,是方便对其进行上下移动,由于之前是固定高度可以使用transform整体移动,这里不固定就需要单独使用top进行移动。

然后就是计算索引的部分如下:

function calcIndex() {
  let start = 0;
  let end = 0;
  let height = 0;

  // 计算开始索引
  while (height < scrollTop.value && start < itemList.value.length) {
    height += itemList.value[start].height;
    start++;
  }

  // 计算结束索引
  end = start;
// 这里的270是我这边下拉框的可是区域的高度
while (height < scrollTop.value + 270 && end < itemList.value.length) { height += itemList.value[end].height; end++; } //防止出现空白的情况
if(start > 1) {
start--
} visibleItem.value
= itemList.value.slice(start, end+1).map((item, index) => ({ text: item.text, top: itemList.value.slice(0, start + index).reduce((acc, curr) => acc + curr.height, 0), height: item.height, })); }

这个方法在每次滚动事件触发的时候都调用一次即可。

注意:在滚动过程中由于不固定高度,很容易出现空白的情况,因为那个高度加上就超过可是区域,不加就会留下空白,所以建议把首尾索引扩大一些,我这里是扩大了1个单位,实际工作中可根据自己的情况来扩大。

总结:不固定高度的情况关键就是计算首尾索引,起始索引就是累加看看有没有超过scrollTop,末尾索引其实就是遍历元素,累加高度看看是否超过屏幕可视区域高度+scrollTop,然后就是计算每个元素的偏移量top

最后的最后:除非有特殊的场景,插件满足不了,否则实际项目中还是建议使用现成的插件,人家里面做了很多优化也更加稳定,并且不用在项目里面写那么多底层代码不是更好吗

posted @ 2024-11-29 10:39  122www  阅读(227)  评论(0编辑  收藏  举报