Skip to content

封装树形组件

使用示例

支持的属性:

  1. data:树形结果的数据,例如:

    js
    const data = ref([
      {
        label: '水果',
        checked: false, // 添加初始勾选状态
        children: [
          {
            label: '苹果',
            checked: false,
            children: [
              {
                label: '红富士',
                checked: false
              },
              {
                label: '黄元帅',
                checked: false
              }
            ]
          },
        ]
      },
    ])
  2. show-checkbox:是否显示复选框

  3. transition:是否应用过渡效果

  4. 支持事件 @update:child-check,可以获取最新的状态

vue
<Tree
  :data="data"
  :show-checkbox="true"
  :transition="true"
  @update:child-check="handleChildCheck"
/>

实现

思路

渲染视图:

* 把数据渲染成视图

* 子节点递归渲染

* 实现show-checkbox属性

* 实现展开逻辑

定义响应式数据 数据是对子节点展开的映射map

点击节点的时候,改变映射关系

* 实现勾选

勾选父,选中所有的子

勾选所有的子,选中父

组件实现

vue
<template>
  <div class="tree-node" v-for="(node, index) in data" :key="node.label">
    <div class="node-label">
      <button
        class="toggle-button"
        v-show="hasChild(node)"
        @click="isOpenArr[index] = !isOpenArr[index]"
      >
        {{ isOpenArr[index] ? "▼" : "►" }}
      </button>
      <input
        type="checkbox"
        v-if="showCheckbox"
        v-model="node.checked"
        @change="onCheck(node, node.checked)"
      />
      <label>{{ node.label }}</label>
    </div>
    <!-- 递归组件:将属性传递给子组件 -->
    <div v-if="transition">
      <Transition
        name="expand"
        @before-enter="beforeEnter"
        @enter="enter"
        @after-enter="afterEnter"
        @before-leave="beforeLeave"
        @leave="leave"
        @after-leave="afterLeave"
      >
        <div v-show="isOpenArr[index]" v-if="node.children">
          <!-- 递归调用的时候,触发上一层的事件
            除了点击第一层的checkbox触发emit
           点击递归组件的时候,也要绑定checked事件,然后触发上一层
           -->
          <Tree
            :data="node.children"
            :showCheckbox
            @checked="$emit('checked', node)"
          />
        </div>
      </Transition>
    </div>

    <div v-else>
      <div v-show="isOpenArr[index]" v-if="node.children">
        <Tree
          :data="node.children"
          :showCheckbox
          :transition
          @checked="$emit('checked', node)"
        />
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";
import { inject, provide, watch } from "vue";

// props
const props = defineProps({
  data: {
    type: Array,
    required: true,
  },
  showCheckbox: {
    type: Boolean,
    default: true,
  },
  transition: {
    type: Boolean,
    default: true,
  },
});

const emits = defineEmits(["checked"]);

//依赖注入当前的父节点,并且将父节点提供给子组件
const parentNode = inject("parentNode", null); //注入父节点,根节点默认为null
provide("parentNode", props.data);

//对应每一层的展开状态  [false,false,false]
const isOpenArr = ref(props.data.map(() => true));

// 点击展开箭头
const onToggle = (node) => {};
//是否有子节点
const hasChild = (node) => {
  return node.children && node.children.length > 0;
};

// 勾选
const onCheck = (node, checked) => {
  /**
   * 选中所有的子节点
   * @param node
   * @param checked
   */
  const updateAllChild = (node, checked) => {
    node.children?.forEach((item) => {
      item.checked = checked;
      // 递归
      if (hasChild(node)) {
        node.children.forEach((child) => {
          updateAllChild(child, checked);
        });
      }
    });
  };

  /**更新父亲的选中状态
   * @param node   当前节点
   */
  const updateParent = (node) => {
    /* 
    这里的难点是如何将父节点传递进来
    1.可以给<Tree/>传递一个parent属性,值就是node(当前的节点),在props中接收
    2.可以使用provide/inject来获取
    */
    if (parentNode) {
      //   在所有的节点中找到当前的父节点
      console.log("parentNode", parentNode);
      console.log("当前的node", node);
      for (const pNode of parentNode) {
        if (pNode.children.includes(node)) {
          console.log("找到了", pNode);
          pNode.checked = pNode.children.every((child) => child.checked);
          updateParent(pNode);
        }
      }
    }
  };

  //选中的同时去更新父子节点的选中状态
  updateAllChild(node, checked);
  updateParent(node);

  //触发自定义事件
  emits("checked", node);
};

//解决子元素全选了但根元素无法选中的Bug
watch(
  () => props.data,
  (newVal) => {
    parentNode?.forEach((node) => {
      if (node.children && node.children.length) {
        node.checked = node.children.every((child) => child.checked);
      }
    });
  },
  { deep: true }
);

// 过渡动画相关的方法
function beforeEnter(el) {
  el.style.maxHeight = "0";
  el.style.opacity = "0";
  el.style.overflow = "hidden";
}

function enter(el) {
  el.style.transition = "max-height 0.3s ease, opacity 0.3s ease";
  el.style.maxHeight = el.scrollHeight + "px";
  el.style.opacity = "1";
}

function afterEnter(el) {
  el.style.maxHeight = "none";
}

function beforeLeave(el) {
  el.style.maxHeight = el.scrollHeight + "px";
  el.style.opacity = "1";
  el.style.overflow = "hidden";
}

function leave(el) {
  el.style.transition = "max-height 0.3s ease, opacity 0.3s ease";
  el.style.maxHeight = "0";
  el.style.opacity = "0";
}

function afterLeave(el) {
  el.style.maxHeight = "none";
}
</script>

<style scoped>
.tree-node {
  margin-left: 20px;
  font-family: Arial, sans-serif;
}
.node-label {
  cursor: pointer;
  display: flex;
  align-items: center;
  font-size: 14px;
}
.toggle-button {
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
  font-size: 14px;
  color: black;
}
</style>

使用

vue
<template>
  <div id="app">
    <Tree
      :data="data"
      :show-checkbox="true"
      :transition="true"
      @checked="onChecked"
    />
  </div>
</template>

<script setup>
import Tree from "./components/Tree.vue";
import { ref } from "vue";
// tree组件的数据
const data = ref([
  {
    label: "水果",
    checked: false, // 添加初始勾选状态
    children: [
      {
        label: "苹果",
        checked: false,
        children: [
          {
            label: "红富士",
            checked: false,
          },
          {
            label: "黄元帅",
            checked: false,
          },
        ],
      },
      {
        label: "香蕉",
        checked: false,
        children: [
          {
            label: "大香蕉",
            checked: false,
          },
          {
            label: "小香蕉",
            checked: false,
          },
        ],
      },
    ],
  },
  {
    label: "游戏",
    checked: false,
    children: [
      {
        label: "英雄联盟",
        checked: false,
        children: [
          {
            label: "剑圣",
            checked: false,
          },
          {
            label: "盖伦",
            checked: false,
          },
          {
            label: "流浪法师",
            checked: false,
          },
          {
            label: "女枪",
            checked: false,
          },
        ],
      },
      {
        label: "王者荣耀",
        checked: false,
        children: [
          {
            label: "后羿",
            checked: false,
          },
          {
            label: "鲁班七号",
            checked: false,
          },
        ],
      },
    ],
  },
  {
    label: "电影",
    checked: false,
    children: [
      {
        label: "动作片",
        checked: false,
        children: [
          {
            label: "速度与激情",
            checked: false,
          },
          {
            label: "碟中谍",
            checked: false,
          },
        ],
      },
      {
        label: "爱情片",
        checked: false,
        children: [
          {
            label: "泰坦尼克号",
            checked: false,
          },
          {
            label: "罗密欧与朱丽叶",
            checked: false,
          },
        ],
      },
    ],
  },
]);

//
const onChecked = (node) => {
  console.log("选中了", node);
};
</script>

<style>
#app {
  font-family: "Avenir", Helvetica, Arial, sans-serif;
  text-align: center;
  color: #2c3e50;
  margin: 60px auto;
  width: 300px;
}
</style>

MIT License