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

相关推荐
Revol_C3 小时前
【Element Plus】升级版本后,el-drawer自定义的关闭按钮离奇消失之谜
前端·css·vue.js
烟袅3 小时前
小程序开发入门:从结构到事件,快速掌握核心要点
前端·微信小程序
BLOOM3 小时前
新一代前端数据mock工具Data Faker
前端·javascript
UIUV3 小时前
微信小程序开发学习笔记:从架构到实战
前端·javascript·前端框架
程序猿_极客3 小时前
JavaScript的Web APIs 入门到实战(day2):事件监听与交互实现,轻松实现网页交互效果(附练习巩固)
开发语言·前端·javascript·学习笔记·web apis 入门到实战
Mintopia3 小时前
🚀 一文看懂 “Next.js 全栈 + 微服务 + GraphQL” 的整体样貌
前端·javascript·全栈
Mintopia3 小时前
🧬 医疗Web场景下,AIGC的辅助诊断技术边界与伦理
前端·javascript·aigc
半桶水专家3 小时前
父子组件通信详解
开发语言·前端·javascript
Watermelo6173 小时前
从vw/h到clamp(),前端响应式设计的痛点与进化
前端·javascript·css·算法·css3·用户界面·用户体验