Vue树选择

Element ui树选择

TreeSelect.vue

复制代码
<template>
  <div class="tree-select">
    <el-popover
      placement="bottom-start"
      width="300"
      trigger="click"
      v-model="visible"
      popper-class="tree-select-popper"
    >
      <!-- 搜索框 -->
      <el-input
        v-model="filterText"
        placeholder="搜索..."
        size="small"
        clearable
        style="margin-bottom: 8px;"
      />

      <!-- 树形结构 -->
      <el-tree
        ref="tree"
        :data="data"
        :props="defaultProps"
        node-key="id"
        :show-checkbox="multiple"
        highlight-current
        :filter-node-method="filterNode"
        @node-click="handleNodeClick"
        @check="handleCheck"
        default-expand-all
      />

      <!-- 输入框触发器 -->
      <el-input
        slot="reference"
        v-model="selectedLabels"
        :placeholder="placeholder"
        readonly
        suffix-icon="el-icon-caret-bottom"
      />
    </el-popover>
  </div>
</template>

<script>
export default {
  name: "TreeSelect",
  props: {
    value: [String, Number, Array],
    data: { type: Array, required: true },
    multiple: { type: Boolean, default: false },
    placeholder: { type: String, default: "请选择" }
  },
  data() {
    return {
      visible: false,
      filterText: "",
      selectedLabels: "",
      defaultProps: { children: "children", label: "label" }
    };
  },
  watch: {
    filterText(val) {
      this.$refs.tree?.filter(val);
    },
    value: {
      immediate: true,
      handler(val) {
        if (!val) {
          this.selectedLabels = "";
          return;
        }
        if (this.multiple && Array.isArray(val)) {
          const nodes = this.$refs.tree?.getCheckedNodes() || [];
          this.selectedLabels = this.formatGroupedLabels(nodes);
        } else {
          const node = this.findNodeById(this.data, val);
          this.selectedLabels = node ? node.label : "";
        }
      }
    },
    visible(val) {
      const popper = document.querySelector('.tree-select-popper');
      if (val) {
        // 弹窗打开,允许交互
        popper?.removeAttribute('inert');
      } else {
        // 弹窗关闭前,移除焦点
        this.$nextTick(() => {
          document.activeElement?.blur();
        });
        // 延迟设置 inert,确保动画完成后 DOM 被隐藏
        setTimeout(() => {
          popper?.setAttribute('inert', '');
        }, 300); // 与 Element UI 的 fade-in-linear 动画时长保持一致
      }
    }
  },
  methods: {
    filterNode(value, data) {
      if (!value) return true;
      return data.label.includes(value);
    },
    handleNodeClick(node) {
      if (!this.multiple) {
        this.selectedLabels = node.label;
        this.$emit("input", node.id);
        this.visible = false;
      }
    },
    handleCheck() {
      if (this.multiple) {
        const nodes = this.$refs.tree.getCheckedNodes();
        this.selectedLabels = this.formatGroupedLabels(nodes);
        this.$emit("input", nodes.map(n => n.id));
      }
    },
    formatGroupedLabels(nodes) {
      const grouped = {};
      nodes.forEach(node => {
        const topParent = this.findTopParent(this.data, node.id);
        if (topParent && node.id !== topParent.id) {
          if (!grouped[topParent.label]) {
            grouped[topParent.label] = [];
          }
          grouped[topParent.label].push(node.label);
        }
      });
      return Object.keys(grouped)
        .map(parent => `${parent}: ${grouped[parent].join(", ")}`)
        .join("; ");
    },
    findTopParent(list, id, parent = null) {
      for (let item of list) {
        if (item.id === id) return parent || item;
        if (item.children) {
          const found = this.findTopParent(item.children, id, parent || item);
          if (found) return found;
        }
      }
      return null;
    },
    findNodeById(list, id) {
      for (let item of list) {
        if (item.id === id) return item;
        if (item.children) {
          const found = this.findNodeById(item.children, id);
          if (found) return found;
        }
      }
      return null;
    }
  }
};
</script>

<style scoped>
.tree-select {
  width: 300px;
}
.tree-select-popper {
  max-height: 300px;
  overflow: auto;
}
</style>

使用示例

复制代码
<template>
  <div>
    <h3>单选</h3>
    <tree-select v-model="singleValue" :data="treeData" />

    <h3 style="margin-top:20px;">多选</h3>
    <tree-select v-model="multiValue" :data="treeData" multiple />
  </div>
</template>

<script>
import TreeSelect from "./TreeSelect.vue";

export default {
  components: { TreeSelect },
  data() {
    return {
      singleValue: null,
      multiValue: [],
      treeData: [
        {
          id: 1,
          label: "水果",
          children: [
            {
              id: 11,
              label: "苹果",
              children: [
                { id: 111, label: "红富士" },
                { id: 112, label: "奶苹果" }
              ]
            },
            { id: 12, label: "香蕉" }
          ]
        },
        {
          id: 2,
          label: "蔬菜",
          children: [
            { id: 21, label: "西红柿" },
            { id: 22, label: "黄瓜" }
          ]
        }
      ]
    };
  }
};
</script>

效果

扩展

v-model 的底层机制(Vue 2)

在 Vue 2 中,v-model 是语法糖,它自动做了两件事:

复制代码
<tree-select v-model="form.singleValue" />

等价于:

复制代码
<tree-select
  :value="form.singleValue"
  @input="form.singleValue = $event"
/>

所以你虽然没在父组件写 @input,Vue 帮你自动加上了!

子组件只需 $emit('input', newValue) 就能触发更新

你在子组件中写了:

复制代码
this.$emit('input', node.id);

Vue 会自动把这个 input 事件接到父组件的 v-model 上,并执行:

复制代码
form.singleValue = node.id;

为什么叫"语法糖"?

因为你没显式写 :value@input,但 Vue 自动帮你做了:

  • :value → 绑定到 props.value

  • @input → 自动更新绑定的变量

你只要在子组件里 $emit('input', newValue),父组件就会自动更新。

总结一句话

Vue 的 v-model 本质上是 :value + @input 的组合,子组件只要 $emit('input', ...),父组件就能自动更新绑定值。

相关推荐
娃哈哈哈哈呀5 分钟前
formData 传参 如何传数组
前端·javascript·vue.js
513495922 小时前
Vite环境变量配置
vue.js
2503_928411562 小时前
11.24 Vue-组件2
前端·javascript·vue.js
g***B7383 小时前
JavaScript在Node.js中的模块系统
开发语言·javascript·node.js
Z***25803 小时前
JavaScript在Node.js中的Deno
开发语言·javascript·node.js
weixin79893765432...3 小时前
Vue + Express + DeepSeek 实现一个简单的对话式 AI 应用
vue.js·人工智能·express
高级程序源4 小时前
springboot社区医疗中心预约挂号平台app-计算机毕业设计源码16750
java·vue.js·spring boot·mysql·spring·maven·mybatis
cypking4 小时前
Vue 3 + Vite + Router + Pinia + Element Plus + Monorepo + qiankun 构建企业级中后台前端框架
前端·javascript·vue.js
San30.4 小时前
ES6+ 新特性解析:让 JavaScript 开发更优雅高效
开发语言·javascript·es6
雨雨雨雨雨别下啦5 小时前
【从0开始学前端】vue3简介、核心代码、生命周期
前端·vue.js·vue