不知各位使用 elementPlus 组件库中的 el-select 时有没有发现,每添加一个 el-select 组件,就意味着页面中同时会生成一个 el-popover 的下拉框组件,这样就会导致某些表单页面或者列表嵌套表单的页面大量的使用 el-select 组件时,页面中会生成大量的 el-popover 组件,导致页面卡顿,性能下降,那么有没有什么办法可以解决这个问题呢?
既然如此那就手动造一个全局下拉框组件,同时满足 单选,多选,树形下拉框,添加新item,搜索等功能,下面就开始我们的造轮子之旅吧!
ps: 此组件是基于
elementPlus组件开发的,且项目中用到了tailwindcss和scss,所以需要先安装这几个依赖。
目录结构如下,总共三个文件,两个组件和一个页面,其中 popover.vue 是弹出框组件,在一个页面中只需引入一个
├── demo
│ ├── components
│ │ ├── popover.vue
│ │ └── select.vue
│ └── index.vue
popover.vue负责弹出框组件
<template>
<el-tooltip ref="tooltipRef" :visible="visible" effect="light" :placement="initPlacement" :virtual-ref="virtualRef" virtual-triggering transition="el-zoom-in-top" :gpu-acceleration="false" :show-arrow="config.showArrow" :popper-options="{ modifiers: [{ name: 'computeStyles', options: { adaptive: false, enabled: false } }] }" popper-class="!p-0" >
<template #content>
<div @click.prevent.stop class="box-border py-2" :style="popoverWidth">
<div v-if="!options?.length" class="text-colors-3 text-center">
No Data
</div>
<el-scrollbar v-else max-height="336px">
<ul v-if="!config.isTree">
<li v-for="(item, index) in options" :key="`listItem-${index}`" @click="handleSelectPopoverItem($event, item)" class="flex justify-between items-center py-[5px] px-5 hover:bg-[#F5F6F7] cursor-pointer" :class="isChooseItem(item) && ['text-colors-primary', 'font-bold']">
<span class="whitespace-nowrap overflow-hidden text-ellipsis">{{ item[config.dataProps.label] }}</span>
<el-icon v-if="config.multiple && isChooseItem(item)" :size="12" class="flex-none"><Check /></el-icon>
</li>
</ul>
<div v-else>
<el-tree-v2 ref="treeRef" :data="options" :highlight-current="false" :props="config.dataProps" :height="200" :item-size="28" :expand-on-click-node="false" :filter-method="filterMethod" @node-click="handleNodeClick" class="px-4">
<template #default="{ node }">
<div class="w-full flex items-center justify-between pr-1 overflow-hidden">
<p class="w-full overflow-hidden text-ellipsis whitespace-nowrap" :class="[ chooseData[config.dataProps.value] == node.data[config.dataProps.value] && '!text-colors-primary']">{{ node.data[config.dataProps.label] }}</p>
</div>
</template>
<template #empty>
<div class="h-[200px] leading-[200px] text-colors-3">No Data</div>
</template>
</el-tree-v2>
</div>
</el-scrollbar>
</div>
</template>
</el-tooltip>
</template>
<script setup>
import { Check } from '@element-plus/icons-vue';
const props = defineProps({
virtualRef: { type: Object, default: () => ({}) }, // 虚拟触发元素【如果是ref,则传递 ref.value.$el】
visible: { type: Boolean, default: false }, // 是否显示
})
const emit = defineEmits(['resetSelectPopoverConfig']);
onMounted(() => {
document.addEventListener('click', handleResetSelectPopoverConfig);
})
const treeRef = ref(null);
const chooseData = ref(''); // 选择的数据
const config = ref({}); // 配置
// 非请求来的数据在弹窗显示时即可初始化数据
watch(() => props.visible, visible => visible && handleInitData());
// 计算popover宽度
const popoverWidth = computed(() => {
return (config.value.fitInputWidth && props.virtualRef.offsetWidth) ? `width: ${props.virtualRef.offsetWidth}px;` : `width: ${config.value.popoverWidth || 400}px;`;
})
const tooltipRef = ref(null);
// 初始化数据
const initPlacement = ref('bottom');
const options = ref([]);
const handleInitData = () => {
if (!props.visible) {
config.value = {};
return;
} else {
config.value = props.virtualRef.popoverConfig;
nextTick(() => initPlacement.value = config.value.initPlacement || tooltipRef.value.popperRef.popperInstanceRef.state.placement);
options.value = config.value.options ? JSON.parse(JSON.stringify(config.value.options)) : [];
if (config.value.data) {
// 赋值选中的数据
chooseData.value = JSON.parse(JSON.stringify(config.value.data));
// 树形结构的初始化展开
if (config.value.isTree && config.value.data[config.value.dataProps.value]) {
setTimeout(() => handleTreeExpand(config.value.data[config.value.dataProps.value]), 0);
}
} else {
// 重置选中的数据
chooseData.value = config.value.multiple ? [] : '';
}
}
}
// 树形结构展开
const handleTreeExpand = (key) => {
const currentNode = treeRef.value && treeRef.value.getNode(key);
if (!currentNode) return;
const parantNode = currentNode.parent && treeRef.value.getNode(currentNode.parent.data[config.value.dataProps.value]);
parantNode && treeRef.value.expandNode(parantNode, true);
parantNode?.parent && handleTreeExpand(parantNode[config.value.dataProps.value]);
}
// 是否选中
const isChooseItem = computed(() => {
return item => {
if (config.value.multiple) {
return chooseData.value.find(i => i[config.value.dataProps.value] === item[config.value.dataProps.value])
} else {
return chooseData.value[config.value.dataProps.value] === item[config.value.dataProps.value];
}
};
})
// 更新tooltip的显示,防止tooltip错位
const handleUpdatePopover = () => {
setTimeout(() => tooltipRef.value.updatePopper(), 1);
}
// 选中项
const handleSelectPopoverItem = (e, item) => {
if (config.value.multiple) {
const index = chooseData.value.findIndex(i => i[config.value.dataProps.value] === item[config.value.dataProps.value]);
index > -1 ? chooseData.value.splice(index, 1) : chooseData.value.push(item);
config.value.onSelect(JSON.parse(JSON.stringify(chooseData.value)));
handleUpdatePopover();
} else {
handleResetSelectPopoverConfig();
if (item && chooseData.value[config.value.dataProps.value] === item[config.value.dataProps.value]) return;
chooseData.value = item;
config.value.onSelect(JSON.parse(JSON.stringify(item)));
}
}
// 点击node节点
const handleNodeClick = (data, node, e) => {
handleSelectPopoverItem(e, data);
}
// 过滤指定节点
const filterMethod = (query, node) => {
// 当query为空时且非子节点时,折叠所有节点
if (query === '' && node.children) {
const trueNode = treeRef.value.getNode(node[config.value.dataProps.value]);
trueNode && nextTick(() => treeRef.value.collapseNode(trueNode));
}
return node[config.value.dataProps.label].includes(query);
}
// 搜索
const handleSearch = (query) => {
if (config.value.isTree) {
treeRef.value && treeRef.value.filter(query);
return;
}
if (query) {
const regex = new RegExp(query, 'gi');
options.value = config.value.options.filter(item => regex.test(item[config.value.dataProps.label]));
} else {
options.value = JSON.parse(JSON.stringify(config.value.options));
}
handleUpdatePopover();
}
// 重置配置
function handleResetSelectPopoverConfig() {
emit('resetSelectPopoverConfig');
}
// 单独更新选中的值
const handleUpdateChooseData = (val) => {
chooseData.value = JSON.parse(JSON.stringify(val));
}
onBeforeUnmount(() => {
document.removeEventListener('click', handleResetSelectPopoverConfig);
})
defineExpose({
handleInitData,
handleUpdateChooseData,
handleSearch,
handleUpdatePopover,
getVisibleStatus: () => props.visible
})
</script>
<style lang="scss" scoped>
:deep(.el-virtual-scrollbar) {
right: -6px!important;
}
:deep(.el-tree) {
.el-tree-node__content {
gap: 4px;
&>.el-tree-node__expand-icon {
padding: 2px;
}
}
.el-tree--highlight-current .el-tree-node.is-current>.el-tree-node__content {
background-color: transparent;
}
}
</style>
select.vue负责下拉选择组件
<template>
<div ref="selectRef" @click.prevent.stop="handleTrigger" class="el-select">
<div @mouseenter="handleHover($event, true)" @mouseleave="handleHover($event, false)" class="el-select__wrapper" :class="[currentPopoverTarget === selectRef && 'is-focused', isHovering && 'is-hovering', disabled && 'is-disabled']">
<div class="el-select__selection">
<div class="el-select__selected-item el-select__input-wrapper w-full flex gap-1">
<template v-if="filterable">
<input ref="filterInputRef" v-model="filterInputValue" :disabled="disabled" type="text" autocomplete="off" spellcheck="false" @input="handleFilterInput" @blur="handleFilterInputBlur" class="el-select__input flex-none" style="width: 11px;">
<span ref="filterInputSpanRef" aria-hidden="true" class="el-select__input-calculator">{{ filterInputValue }}</span>
</template>
<template v-if="multiple">
<el-tag v-for="(tag, tagIndex) in data" :key="`tag-item-${tagIndex}`" disable-transitions :closable="!disabled" type="info" @close="handleCloseTag(tagIndex)" >{{ tag?.[dataProps?.label] }}</el-tag>
</template>
<span v-else-if="!filterInputValue" class="w-fit el-select__selected-item el-select__placeholder text-colors-1 break-all text-ellipsis line-clamp-1" :class="[disabled && 'text-colors-3', (!data?.[dataProps?.label] || filterInputIsFocus) && 'is-transparent']">{{ data?.[dataProps?.label] || placeholder }}</span>
<template v-if="addItems && multiple && !filterable">
<input ref="addItemsInputRef" v-model="addItemsInputValue" :disabled="disabled" type="text" autocomplete="off" spellcheck="false" @input="handleAddItemsInput" @blur="handleAddItemsInputBlur" class="el-select__input flex-none" style="width: 11px;">
<span ref="addItemsInputSpanRef" aria-hidden="true" class="el-select__input-calculator">{{ addItemsInputValue }}</span>
</template>
</div>
</div>
<div class="el-select__suffix">
<i v-if="clearable && isHovering && ((!multiple && data?.[dataProps?.label]) || (multiple && data.length))" @click.prevent.stop="handleClear" class="el-icon el-select__caret el-input__icon el-select__clear">
<el-icon><CircleClose /></el-icon>
</i>
<i v-else class="el-icon el-select__caret el-input__icon" :class="currentPopoverTarget === selectRef && 'is-reverse'">
<el-icon><ArrowDown /></el-icon>
</i>
</div>
</div>
</div>
</template>
<script setup>
import { CircleClose, ArrowDown } from '@element-plus/icons-vue';
const props = defineProps({
modelValue: '', // 双向绑定的值
selectPopoverRef: null, // select的popover [多选时需传递]
options: null, // 数据
disabled: { type: Boolean, default: false }, // 是否禁用
currentPopoverTarget: null, // 当前激活popover的target
dataProps: { type: Object, default: () => ({ value: 'value', label: 'label', children: 'children' }) }, // 结构配置
placeholder: { type: String, default: 'pleaseSelect' },
fitInputWidth: { type: Boolean, default: true }, // 是否适应输入框宽度
popoverWidth: { type: Number, default: 400 }, // popover宽度
filterable: { type: Boolean, default: false }, // 是否可搜索
clearable: { type: Boolean, default: false }, // 是否显示清除按钮
multiple: { type: Boolean, default: false }, // 是否多选
showArrow: { type: Boolean, default: true }, // 是否显示箭头
addItems: { type: Boolean, default: false }, // 是否可以添加内容 [与filterable不可同时使用,优先filterable]
initPlacement: { type: String, default: '' }, // 初始位置
isTree: { type: Boolean, default: false }, // 是否为树形下拉框
});
const emit = defineEmits(['update:modelValue', 'click', 'change']);
watch([() => props.modelValue, () => props.options], () => {
handleInitData();
});
onMounted(() => {
// 绑定下拉框配置项
handleInitConfig();
})
// 初始化配置
const selectRef = ref(null);
const handleInitConfig = () => {
nextTick(() => {
selectRef.value.popoverConfig = {};
const keyList = ['isTree', 'dataProps', 'options', 'fitInputWidth', 'filterable', 'showArrow', 'multiple', 'popoverWidth', 'initPlacement'];
keyList.forEach(key => selectRef.value.popoverConfig[key] = props[key]);
selectRef.value.popoverConfig['onSelect'] = handleSelect;
handleInitData();
})
}
// 初始化选中数据
const data = ref(null);
const handleInitData = () => {
if (!props.modelValue || (props.multiple && !props.modelValue?.length) || !props.options || !props.options.length) {
if (props.multiple) {
data.value = [];
props.modelValue?.forEach(item => {
const obj = {};
obj[props.dataProps.label] = item;
data.value.push(obj);
})
} else {
data.value = {};
data.value[props.dataProps.label] = props.modelValue;
}
return;
}
if (props.isTree) {
let findItem = handleFindTreeItem(props.options, props.modelValue);
if (findItem) {
data.value = findItem;
} else {
data.value = {};
data.value[props.dataProps.label] = props.modelValue;
}
} else {
if (props.multiple) {
const labelArr = [];
props.modelValue.forEach(item => {
const findItem = props.options.find(option => option[props.dataProps.value] === item);
findItem ? labelArr.push(findItem) : labelArr.push({ [props.dataProps.value]: item, [props.dataProps.label]: item });
})
data.value = labelArr.length ? labelArr : props.modelValue;
} else {
const findItem = props.options.find(item => item[props.dataProps.value] === props.modelValue);
if (findItem) {
data.value = findItem;
} else {
data.value = {};
data.value[props.dataProps.label] = props.modelValue;
}
}
}
selectRef.value.popoverConfig.data = data.value;
selectRef.value.popoverConfig.options = props.options;
}
// 树形结构查找选中项
const handleFindTreeItem = (treeData, value) => {
for (let item of treeData) {
if (item[props.dataProps.value] === value) return item;
if (item.children && item.children.length > 0) {
let result = handleFindTreeItem(item.children, value);
if (result) return result;
}
}
return null;
}
// 下拉框选中时触发
const handleSelect = (config) => {
data.value = config;
selectRef.value.popoverConfig.data = data;
if (props.multiple) {
emit('update:modelValue', config.map(i => i[props.dataProps.value]));
emit('change', config);
} else {
emit('update:modelValue', config[props.dataProps.value]);
emit('change', config);
}
}
// 多选框中删除其中某项
const handleCloseTag = (index) => {
data.value.splice(index, 1);
selectRef.value.popoverConfig.data = data.value;
if (props.selectPopoverRef) {
props.selectPopoverRef.handleInitData();
props.selectPopoverRef.handleUpdatePopover();
}
emit('update:modelValue', data.value.map(i => i[props.dataProps.value]));
}
// 点击下拉框时传递ref
const handleTrigger = () => {
if (props.disabled) return;
if (filterInputRef.value) {
filterInputRef.value.focus();
filterInputIsFocus.value = true;
}
addItemsInputRef.value && addItemsInputRef.value.focus();
selectRef.value.popoverConfig.data = data.value;
selectRef.value.popoverConfig.options = props.options;
emit('click', selectRef.value);
}
// filter输入框
const filterInputRef = ref(null);
const filterInputSpanRef = ref(null);
const filterInputValue = ref('');
const filterInputIsFocus = ref(false);
let filterInputTimer = null;
const handleFilterInput = () => {
const maxWidth = selectRef.value.offsetWidth - 24 - 20;
nextTick(() => filterInputRef.value.style.width = filterInputSpanRef.value.offsetWidth > maxWidth ? `${maxWidth}px` : `${filterInputSpanRef.value.offsetWidth || 1}px`);
if (props.selectPopoverRef) {
// 如果当前是关闭状态则手动触发开启下拉框
!props.selectPopoverRef.getVisibleStatus() && emit('click', selectRef.value);
filterInputTimer && clearTimeout(filterInputTimer);
filterInputTimer = setTimeout(() => props.selectPopoverRef.handleSearch(filterInputValue.value), 0);
}
}
const handleFilterInputBlur = () => {
filterInputIsFocus.value = false;
filterInputValue.value = '';
props.selectPopoverRef && props.selectPopoverRef.handleUpdatePopover();
}
// addItems输入框
const addItemsInputRef = ref(null);
const addItemsInputSpanRef = ref(null);
const addItemsInputValue = ref('');
const handleAddItemsInput = () => {
const maxWidth = selectRef.value.offsetWidth - 24 - 20;
nextTick(() => addItemsInputRef.value.style.width = addItemsInputSpanRef.value.offsetWidth > maxWidth ? `${maxWidth}px` : `${addItemsInputSpanRef.value.offsetWidth || 1}px`);
}
const handleAddItemsInputBlur = () => {
if (!addItemsInputValue.value) return;
// 判断是否为options中的一项
const hasItem = props.options.find(i => i[props.dataProps.label] === addItemsInputValue.value);
// 获取原本选中的项
let oldValue = data.value.map(i => i[props.dataProps.value]);
if (hasItem) {
// 替换掉输入值为options的key
addItemsInputValue.value = hasItem[props.dataProps.value];
// 去除inputValue中含有的
oldValue = oldValue.filter(i => i !== hasItem[props.dataProps.value]);
}
emit('update:modelValue', [...oldValue, addItemsInputValue.value]);
// 更新下拉框的选中项
nextTick(() => props.selectPopoverRef && props.selectPopoverRef.handleUpdateChooseData(data.value));
addItemsInputValue.value = '';
addItemsInputRef.value.style.width = '1px';
}
// 清空
const handleClear = () => {
data.value = props.multiple ? [] : '';
selectRef.value.popoverConfig.data = data.value;
props.selectPopoverRef && props.selectPopoverRef.handleInitData()
emit('update:modelValue', data.value);
emit('click');
}
// 移入移出
const isHovering = ref(false);
const handleHover = (e, enterFlag) => {
isHovering.value = enterFlag;
}
</script>
<style lang="scss" scoped>
:deep(.el-tag__content) {
max-width: 100px!important;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
index.vue实例页面
- 最基础的单选下拉框,包含搜索、清除等功能
<template>
<div style="padding: 100px;background-color: #fff;">
<SelectContainer v-model="value" :options="options" clearable :currentPopoverTarget="selectPopoverConfig.virtualRef" :selectPopoverRef="selectPopoverRef" @click="handleShowSelectPopover" style="width: 250px" />
<SelectPopover ref="selectPopoverRef" :virtualRef="selectPopoverConfig.virtualRef" :visible="selectPopoverConfig.visible" @resetSelectPopoverConfig="handleResetSelectPopoverConfig" />
</div>
</template>
<script setup>
import SelectContainer from './components/select.vue';
import SelectPopover from './components/popover.vue';
const value = ref('');
const options = ref([
{
value: '1',
label: 'Option 1',
},
{
value: '2',
label: 'Option 2',
},
{
value: '3',
label: 'Option 3'
}
]);
// 下拉框
const selectPopoverRef = ref(null);
const selectPopoverConfig = ref({
virtualRef: {},
visible: false
});
// 打开下拉框
const handleShowSelectPopover = (target) => {
if (!target) return;
if (selectPopoverConfig.value.virtualRef !== target) {
handleResetSelectPopoverConfig(); // 先将之前的虚拟节点重置
// 延迟设置新的虚拟节点,出现新的下拉动画
setTimeout(() => {
selectPopoverConfig.value.virtualRef = target;
selectPopoverConfig.value.visible = true;
}, 0);
return;
}
selectPopoverConfig.value.visible = !selectPopoverConfig.value.visible;
!selectPopoverConfig.value.visible && (selectPopoverConfig.value.virtualRef = {});
}
// 重置下拉框
const handleResetSelectPopoverConfig = () => {
selectPopoverConfig.value.virtualRef = {};
selectPopoverConfig.value.visible = false;
}
</script>
- 多选下拉框,包含添加items、清除等功能,注意添加items和搜索功能不能同时使用
<template>
<div style="padding: 100px;background-color: #fff;">
<SelectContainer v-model="value" :options="options" clearable multiple addItems :currentPopoverTarget="selectPopoverConfig.virtualRef" :selectPopoverRef="selectPopoverRef" @click="handleShowSelectPopover" style="width: 250px" />
<SelectPopover ref="selectPopoverRef" :virtualRef="selectPopoverConfig.virtualRef" :visible="selectPopoverConfig.visible" @resetSelectPopoverConfig="handleResetSelectPopoverConfig" />
</div>
</template>
<script setup>
import SelectContainer from './components/select.vue';
import SelectPopover from './components/popover.vue';
const value = ref('');
const options = ref([
{
value: '1',
label: 'Option 1',
},
{
value: '2',
label: 'Option 2',
},
{
value: '3',
label: 'Option 3',
},
]);
// 下拉框
const selectPopoverRef = ref(null);
const selectPopoverConfig = ref({
virtualRef: {},
visible: false
});
// 打开下拉框
const handleShowSelectPopover = (target) => {
if (!target) return;
if (selectPopoverConfig.value.virtualRef !== target) {
handleResetSelectPopoverConfig(); // 先将之前的虚拟节点重置
// 延迟设置新的虚拟节点,出现新的下拉动画
setTimeout(() => {
selectPopoverConfig.value.virtualRef = target;
selectPopoverConfig.value.visible = true;
}, 0);
return;
}
selectPopoverConfig.value.visible = !selectPopoverConfig.value.visible;
!selectPopoverConfig.value.visible && (selectPopoverConfig.value.virtualRef = {});
}
// 重置下拉框
const handleResetSelectPopoverConfig = () => {
selectPopoverConfig.value.virtualRef = {};
selectPopoverConfig.value.visible = false;
}
</script>
- 树形下拉框,包含搜索、清除等功能
<template>
<div style="padding: 100px;background-color: #fff;">
<SelectContainer v-model="value2" :options="options2" clearable filterable isTree :currentPopoverTarget="selectPopoverConfig.virtualRef" :selectPopoverRef="selectPopoverRef" @click="handleShowSelectPopover" style="width: 250px" />
<SelectPopover ref="selectPopoverRef" :virtualRef="selectPopoverConfig.virtualRef" :visible="selectPopoverConfig.visible" @resetSelectPopoverConfig="handleResetSelectPopoverConfig" />
</div>
</template>
<script setup>
import SelectContainer from './components/select.vue';
import SelectPopover from './components/popover.vue';
const value2 = ref('');
const options2 = ref([
{
value: '1',
label: 'Option 1',
children: [
{
value: '1-1',
label: 'Option 1-1',
},
{
value: '1-2',
label: 'Option 1-2',
},
]
},
{
value: '2',
label: 'Option 2',
children: [
{
value: '2-1',
label: 'Option 2-1',
},
{
value: '2-2',
label: 'Option 2-2',
}
]
},
{
value: '3',
label: 'Option 3',
},
]);
// 下拉框
const selectPopoverRef = ref(null);
const selectPopoverConfig = ref({
virtualRef: {},
visible: false
});
// 打开下拉框
const handleShowSelectPopover = (target) => {
if (!target) return;
if (selectPopoverConfig.value.virtualRef !== target) {
handleResetSelectPopoverConfig(); // 先将之前的虚拟节点重置
// 延迟设置新的虚拟节点,出现新的下拉动画
setTimeout(() => {
selectPopoverConfig.value.virtualRef = target;
selectPopoverConfig.value.visible = true;
}, 0);
return;
}
selectPopoverConfig.value.visible = !selectPopoverConfig.value.visible;
!selectPopoverConfig.value.visible && (selectPopoverConfig.value.virtualRef = {});
}
// 重置下拉框
const handleResetSelectPopoverConfig = () => {
selectPopoverConfig.value.virtualRef = {};
selectPopoverConfig.value.visible = false;
}
</script>