vue实战:构建通用的下拉树组件

前言

vue 中,如果你想实现一个下拉树的组件,可以直接使用 element plus 中的 treeSelect 组件,但是如果你的项目正在用的是 element 2.X 版本,那么它是不包含 treeSelect 组件的,但是我们还是可以基于一些第三方的插件或者自己封装组件实现这个操作。


一、@riophae/vue-treeselect 插件

riophae/vue-treeselect 是一个基于 vue.js 的插件,它提供了一个树形选择器组件,用于选择树形结构的数据。该插件支持多选、搜索、异步加载等功能,并且可以自定义选项的样式和模板。它的易用性和扩展性使其适用于各种类型的项目。

1.1 安装

npm/cnpm 或者 yarn 安装

npm i @riophae/vue-treeselect
yarn add @riophae/vue-treeselect

1.2 引入

入口文件 main.js 全局引入

import Treeselect from '@riophae/vue-treeselect'
import '@riophae/vue-treeselect/dist/vue-treeselect.css'
Vue.component('treeselect', Treeselect)

使用文件局部引入

<script>
import Treeselect from '@riophae/vue-treeselect'
import '@riophae/vue-treeselect/dist/vue-treeselect.css'
export default {
  components: {
    Treeselect
  },
}
</script>

1.3 基础使用

<template>
  <div class="box">
    <treeselect v-model="selectedItems" placeholder="请选择" :options="treeData"></treeselect>
    <el-button @click="getSelectedItems">获取选中的数据</el-button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      selectedItems: null,
      treeData: [
        {
          id: 1,
          label: "水果",
          children: [
            { id: 2, label: "西瓜" },
            { id: 3, label: "香蕉" },
            { id: 4, label: "橙子" },
          ],
        },
        {
          id: 5,
          label: "蔬菜",
          children: [
            { id: 6, label: "西红柿" },
            { id: 7, label: "黄瓜" },
            { id: 8, label: "青菜" },
          ],
        },
        {
          id: 9,
          label: "零食",
          children: [
            { id: 10, label: "薯片" },
            { id: 11, label: "巧克力" },
          ],
        },
      ],
    };
  },
  methods: {
    getSelectedItems() {
      console.log(this.selectedItems);
    },
  },
};
</script>

实现效果

在这里插入图片描述


1.4 进阶使用

  • 常用的属性
属性描述
v-model=“value”使用v-model指令绑定选中的项
:options=“treeData”设置树形数据
:multiple=“true”允许多选
:clearable=“true”显示清除按钮
:searchable=“true”显示搜索框
:disabled=“false”是否禁用
:openOnFocus=“true”获得焦点时自动展开下拉菜单
:openOnClick=“true”点击时自动展开下拉菜单
:auto-load-root-options=“true”自动加载根级选项
:async=“true”异步加载选项
:load-options=“loadOptions”异步加载选项的方法
:noChildrenText=“‘没有子选项’”没有子选项时的文本提示
:noOptionsText=“‘没有可选项’”没有可选项时的文本提示
:noResultsText=“‘没有匹配的结果’”搜索结果为空时的文本提示
:placeholder=“‘请选择’”占位符文本
:appendToBody=“true”弹出框是否附加到body元素
:normalizer=“normalizeOptions”规范化选项数据。通过normalizer属性,可以自定义选项数据的结构,以适应插件的要求
valueFormat=“”能够决定value属性的格式。当设置为"id"时,value属性的格式就是 id 或 id数组。当设置为"object"时,value属性的格式就是 node 或 node数组
  • 常用的方法
方法描述
@open=“handleOpen”下拉菜单打开时触发的事件
@close=“handleClose”下拉菜单关闭时触发的事件
@deselect=“handleRemove”移除选中项时触发的事件
@search-change=“handleSearch”搜索时触发的事件
@select=“handleSelect”选择项时触发的事件(清除值时不会触发)
@input=“handleInput”选中触发(第一次回显会触发,清除值会触发, value值为undefined)多用于v-model双向绑定组件更新父组件
  • 实践代码
<template>
  <div>
    <treeselect v-model="selectedItems" :options="treeData" :multiple="true" :clearable="true" :searchable="true" :disabled="false"
      :openOnFocus="true" :openOnClick="true" :auto-load-root-options="true" :async="false" :load-options="loadOptions"
      :noChildrenText="'没有子选项'" :noOptionsText="'没有可选项'" :noResultsText="'没有匹配的结果'" :placeholder="'请选择'" :appendToBody="true"
      @open="handleOpen" @close="handleClose" @input="handleInput" @deselect="handleRemove" @search-change="handleSearch"
      @select="handleSelect"></treeselect>
    <button @click="getSelectedItems">获取选中的数据</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      selectedItems: [], // 选中的项
      treeData: [
        {
          id: 1,
          label: "水果",
          children: [
            { id: 2, label: "西瓜" },
            { id: 3, label: "香蕉" },
            { id: 4, label: "橙子" },
          ],
        },
        {
          id: 5,
          label: "蔬菜",
          children: [
            { id: 6, label: "西红柿" },
            { id: 7, label: "黄瓜" },
            { id: 8, label: "青菜" },
          ],
        },
        {
          id: 9,
          label: "零食",
          children: [
            { id: 10, label: "薯片" },
            { id: 11, label: "巧克力" },
          ],
        },
      ],
    };
  },
  methods: {
    loadOptions({ parentNode, callback }) {
      // 异步加载选项的方法
      // parentNode: 当前父节点
      // callback: 加载完成后的回调函数
      // 在这里根据需要进行异步加载选项的操作,并在加载完成后调用callback方法传递选项数据
    },
    handleOpen() {
      // 下拉菜单打开时触发的事件
      console.log("下拉菜单打开");
    },
    handleClose() {
      // 下拉菜单关闭时触发的事件
      console.log("下拉菜单关闭");
    },
    handleRemove(removedItem) {
      // 移除选中项时触发的事件
      console.log("移除选中项", removedItem);
    },
    handleSearch(searchText) {
      // 搜索时触发的事件
      console.log("搜索", searchText);
    },
    handleSelect(selectedItems) {
      // 选择项时触发的事件
      console.log("选择项select", selectedItems);
    },
    handleInput(selectedItems) {
      // 选择项时触发的事件
      console.log("选择项input", selectedItems);
    },
    getSelectedItems() {
      // 获取选中的数据
      console.log(this.selectedItems);
    },
  },
};
</script>

1.5 常见问题

1.5.1 占位符 unknown

  • 问题截图
    在这里插入图片描述

  • 解决方法

    v-model 不能写成空字符串或者空数组,否则会出现 unknown,可以默认是 null

1.5.2 数据提示英文

  • 问题截图

在这里插入图片描述

  • 解决方法

    使用 noChildrenTextnoOptionsTextnoResultsText 自定义文本的属性。

    noChildrenText:用于定义当某个选项没有子选项时的文本提示。例如,当一个分类没有子分类时,可以使用 noChildrenText 来显示相应的提示文本。
    noOptionsText:用于定义当没有可选项时的文本提示。例如,当数据为空或没有匹配的选项时,可以使用 noOptionsText 来显示相应的提示文本。
    noResultsText:用于定义当搜索结果为空时的文本提示。例如,当用户进行搜索但没有匹配的结果时,可以使用 noResultsText 来显示相应的提示文本。

    <treeselect v-model="selectedItems" :options="treeData"  
    noChildrenText="没有子选项" noOptionsText="没有可选项" noResultsText="没有匹配的结果"></treeselect>
    

1.5.3 数据结构不符合

  • 问题描述

    很多时候后台返回回来的数据结构的字段并不是 idlabelchildren 这些,这个时候就需要我们将其换成符合要求的数据结构。

  • 解决方法

    使用 normalizer 属性,它用于规范化选项数据。通过 normalizer 属性,你可以自定义选项数据的结构,以适应插件的要求。

    <template>
      <div class="box">
        <treeselect v-model="selectedItems" :normalizer="normalizeOptions" :options="treeData"></treeselect>
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return {
          selectedItems: null,
          treeData: [
            {
              id: 1,
              name: "水果",
              children: [
                {
                  id: 2,
                  name: "苹果",
                  children: [
                    { id: 21, name: "红苹果" },
                    { id: 22, name: "绿苹果" },
                  ],
                },
                {
                  id: 3,
                  name: "香蕉",
                  children: [
                    { id: 31, name: "大香蕉" },
                    { id: 32, name: "小香蕉" },
                  ],
                },
              ],
            },
            {
              id: 5,
              name: "蔬菜",
              children: [
                {
                  id: 6,
                  name: "叶菜类",
                  children: [
                    { id: 61, name: "菠菜" },
                    { id: 62, name: "生菜" },
                  ],
                },
                {
                  id: 7,
                  name: "根茎类",
                  children: [],
                },
              ],
            },
          ],
        };
      },
      methods: {
        // 规范化选项数据的方法
        normalizeOptions(node) {
          // node: 原始的选项数据
          // 在这里根据需要进行选项数据的规范化操作,并返回规范化后的选项数据
          // 例如,可以将原始的选项数据转换为符合插件要求的结构
          if (node.children && !node.children.length) {
            // 去掉children=[]的children属性
            delete node.children;
          }
          return {
            id: node.id,
            label: node.name,
            children: node.children,
          };
        },
      },
    };
    </script>
    

    当然你也可以手动实现,通过递归的方式。

    <template>
      <div class="box">
        <treeselect v-model="selectedItems" :options="treeData"></treeselect>
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return {
          selectedItems: null,
          treeData: [],
        };
      },
      mounted() {
        let list = [
          {
            id: 1,
            name: "层级1",
            children: [
              {
                id: 2,
                name: "层级2",
                children: [
                  {
                    id: 3,
                    name: "层级3",
                    children: [
                      {
                        id: 4,
                        name: "层级4",
                      },
                    ],
                  },
                ],
              },
            ],
          },
        ];
        this.treeData = this.normalizeOptions(list);
      },
      methods: {
        normalizeOptions(options) {
          const normalizedOptions = [];
          if (options) {
            for (const option of options) {
              // 创建一个规范化选项对象,将id和name属性映射到该对象中
              const normalizedOption = {
                id: option.id,
                label: option.name,
              };
              // 检查当前选项是否有子选项
              if (option.children && option.children.length > 0) {
                // 如果有子选项,递归调用normalizeOptions方法对子选项进行规范化
                // 并将规范化后的子选项数组赋值给当前选项的children属性
                normalizedOption.children = this.normalizeOptions(option.children);
              }
              // 将规范化后的选项对象添加到normalizedOptions数组中
              normalizedOptions.push(normalizedOption);
            }
          }
          return normalizedOptions;
        },
      },
    };
    </script>
    

1.5.4 样式调整

/* 组件样式 */
::v-deep .vue-treeselect {
  width: 200px;
  height: 30px;
  line-height: 30px;
  font-size: 18px;
}
/* 内容样式 */
::v-deep .vue-treeselect__control {
  height: 30px;
  color: blue;
}
/* 占位符样式 */
::v-deep .vue-treeselect__placeholder,
::v-deep .vue-treeselect__single-value {
  color: red;
}

1.5.5 获取选中节点对象而不是单一的值

valueFormat 属性能够决定 value 属性的格式。当设置为 id 时,value 属性的格式就是 idid 数组。当设置为 object 时,value 属性的格式就是 nodenode 数组。

  • 单一的值

  • 节点对象

    <treeselect v-model="selectedItems" valueFormat="object" :options="treeData" @input="handleInput"></treeselect>
    

在这里插入图片描述


二、自定义组件

自定义的属性和方法

属性/方法描述类型
data展示数据array
props配置选项,具体配置可以参照element ui库中el-tree的配置object
show-checkbox节点是否可被选择boolean
check-strictly在显示复选框的情况下,是否严格的遵循父子不互相关联的做法,默认为falseboolean
icon-class自定义树节点的图标string
load加载子树数据的方法,仅当lazy属性为true时生效function(node, resolve)
lazy是否懒加载子节点,需要和load方法结合使用boolean
disabled下拉框是否禁用boolean
getCheckedKeys若节点可被选择(即show-checkbox为true),则返回目前被选中的节点的 key 所组成的数组
getCurrentNode获取当前被选中节点的data,若没有节点被选中则返回 null
collapse-tags多选时是否将选中值按文字的形式展示
select-last-node单选时是否只能选择最后一个节点
show-count若节点中存在children则在父节点展示所属children的数量,注意但设置插槽时show-count将失效
clearable单选时是否可以清空选项boolean
filterable启用搜索功能boolean

封装文件

<template>
  <el-select :value="valueFilter(value)" :placeholder="$attrs['placeholder']" :multiple="$attrs['show-checkbox']"
    :disabled="$attrs['disabled']" :filterable="$attrs['filterable']" :clearable="$attrs['clearable']"
    :collapse-tags="$attrs['collapse-tags']" @change="selectChange" @clear="selectClear" ref="mySelect" :filter-method="remoteMethod">
    <template slot="empty">
      <div class="selecTree">
        <el-tree :data="data" :props="props" @node-click="handleNodeClick" :show-checkbox="$attrs['show-checkbox']"
          :check-strictly="$attrs['check-strictly']" :icon-class="$attrs['icon-class']" :lazy="$attrs['lazy']" :load="$attrs['load']"
          :node-key="props.value" :filter-node-method="filterNode" @check-change="handleCheckChange"
          :default-expanded-keys="defaultExpandedKeys" ref="myTree">
          <template slot-scope="{ node, data }">
            <slot :node="node" :data="data">
              <span class="slotSpan">
                <span>
                  {{ data[props.label] }}
                  <b v-if="$attrs['show-count'] != undefined && data[props.children]">({{ data[props.children].length }})</b>
                </span>
              </span>
            </slot>
          </template>
        </el-tree>
      </div>
    </template>
  </el-select>
</template>
<script>
export default {
  props: {
    value: {
      type: undefined,
      default: null,
    },
    data: {
      type: Array,
      default: null,
    },
    props: {
      type: Object,
      default: null,
    },
  },
  data() {
    return {
      defaultExpandedKeys: [],
    };
  },
  created() {
    this.propsInit();
  },
  mounted() {
    setTimeout(this.initData, 10);
  },
  beforeUpdate() {
    this.propsInit();
    this.initData();
  },

  methods: {
    initData() {
      if (this.$attrs["show-checkbox"] === undefined) {
        let newItem = this.recurrenceQuery(
          this.data,
          this.props.value,
          this.value
        );
        if (newItem.length) {
          if (this.props.value && newItem[0][this.props.value]) {
            this.defaultExpandedKeys = [newItem[0][this.props.value]];
          }
          this.$nextTick(() => {
            this.$refs.myTree.setCurrentNode(newItem[0]);
          });
        }
      } else {
        let newValue = JSON.parse(JSON.stringify(this.value));
        if (!(newValue instanceof Array)) {
          newValue = [newValue];
        }
        if (newValue?.length) {
          let checkList = newValue.map((key) => {
            if (key) {
              let newItem = this.recurrenceQuery(
                this.data,
                this.props.value,
                key
              );
              return newItem[0] || "";
            }
          });
          if (checkList?.length) {
            let defaultExpandedKeys = checkList.map(
              (item) => item?.[this.props.value || ""]
            );
            if (defaultExpandedKeys.length)
              this.defaultExpandedKeys = defaultExpandedKeys;
            this.$nextTick(() => {
              this.$refs.myTree.setCheckedNodes(checkList);
            });
          }
        }
      }
      this.$forceUpdate();
    },
    // 多选
    handleCheckChange(data, e, ev) {
      let checkList = this.$refs.myTree.getCheckedNodes();
      let setList = null;
      if (checkList.length) {
        setList = checkList.map((item) => item[this.props.value]);
      }
      this.$emit("input", setList);
      // 共三个参数,依次为:传递给 data 属性的数组中该节点所对应的对象、节点本身是否被选中、节点的子树中是否有被选中的节点
      this.$emit("change", data, e, ev);
    },
    // 单选事件
    handleNodeClick(data, e) {
      if (!(this.$attrs["select-last-node"] === undefined)) {
        if (data[this.props.children] && data[this.props.children]?.length) {
          return false;
        }
      }
      if (this.$attrs["show-checkbox"] === undefined) {
        this.$emit("input", data[this.props.value]);
        this.$refs.mySelect.blur();
      }
      this.$emit("change", data, e);
    },
    //   递归查找通用方法
    recurrenceQuery(list, key, value) {
      if (!list || !key || !value) return [];
      let queryData = [];
      list.map((item) => {
        if (item[this.props.children] && item[this.props.children].length) {
          queryData.push(
            ...this.recurrenceQuery(item[this.props.children], key, value)
          );
        }
        if (item[key] == value) {
          queryData.push(item);
        }
        return item;
      });
      return queryData;
    },
    selectChange(e) {
      if (this.$attrs["show-checkbox"] !== undefined) {
        let checkList = e.map((key) => {
          let newItem = this.recurrenceQuery(this.data, this.props.label, key);
          return newItem[0] || "";
        });
        this.$refs.myTree.setCheckedNodes(checkList);
        this.$emit("input", e);
      }
    },
    selectClear(flag) {
      if (this.$attrs["show-checkbox"] === undefined) {
        if (!flag) this.$emit("input", "");
        this.$refs.myTree.setCurrentKey(null);
      } else {
        if (!flag) this.$emit("input", []);
        this.$refs.myTree.setCheckedKeys([]);
      }
      this.remoteMethod("");
    },
    getCheckedNodes() {
      if (
        this.value !== null &&
        this.value !== undefined &&
        this.value !== ""
      ) {
        return this.$refs.myTree.getCheckedNodes();
      }
      return [];
    },
    getCurrentNode() {
      if (
        this.value !== null &&
        this.value !== undefined &&
        this.value !== ""
      ) {
        return this.$refs.myTree.getCurrentNode();
      }
      return null;
    },
    valueFilter(val) {
      if (this.$attrs["show-checkbox"] === undefined) {
        let res = "";
        [res] = this.recurrenceQuery(this.data, this.props.value, val);
        return res?.[this.props.label] || "";
      } else {
        if (!val?.length) return [];
        let res = val.map((item) => {
          let [newItem] = this.recurrenceQuery(
            this.data,
            this.props.value,
            item
          );
          return newItem?.[this.props.label] || "";
        });
        if (!res?.length) return [];
        res = res.filter((item) => item);
        return res;
      }
    },
    propsInit() {
      this.props.label = this.props.label || "label";
      this.props.value = this.props.value || "value";
      this.props.children = this.props.children || "children";
      if (
        this.$attrs["select-last-node"] !== undefined &&
        !this.props.disabled
      ) {
        this.props.disabled = (data) => data?.[this.props.children]?.length;
        this.$attrs["check-strictly"] = true;
      }
    },

    remoteMethod(query) {
      this.$refs.myTree.filter(query);
    },
    filterNode(value, data) {
      if (!value) return true;
      return data[this.props.label].indexOf(value) !== -1;
    },
  },

  watch: {
    value: {
      deep: true,
      handler(val) {
        if (!val || !val?.length) {
          this.selectClear(true);
        }
      },
    },
  },
};
</script>

使用文件

<template>
  <div class="box">
    <tree-select @change="sendSelectedValue" v-model="value" :data="treeData" :props="treeProps" filterable clearable></tree-select>
  </div>
</template>

<script>
import TreeSelect from "@/components/treeSelect";
export default {
  components: {
    TreeSelect,
  },
  data() {
    return {
      value: "",
      treeData: [
        {
          id: 1,
          name: "水果",
          children: [
            {
              id: 2,
              name: "苹果",
              children: [
                { id: 21, name: "红苹果" },
                { id: 22, name: "绿苹果" },
              ],
            },
            {
              id: 3,
              name: "香蕉",
              children: [
                { id: 31, name: "大香蕉" },
                { id: 32, name: "小香蕉" },
              ],
            },
          ],
        },
        {
          id: 5,
          name: "蔬菜",
          children: [
            {
              id: 6,
              name: "叶菜类",
              children: [
                { id: 61, name: "菠菜" },
                { id: 62, name: "生菜" },
              ],
            },
            {
              id: 7,
              name: "根茎类",
              children: [],
            },
          ],
        },
      ],
      // 配置项
      treeProps: {
        label: "name", // 树节点的文本字段
        value: "id", // 树节点的值字段
        children: "children", // 树节点的子节点字段
        disabled: (data) => data.disabled, // 禁用节点的条件函数,接收一个参数 data,返回一个布尔值
        iconClass: "custom-icon", // 自定义树节点的图标样式
        checkStrictly: true, // 在显示复选框的情况下,是否严格遵循父子节点不互相关联的做法
        load: (node, resolve) => {}, // 加载子树数据的方法,仅当 lazy 属性为 true 时生效
        lazy: true, // 是否懒加载子节点,需与 load 方法结合使用
        collapseTags: true, // 多选时是否将选中值按文字的形式展示
        selectLastNode: true, // 单选时是否只能选择最后一个节点
        showCount: true, // 若节点中存在 children,则在父节点展示所属 children 的数量
      },
    };
  },
  methods: {
    sendSelectedValue(e) {
      console.log(e);
    },
  },
};
</script>

实现效果

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

水星记_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值