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

相关推荐
醉城夜风~1 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华2 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子4 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅4 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni4 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学6 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师6 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc886 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn7 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端