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);
})
```