引言:放置表单里的虚拟树!
一个结合 el-input ,el-tree-v2,el-popover以及el-tooltip 的 Vue3 组件,支持大数据量加载、虚拟滚动、智能过滤和交互优化!主要是放置在表单里,实现表单的大数据量加载,滚动不卡顿以及搜索不卡顿的功能!
一、代码实现,输入框,下拉框和虚拟树的结合🛠️
一开始作者想直接将虚拟树和输入框结合,但发现下拉框的宽度不好控制,想根据渲染的树节点最长的长度进行控制,但发现如果用户搜索后得到的数据量过大,会导致下拉框渲染特别慢,且很卡,失去了虚拟滚动渲染指定数量节点的功效。 解决方法:故想到让下拉框固定其宽度并且将超过宽度文本使用省略号显示,超出长度的直接使用el-tooltip 显示全部长度。
二、交互优化 🎯
- 点击输入框展开树,点击外部自动关闭。
- 清除按钮动态显示(hover 时出现)。
- 树节点文本溢出时自动显示
Tooltip。
三、虚拟滚动树 🚀
- 基于
el-tree-v2,支持 万级数据流畅渲染(告别卡顿)。 - 可配置
height和maxHeight,适应不同场景。
四、话不多说,上代码
ini
<template>
<div class="el-select combo-tree-v2" style="width: 10o%">
<el-popover
ref="selectPopover"
popper-class="combo-tree-v2"
placement="bottom-start"
:popper-style="{ 'max-height': maxHeight + 'px', height: height + 'px' }"
:visible="data.visible"
:show-arrow="false"
:width="width"
>
<div ref="treeContainer" class="tree-container">
<el-tree-v2
ref="selectTreeX"
:data="treeData"
:props="treeProps"
:filter-method="filterMethod"
:height="height - 26"
@node-click="nodeClickFn"
>
<template #default="{ node }">
<el-tooltip
effect="dark"
:content="node.label"
placement="top"
:show-after="300"
:disabled="!overflowKey[node.key]"
>
<div
class="combo-tree-v2__label"
@mouseenter="checkOverflow(node)"
>
{{ node.label }}
</div>
</el-tooltip>
</template>
</el-tree-v2>
</div>
<template #reference>
<el-input ref="input" v-model="data.selectedLabel" :placeholder="placeholder" @input="handleInput"
@click.stop="handleFocus" @mouseenter="data.inputHovering = true" @mouseleave="data.inputHovering = false">
<template #suffix>
<el-icon class="el-input__icon">
<component :is="iconClass" @click="handleIconClick"/>
</el-icon>
</template>
</el-input>
</template>
</el-popover>
</div>
</template>
<script setup>
import { ref, onMounted, reactive, computed, nextTick, watch } from 'vue';
import { CircleClose, CaretTop } from "@element-plus/icons-vue";
import { removeClass, addClass, hasClass } from '@/utils/utils';
defineOptions({
name: 'ComboTreeV2'
});
const props = defineProps({
// 树的初始数据
localData: {
type: Array,
default: () => []
},
// 传入默认的props,看后端返回数据,可自定义!
treeProps: {
type: Object,
default: () => {
return {
value: 'value',
label: 'label',
children: 'children'
}
}
},
// 是否可清除
clearable: {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
},
height: {
type: Number,
default: 500
},
// 下拉框中树的最大高度
maxHeight: {
type: Number,
default: 500
},
placeholder: {
type: String,
default: ''
},
width: {
type: Number,
default: 300
}
});
// 弹出框实例
const selectPopover = ref();
// 输入框实例
const input = ref();
const selectTreeX = ref();
const treeContainer = ref();
// 树的全量数据
const treeData = ref([]);
// 存储树节点被隐藏的key
const overflowKey = ref({});
const data = reactive({
inputHovering: true,
selectedLabel: '',
visible: false
});
const iconClass = computed(() => {
const criteria = props.clearable &&
data.inputHovering && data.selectedLabel !== undefined && data.selectedLabel !== '';
return criteria ? CircleClose : CaretTop;
});
const $emit = defineEmits(['node-click', 'clear']);
onMounted(() => {
// document.body.addEventListener('click', handleHide);
document.addEventListener('click', handleHide);
nextTick(() => {
treeData.value = props.localData;
});
});
watch(() => data.visible, (val) => {
if (!val) {
handleIconHide();
} else {
handleIconShow();
}
});
const handleIconHide = () => {
const icon = document.querySelector('.el-input__icon');
if (icon) {
removeClass(icon, 'is-reverse');
}
}
const handleIconShow = () => {
const icon = document.querySelector('.el-input__icon');
if (icon && !hasClass(icon, 'el-icon-circle-close')) {
addClass(icon, 'is-reverse');
}
}
/**
* 过滤虚拟树数据
*/
const handleInput = (val) => {
selectTreeX.value && selectTreeX.value.filter(val);
}
/**
* 虚拟树过滤时设置的过滤函数
*/
const filterMethod = (query, node) => {
return node[props.treeProps.label].includes(query);
}
const handleHide = (e) => {
const noIsPopper = e.target && selectPopover.value && e.target.offsetParent !== selectPopover.value.popperRef;
const noIsInput = e.target && input.value && e.target.offsetParent !== input.value.$el;
const noIsIcon = e.target && e.target.offsetParent !== undefined;
const noIsBody = e.target.nodeName !== 'BODY';
if (!e.target || (noIsPopper && noIsInput && noIsIcon && noIsBody)) {
data.visible = false;
}
return false;
}
const handleFocus = () => {
data.visible = true;
}
/**
* 点击输入框内Icon
*/
const handleIconClick = (Event) => {
if (iconClass.value.name.includes('CircleClose')) {
clear();
} else {
toggleMenu();
}
}
/**
* 清空输入框内容
*/
const clear = () => {
selectTreeX.value.filter('');
data.visible = false;
data.selectedLabel = '';
$emit('clear');
}
const toggleMenu = () => {
if (!props.disabled) {
data.visible = !data.visible;
}
}
/**
* 点击树节点
*/
const nodeClickFn = (nodeData, node, self) => {
if (node.isLeaf) {
data.selectedLabel = nodeData[props.treeProps.label];
data.visible = false;
}
$emit('node-click', nodeData, node, self);
}
/**
* 自定义输入框的值
*/
const setInputValue = (val) => {
data.selectedLabel = val;
}
/**
* 判断文本是否溢出
*/
const checkOverflow = (node) => {
const element = document.querySelector(`.el-tree-node[data-key="${node.key}"] .combo-tree-v2__label`);
if (element && (overflowKey.value[node.key] !== true || overflowKey.value[node.key] !== false)) {
const isOverflow = element.scrollWidth > element.clientWidth;
overflowKey.value[node.key] = isOverflow;
}
}
defineExpose({
nodeClickFn,
setInputValue
});
</script>
<style lang="scss">
.el-select.combo-tree-v2 .el-input__icon.el-icon {
font-size: 18px;
transition: transform .3s --webkit-transform .3s;
transform: rotateZ(180deg);
cursor: pointer;
}
.el-select.combo-tree-v2 .el-input__icon.el-icon.is-reverse {
transform: rotateZ(0);
}
.tree-container {
border: 1px solid #d8d8d8;
}
.combo-tree-v2__label {
display: block;
width: 100%;
text-align: start;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
五、Utils工具类函数🔧
javascript
/**
* Check if an element has a Class
* @param {HTMLElement} ele
* @param {string} cls
* @returns {boolean}
*/
export function hasClass(ele, cls) {
return !!ele.className.match(new RegExp('(\\s|^)' + cls + '(\\s|$)'));
}
/**
* Add class to an element
* @param {HTMLElement} ele
* @param {string} cls
*/
export function addClass(ele, cls) {
if (!hasClass(ele, cls)) ele.className += ' ' + cls;
}
/**
* Remove class from element
* @param {HTMLElement} ele
* @param {string} cls
*/
export function removeClass(ele, cls) {
if (hasClass(ele, cls)) {
const reg = new RegExp('(\\s|^)' + cls + '(\\s|$)');
ele.className = ele.className.replace(reg, '');
}
}
六、使用组件的方法🚀
只需要先获取虚拟树的所有数据,并将数据传递给ComboTreeV2即可使用,组件可能有功能未完善,目前该组件主要用于表单里需要下拉树,且下拉树的数据很大的时候可以使用。剩余自定义功能可供添加!
xml
<template>
<div style="width: 100%; height: 100%">
<combo-tree-v2 :localData="treeData" clearable></combo-tree-v2>
</div>
</template>
<script setup>
import { onUnmounted, ref, onMounted, nextTick } from "vue";
import ComboTreeV2 from "@/components/ComboTreeV2/index.vue";
const treeData = ref([]);
const treeProps = ref({
value: "value",
label: "label",
children: "children",
});
onMounted(() => {
treeData.value = generateTreeData({ depth: 3, breadth: 20, prefix: "部门" });
});
/**
* 生成树形数据
* @param {Object} options 配置项
* @param {number} options.depth 树的深度(默认3)
* @param {number} options.breadth 每层的节点数(默认2)
* @param {string} options.prefix 节点名称前缀(默认'Node')
* @returns {Array} 树形数据
*/
function generateTreeData({ depth = 3, breadth = 2, prefix = "Node" } = {}) {
const result = [];
// 递归生成树节点
function buildTree(currentDepth, parentCode = "") {
if (currentDepth > depth) return [];
const nodes = [];
for (let i = 1; i <= breadth; i++) {
const value = parentCode ? `${parentCode}-${i}` : `${i}`;
const label = `${prefix} ${value}`;
const node = {
value, // value 字段
label, // label 字段
children: buildTree(currentDepth + 1, value), // 递归生成子节点
};
nodes.push(node);
}
return nodes;
}
// 生成根节点
for (let i = 1; i <= breadth; i++) {
result.push({
value: `${i}`,
label: `${prefix} ${i}dsaaaaaaaaasdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`,
children: buildTree(2, `${i}`), // 从第2层开始递归
});
}
return result;
}
</script>
<style scoped>
</style>
七、最终效果图如下🎨:


八、总结📝
ComboTreeV2 是一个集 高性能、易用性和可扩展性 于一体的树形选择组件,特别适合中后台系统的复杂数据选择场景。