🌳 ComboTreeV2:高性能虚拟树

引言:放置表单里的虚拟树!

一个结合 el-inputel-tree-v2el-popover以及el-tooltip 的 Vue3 组件,支持大数据量加载、虚拟滚动、智能过滤和交互优化!主要是放置在表单里,实现表单的大数据量加载,滚动不卡顿以及搜索不卡顿的功能!

一、代码实现,输入框,下拉框和虚拟树的结合🛠️

一开始作者想直接将虚拟树和输入框结合,但发现下拉框的宽度不好控制,想根据渲染的树节点最长的长度进行控制,但发现如果用户搜索后得到的数据量过大,会导致下拉框渲染特别慢,且很卡,失去了虚拟滚动渲染指定数量节点的功效。 解决方法:故想到让下拉框固定其宽度并且将超过宽度文本使用省略号显示,超出长度的直接使用el-tooltip 显示全部长度。

二、交互优化 🎯

  • 点击输入框展开树,点击外部自动关闭。
  • 清除按钮动态显示(hover 时出现)。
  • 树节点文本溢出时自动显示 Tooltip

三、虚拟滚动树 🚀

  • 基于 el-tree-v2,支持 万级数据流畅渲染(告别卡顿)。
  • 可配置 heightmaxHeight,适应不同场景。

四、话不多说,上代码

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 是一个集 高性能、易用性和可扩展性 于一体的树形选择组件,特别适合中后台系统的复杂数据选择场景。

相关推荐
ywf12151 小时前
前端的dist包放到后端springboot项目下一起打包
前端·spring boot·后端
恋猫de小郭1 小时前
2026,Android Compose 终于支持 Hot Reload 了,但是收费
android·前端·flutter
hpoenixf7 小时前
2026 年前端面试问什么
前端·面试
还是大剑师兰特7 小时前
Vue3 中的 defineExpose 完全指南
前端·javascript·vue.js
泯泷7 小时前
阶段一:从 0 看懂 JSVMP 架构,先在脑子里搭出一台最小 JSVM
前端·javascript·架构
mengchanmian8 小时前
前端node常用配置
前端
华洛8 小时前
利好打工人,openclaw不是企业提效工具,而是个人助理
前端·javascript·产品经理
xkxnq9 小时前
第六阶段:Vue生态高级整合与优化(第93天)Element Plus进阶:自定义主题(变量覆盖)+ 全局配置与组件按需加载优化
前端·javascript·vue.js
A黄俊辉A9 小时前
vue css中 :global的使用
前端·javascript·vue.js
小码哥_常10 小时前
被EdgeToEdge适配折磨疯了,谁懂!
前端