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 在显示复选框的情况下,是否严格的遵循父子不互相关联的做法,默认为false boolean
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>

实现效果

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/Shids_/article/details/134872897