使用vue3 composition-api封装el-select实现下拉虚拟滚动

205 阅读2分钟

1、问题背景:进来有遇到上千条数据进行下拉选择,导致页面卡顿用户体验差
2、技术框架:使用vue2项目基础上使用vue3 composition-api,UI框架使用element-ui。
解决思路: 这是一个常见的优化点通常采用虚拟滚动,只显示可视区范围内的数据因为element-ui select组件不支持虚拟滚动,故而手动实现了一下。
对el-select二次封装实现虚拟滚动: 1、构造props包括 options: 下拉列表选项
optionLabel: 下拉列表label属性名
optionValue: 下拉列表项value对应属性名
itemHeight: 每项的固定高度
2、监听scroll跟列表动画开始transitionstart事件
3、通过itemHeight与滚动scrollTop计算开始展示项start跟结束项end更新options项


  let { height } = selectRef.value.$refs.scrollbar.$refs.wrap.getBoundingClientRect();

  height = height === 0 ? 8 * props.itemHeight : height;

  scrollTop.value <= 0 ? scrollTop.value = 0 : scrollTop.value;

  let start = (Math.abs(scrollTop.value) / props.itemHeight) * props.itemHeight > arrs.value.length * props.itemHeight - height ? Math.floor((Math.abs(scrollTop.value) - height) / props.itemHeight) : scrollTop.value === 0 || Number.isNaN(scrollTop.value) ? 0 : Math.floor(Math.abs(scrollTop.value) / props.itemHeight); // 计算开始索引

  let end = scrollTop.value === 0 || Number.isNaN(scrollTop.value) ? height / props.itemHeight : start + 10; // 计算结束索引(包含不完全显示的项)

4、当展示下拉选项时计算scrollTop位置代码如下

const visibleChange = (val) => {

  if (val) {

    nextTick(() => {

      arrs.value = props.options;

      isVisible.value = true;

      setScrollWrapHeight();
      //更新选项数据
      updateVisibleItems();

    })

  }

}
const setScrollWrapHeight = () => {

  const index = (arrs.value || []).findIndex(item => item[props.optionValue] === proxy.$attrs.value);

  const start = index < 0 ? 0 : index;

  if (start >= 7) {

    scrollTop.value = start * props.itemHeight;

    if (start > arrs.value.length - 8) {

      scrollTop.value = (arrs.value.length - 8)* props.itemHeight;

    }

  } else {

    scrollTop.value = 0;

  }

  contentHeight.value = arrs.value.length * props.itemHeight + 10;

  selectRef.value.$refs.scrollbar.$refs.resize.style.height = contentHeight.value + 'px';

  const wrap = selectRef.value.$refs.scrollbar.$refs.wrap;

  const scrollHeight = start + 8 >= arrs.value.length ? start * props.itemHeight - 8 * props.itemHeight : start * props.itemHeight;

  const heightPercentage = scrollHeight !== 0 ? ((scrollHeight * 100) / wrap.clientHeight) : 0;

  selectRef.value.$refs.scrollbar.$el.querySelector('.el-scrollbar__bar.is-vertical > .el-scrollbar__thumb').style.transform = `translateY(${heightPercentage}%)`;

}

5、搜索过滤时重新计算scroll设置options选项代码如下

const filterMethod = debounce((val) => {

  let regex = new RegExp(val, 'gi');

  arrs.value = [];

  scrollTop.value = 0;

  selectRef.value.$refs.scrollbar.$refs.wrap.scrollTop = 0;

  if (val.trim()) {

    arrs.value = props.options.filter(item => regex.test(item[props.optionLabel]));

  } else {

    arrs.value = props.options;

  }

  contentHeight.value = arrs.value.length * props.itemHeight + 10;

  selectRef.value.$refs.scrollbar.$refs.resize.style.height = contentHeight.value + 'px';

  updateVisibleItems();

}, 300)

6、整体代码如下

<template><el-select ref="selectRef" :filter-method="filterMethod" v-bind="$attrs" v-on="$listeners"
  @visible-change="visibleChange">
  <div class="virtual-select" :style="{ transform: `translateY(${scrollTop}px)` }">
    <el-option v-for="item in visibleItems" :key="item[optionValue]" :label="item[optionLabel]"
      :value="item[optionValue]"></el-option>
  </div>
</el-select></template>
import { ref, getCurrentInstance, onMounted, watch, nextTick, onBeforeUnmount } from '@vue/composition-api';

import { throttle, debounce } from '@/utils';

const props = defineProps({

  options: {

    type: Array,

    default: () => []

  },

  optionLabel: {

    type: String,

    default: 'label'

  },

  optionValue: {

    type: String,

    default: 'value'

  },

  itemHeight: {

    type: Number,

    default: 34

  }

});

const selectRef = ref(null);

const scrollTop = ref(0);

const contentHeight = ref(0);

const visibleItems = ref([]);

const isVisible = ref(false);

const isUpdated = ref(false);

const { proxy } = getCurrentInstance();

const arrs = ref([]);

watch(() => props.options, (val) => {

  nextTick(() => {

    arrs.value = val;

    isUpdated.value = true;

    setScrollWrapHeight();

    updateVisibleItems();

  })

}, {

  immediate: true,

})
onMounted(() => {
  nextTick(() => {
    selectRef.value.$refs.scrollbar.     $refs.wrap.addEventListener('scroll',throttle(handleScroll,100), false);
     selectRef.value.$refs.popper.$el.addEventListener('transitionstart', transitionStartFun);
    selectRef.value.$refs.popper.$el.addEventListener('transitionend', transitionEndFun);
  })

})
const handleScroll = (event) => {

  event.stopPropagation();

  event.preventDefault();

  if (event.srcElement.scrollTop === 0) {

    scrollTop.value = 0;

    updateVisibleItems();

    return;

  }

  let scrolTop = selectRef.value.$refs.scrollbar.$refs.wrap.scrollTop;

  scrollTop.value = (scrolTop === 0 && scrollTop.value ? scrollTop.value : scrolTop); // 反向应用transform,因为transform的Y是向下为正,而scrollTop是向上为正。

  const { height } = selectRef.value.$refs.scrollbar.$refs.wrap.getBoundingClientRect();

  if ((height + scrolTop) >= contentHeight.value) {

    nextTick(() => {

      scrolTop = contentHeight.value - height;

      scrollTop.value = contentHeight.value - height;

    })

    let {

      height

    } = selectRef.value.$refs.scrollbar.$refs.wrap.getBoundingClientRect();

    height = height === 0 ? 8 * props.itemHeight : height;

    visibleItems.value = arrs.value.slice(arrs.value.length - Math.ceil(height / props.itemHeight) < 0 ? 0 : arrs.value.length - Math.ceil(height / props.itemHeight) + 1, arrs.value.length);

    return;

  } else {

    if (isUpdated.value || isVisible.value) {

      isUpdated.value = false;

      isVisible.value = false;

      return;

    }

    updateVisibleItems(); // 更新可见项

  }

  if (isVisible.value) {

    isVisible.value = false;

  }

}

const updateVisibleItems = () => {

  let { height } = selectRef.value.$refs.scrollbar.$refs.wrap.getBoundingClientRect();

  height = height === 0 ? 8 * props.itemHeight : height;

  scrollTop.value <= 0 ? scrollTop.value = 0 : scrollTop.value;

  let start = (Math.abs(scrollTop.value) / props.itemHeight) * props.itemHeight > arrs.value.length * props.itemHeight - height ? Math.floor((Math.abs(scrollTop.value) - height) / props.itemHeight) : scrollTop.value === 0 || Number.isNaN(scrollTop.value) ? 0 : Math.floor(Math.abs(scrollTop.value) / props.itemHeight); // 计算开始索引

  let end = scrollTop.value === 0 || Number.isNaN(scrollTop.value) ? height / props.itemHeight : start + 10; // 计算结束索引(包含不完全显示的项)

  if (isVisible.value) {

    let star = Math.floor(Math.abs(scrollTop.value) / props.itemHeight);

    start = arrs.value.length - star <= 8 && arrs.value.length - 8 > 0 ? arrs.value.length - 8 : star - 4 <= 0 ? 0 : star - 4;

    end = start + 8 >= arrs.value.length ? arrs.value.length : start + 8;

  } else {

    start = start <= 0 ? 0 : start;

  }

  visibleItems.value = arrs.value.slice(start, end); // 获取可见项列表

  if (start <= 0) {

    const wrap = selectRef.value.$refs.scrollbar.$refs.wrap;

    const heightPercentage = 0;

    selectRef.value.$refs.scrollbar.$el.querySelector('.el-scrollbar__bar.is-vertical > .el-scrollbar__thumb').style.transform = `translateY(${heightPercentage}%)`;

  } else if (end.itemHeight >= contentHeight.value) {

    selectRef.value.$refs.scrollbar.$refs.resize.scrollTop = contentHeight.value - height;

    const wrap = selectRef.value.$refs.scrollbar.$refs.wrap;

    const scrollHeight = start + 8 >= arrs.value.length ? start * props.itemHeight - 8 * props.itemHeight : start * props.itemHeight;

    const heightPercentage = scrollHeight !== 0 ? ((scrollHeight * 100) / wrap.clientHeight) : 0;

    selectRef.value.$refs.scrollbar.$el.querySelector('.el-scrollbar__bar.is-vertical > .el-scrollbar__thumb').style.transform = `translateY(${heightPercentage}%)`;

  } else {

    const wrap = selectRef.value.$refs.scrollbar.$refs.wrap;

    const scrollHeight = start + 8 >= arrs.value.length ? start * props.itemHeight - 8 * props.itemHeight : start * props.itemHeight;

    const heightPercentage = scrollHeight !== 0 ? ((scrollHeight * 100) / wrap.clientHeight) : 0;

    selectRef.value.$refs.scrollbar.$el.querySelector('.el-scrollbar__bar.is-vertical > .el-scrollbar__thumb').style.transform = `translateY(${heightPercentage}%)`;

  }

}
const setScrollWrapHeight = () => {

  const index = (arrs.value || []).findIndex(item => item[props.optionValue] === proxy.$attrs.value);

  const start = index < 0 ? 0 : index;

  if (start >= 7) {

    scrollTop.value = start * props.itemHeight;

    if (start > arrs.value.length - 8) {

      scrollTop.value = (arrs.value.length - 8)* props.itemHeight;

    }

  } else {

    scrollTop.value = 0;

  }

  contentHeight.value = arrs.value.length * props.itemHeight + 10;

  selectRef.value.$refs.scrollbar.$refs.resize.style.height = contentHeight.value + 'px';

  const wrap = selectRef.value.$refs.scrollbar.$refs.wrap;

  const scrollHeight = start + 8 >= arrs.value.length ? start * props.itemHeight - 8 * props.itemHeight : start * props.itemHeight;

  const heightPercentage = scrollHeight !== 0 ? ((scrollHeight * 100) / wrap.clientHeight) : 0;

  selectRef.value.$refs.scrollbar.$el.querySelector('.el-scrollbar__bar.is-vertical > .el-scrollbar__thumb').style.transform = `translateY(${heightPercentage}%)`;

}

  


const filterMethod = debounce((val) => {

  let regex = new RegExp(val, 'gi');

  arrs.value = [];

  scrollTop.value = 0;

  selectRef.value.$refs.scrollbar.$refs.wrap.scrollTop = 0;

  if (val.trim()) {

    arrs.value = props.options.filter(item => regex.test(item[props.optionLabel]));

  } else {

    arrs.value = props.options;

  }

  contentHeight.value = arrs.value.length * props.itemHeight + 10;

  selectRef.value.$refs.scrollbar.$refs.resize.style.height = contentHeight.value + 'px';

  updateVisibleItems();

}, 300)

const visibleChange = (val) => {

  if (val) {

    nextTick(() => {

      arrs.value = props.options;

      isVisible.value = true;

      setScrollWrapHeight();

      updateVisibleItems();

    })

  }

}

  


function transitionStartFun() {
  selectRef.value.$refs.scrollbar.$refs.wrap.scrollTop = scrollTop.value;
}
function transitionEndFun() {
  let {
    height
  } = selectRef.value.$refs.scrollbar.$refs.wrap.getBoundingClientRect();
  if ((Math.ceil(height + scrollTop.value + 8)) >= contentHeight.value) {
    selectRef.value.$refs.scrollbar.$refs.wrap.scrollTop = contentHeight.value - height + 12;
    return;
  }
}
onBeforeUnmount(() => {

  selectRef.value.$refs.scrollbar.$refs.wrap.removeEventListener('scroll', handleScroll);

  selectRef.value.$refs.popper.$el.removeEventListener('transitionstart', transitionStatFun);
  selectRef.value.$refs.popper.$el.removeEventListener('transitionend', transitionEndFun);

})
```