需求简介
vue2+element-ui项目中,当el-select中数据量较大时,会导致页面加载和渲染卡顿。在现在的el-select的基础上使用分页或者虚拟列表的形式去处理大量的下拉菜单,保证页面的正常渲染及el-select的正常回显。
需求分析
主要涉及几个点:
- 下拉菜单主体实现虚拟展示,保证渲染效率
- 展开和关闭时要保证已选中的选项在虚拟列表内,保证回显的是 label,而不是 value
- select 清空时,虚拟列表回归到顶部
- 下拉菜单发生改变时,重新计算滚动条长度,并回归到顶部
- 多选场景暂不支持,因为无论如何都不可避免更多的js计算逻辑,两个数组去比较在极限场景下是无法避免的,所以我就砍掉了。
- 本地搜索时更新组件传入的下拉菜单。
完整代码
使用:
<template>
<div style="width: 100%;display: flex;align-items: center">
<el-select
v-model="deviceValue"
filterable
clearable
:filter-method="filterOption"
@visible-change="changeVisible($event)"
>
<virtual-options
ref="virtualRef"
:virtual-data="secList"
:select-value="deviceValue"
/>
</el-select>
</div>
</template>
<script>
import { getXXX } from '@/api/xxx'
import VirtualOptions from './VirtualOptions.vue'
export default {
components: { VirtualOptions },
props: {
defaultValue: {
type: String,
default: ''
},
deviceType: {
type: String,
default: ''
}
},
data() {
return {
deviceValue: '',
secList: [],
cloneSelList: [],
queryType: 0,
selectedObj: {}
}
},
watch: {
// 监听父组件传入的数据,更新到本地
defaultValue(newVal) {
this.deviceValue = newVal
},
// 监听本地数据的变化,通知父组件更新
deviceValue(newVal) {
for (const i of this.secList) {
if (newVal === i.deviceId) {
this.selectedObj = i
break
}
}
this.emitChange()
},
queryType() {
this.emitChange()
}
},
created() {
this.getDeviceList()
},
methods: {
emitChange() {
const queryParamData = {};
if (this.queryType === 0) {
queryParamData['deviceId'] = this.selectedObj['deviceId'];
} else if (this.queryType === 1) {
queryParamData['sectionId'] = this.selectedObj['sectionId'];
queryParamData['sectionName'] = this.selectedObj['sectionName'];
}
this.$emit('change', queryParamData)
},
filterOption(val) {
if (val) {
val = val.toUpperCase();
this.secList = this.cloneSelList.filter((item) => {
if (item.label.toUpperCase().indexOf(val) >= 0) {
return true
}
})
} else {
this.secList = this.cloneSelList
}
},
changeVisible(cb) {
this.secList = this.cloneSelList
this.$nextTick(() => cb && this.$refs["virtualRef"].resetVirtual()); // 解决打开白屏问题,必须使用 $nextTick 延时处理
},
getDeviceList() {
this.deviceValue = this.defaultValue
getXXX ({ deviceType: this.deviceType }).then(response => {
const data = response.data
this.cloneSelList = this.secList = data
})
}
}
}
</script>
VirtualOptions组件
<template>
<div class="option-wrap" :id="randomId">
<!-- 真实dom -->
<div class="virtual-dom">
<!-- 使用虚拟列表渲染el-option组件 -->
<el-option v-for="item in virtualOptions" :key="item.deviceId" :label="item.label" :value="item.deviceId" />
<!-- 增加一个空的,解决第一次展示为空的问题 -->
<el-option v-if="virtualData && virtualData.length" disabled style="display: none" :value="''" :label="''" />
</div>
</div>
</template>
<script>
import { getParents } from '@/utils/utils'
export default {
props: {
virtualData: {
type: Array,
default: () => []
},
selectValue: {
type: [String, Number],
default: ''
},
isDevice: {
type: Boolean,
default: true
}
},
data() {
return {
randomId: '', // 随机生成的ID值
virtualOptions: [], // 虚拟列表的选项数据
leafNumber: 0, // 选项的叶子节点数量
optionHeight: 34, // 选项的高度
};
},
watch: {
// 监听selectValue的变化,当其发生变化时调用resetVirtual方法
selectValue() {
this.resetVirtual();
},
// 监听virtualData的变化
virtualData() {
this.resetVirtual()
this.initWrapHeight()
}
},
mounted() {
// 初始化随机ID值,并在下一次DOM更新时调用initVirtual方法
this.initId()
this.$nextTick(() => this.initVirtual())
},
methods: {
// 初始化虚拟列表容器的高度
initWrapHeight() {
const $wrap = document.getElementById(this.randomId);
$wrap.style.height = this.virtualData.length * this.optionHeight + 100 + 'px'; // 设定虚拟列表的总高度
this.initVirtual()
},
/**
* 重置虚拟列表,使用场景有两个
* 1- select选中时,保证选中数据正常为label值
* 2- 下拉框展开保证下拉菜单回显正常
*/
resetVirtual() {
const $wrap = document.getElementById(this.randomId);
const $virtualDom = $wrap.querySelector('.virtual-dom');
const $scroll = getParents($wrap, 'el-select-dropdown__wrap');
const _scrollHeight = $scroll.offsetHeight
let vIndex = 0;
if (this.selectValue !== '') {
// 查找选中值在虚拟数据中的索引
vIndex = this.virtualData.findIndex((item) => item.deviceId === this.selectValue);
}
// 计算可视区域内需要显示的选项数量
const showNumber = parseInt(_scrollHeight / this.optionHeight) + this.leafNumber;
// 更新虚拟列表的选项数据
this.virtualOptions = this.virtualData.slice(vIndex, vIndex + showNumber);
this.$nextTick(() => {
// 更新虚拟列表的位置和滚动条的位置,实现选项的正确显示
$virtualDom.style.transform = `translate(0, ${vIndex * this.optionHeight}px)`;
$scroll.scrollTop = vIndex * this.optionHeight;
});
},
// 初始化虚拟列表
initVirtual() {
const $wrap = document.getElementById(this.randomId);
const $virtualDom = $wrap.querySelector('.virtual-dom');
$wrap.style.height = this.virtualData.length * this.optionHeight + 100 + 'px'; // 设定虚拟列表的总高度
const $scroll = getParents($wrap, 'el-select-dropdown__wrap');
const scrollFunc = () => {
const _scrollTop = $scroll.scrollTop;
const showNumber = 8
$virtualDom.style.transform = `translate(0, ${_scrollTop}px)`;
// 保证选中项超出可视范围后导致回显选择项异常
let vIndex = parseInt(_scrollTop / this.optionHeight);
// 保证最后一屏显示正常 1 为 leaf
if (vIndex > this.virtualData.length - showNumber) {
vIndex = this.virtualData.length - showNumber + 1;
}
if (
this.selectValue !== '' &&
this.virtualOptions.findIndex((item) => item.value === this.selectValue) > -1
) {
this.virtualOptions = [
...this.virtualData.slice(vIndex, vIndex + showNumber),
Object.assign(
{},
this.virtualData.find((item) => item.deviceId === this.selectValue),
{ hide: true },
),
];
} else {
if (this.virtualData.length < 8) {
this.virtualOptions = this.virtualData
} else {
this.virtualOptions = this.virtualData.slice(vIndex, vIndex + showNumber);
}
}
}
// 重置virtualData时,清理滚动方法并初始化滚动容器高度
this.$nextTick(() => $scroll.addEventListener('scroll', scrollFunc));
this.$nextTick(scrollFunc())
},
// 生成一个随机的id值
initId() {
this.randomId = 'virtual_' + parseInt(Math.random() * 10 * 1024 * 1024 + '');
}
}
};
</script>
utils.js
/**
* 根据class名称递归查询父节点
* @param {HTMLElement} element 当前节点element
* @param {string} className 需要查询的class值
*/
export function getParents(element, className) {
var returnParentElement = null
function getpNode(element, className) {
// 创建父级节点的类数组
const pClassList = element.parentNode.getAttribute('class').split(' ')
const pNode = element.parentNode
if (!pClassList || !pClassList.length) {
// 如果未找到类名数组,表示父类无类名,则再次递归
getpNode(pNode, className)
} else if (pClassList && !pClassList.includes(className)) {
// 如果父类的类名中没有预期类名,则再次递归
getpNode(pNode, className)
} else if (pClassList && pClassList.includes(className)) {
returnParentElement = pNode
}
}
getpNode(element, className)
return returnParentElement
}