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', ...),父组件就能自动更新绑定值。

相关推荐
云枫晖2 小时前
手写Promise-静态方法reoslve和reject
前端·javascript
浮幻云月2 小时前
让 Vue 动画如德芙般丝滑!这个 FLIP 动画组件绝了!
前端·javascript
吃饺子不吃馅2 小时前
揭秘 X6 核心概念:Graph、Node、Edge 与 View
前端·javascript·svg
qwy7152292581632 小时前
Vue中的Provide/Inject如何实现动态数据
前端·javascript·vue.js
艾小码2 小时前
告别重复代码!React自定义Hook让逻辑复用如此简单
前端·javascript·react.js
Never_Satisfied3 小时前
在JavaScript / HTML中,让<audio>元素中的多个<source>标签连续播放
开发语言·javascript·html
患得患失9493 小时前
【ThreeJs】【HTML载入】Three.js 中的 CSS2DRenderer 与 CSS3DRenderer 全面解析
javascript·html·css3
puhaha3 小时前
前端实现PDF与图片添加自定义中文水印
javascript
我是日安3 小时前
零到一打造 Vue3 响应式系统 Day 16 - 性能处理:LinkPool
前端·vue.js