Vue3 -Tree树组件开发文档

1,322 阅读7分钟

image.png

一、组件概述

这个树组件基于 Vue3 和 Composition API 构建,提供了强大的节点选择功能,支持单选、多选模式,以及父子节点关联或独立的选择逻辑。组件设计灵活,可定制性强,适用于多种场景。

二、核心功能

1. 选择模式

  • 单选模式:每次只能选中一个节点,点击已选中节点会取消选择
  • 多选模式:可以同时选中多个节点,通过复选框或点击节点进行操作

2. 父子节点关联

  • 关联模式:父节点选中时,所有子节点自动选中;子节点全部选中时,父节点自动选中
  • 独立模式:父子节点选择状态互不影响,可单独控制

3. 节点操作

  • 点击节点:根据配置可触发选择操作
  • 展开 / 折叠:可通过图标控制节点的展开与折叠状态
  • 禁用节点:支持设置某些节点为禁用状态,不可选择

三、组件结构

1. 主要组件

  • Tree.vue:树组件主体,负责整体结构和状态管理
  • TreeNode.vue:树节点组件,负责单个节点的渲染和交互

2. 核心属性

  • treeData:树结构数据,包含节点的 id、标签、子节点等信息
  • modelValue:双向绑定的选中节点值
  • keyMap:自定义节点数据的键名映射
  • config:配置项,控制多选、父子关联等功能

四、使用示例

1. 基础用法

<Tree
  :treeData="treeData"
  v-model="checkedKeys"
  :keyMap="keyMap"
  :config="config"
/>

2. 完整示例代码

index.vue

<template>
  <div class="container">
    <h3>树组件示例</h3>
    <div class="options">
      <label>
        <input type="checkbox" v-model="config.multiple" /> 多选模式
      </label>
      <label>
        <input type="checkbox" v-model="config.checkStrictly" /> 父子节点不关联
      </label>
      <label>
        <input type="checkbox" v-model="config.expandAll" /> 全部展开
      </label>
      <label>
        <input type="checkbox" v-model="config.showCheckbox" /> 显示复选框
      </label>
      <button @click="getAllCheckedNodes">获取所有选中节点</button>
    </div>
    <div class="tree-wrapper">
      <Tree
        ref="treeRef"
        :treeData="treeData"
        v-model="checkedKeys"
        :keyMap="keyMap"
        :config="config"
        @node-check="handleNodeCheck"
      />
    </div>
    <div class="selected-result">
      <h4>选中结果:</h4>
      <pre>{{ checkedKeys }}</pre>
    </div>
    <div class="last-selected">
      <h4>最后操作节点:</h4>
      <pre v-if="lastSelectedNode">{{ lastSelectedNode[keyMap.id] }} - {{ lastSelectedNode[keyMap.label] }} ({{ lastSelectedStatus ? '选中' : '取消选中' }})</pre>
      <p v-else>无操作</p>
    </div>
    <div class="all-checked-nodes" v-if="allCheckedNodes.length > 0">
      <h4>所有选中节点:</h4>
      <pre>{{ allCheckedNodes }}</pre>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import Tree from './Tree.vue';

const treeRef = ref(null);
const allCheckedNodes = ref([]);

const treeData = ref([
  {
    id: 1,
    name: '一级节点1',
    children: [
      {
        id: 4,
        name: '二级节点1-1',
        children: [
          { id: 9, name: '三级节点1-1-1', disabled: true },
          { id: 10, name: '三级节点1-1-2' }
        ]
      },
      { id: 5, name: '二级节点1-2' }
    ]
  },
  {
    id: 2,
    name: '一级节点2',
    children: [
      { id: 6, name: '二级节点2-1' },
      { id: 7, name: '二级节点2-2' }
    ]
  },
  {
    id: 3,
    name: '一级节点3',
    disabled: true
  }
]);

const checkedKeys = ref([4, 7]);

const keyMap = ref({
  id: 'id',
  label: 'name',
  children: 'children',
  disabled: 'disabled'
});

const config = ref({
  multiple: true,
  checkStrictly: false,
  expandAll: false,
  showCheckbox: true
});

const lastSelectedNode = ref(null);
const lastSelectedStatus = ref(false);

const handleNodeCheck = (node, isChecked) => {
  lastSelectedNode.value = node;
  lastSelectedStatus.value = isChecked;
  console.log('节点勾选变化:', node, '选中状态:', isChecked);
};

const getAllCheckedNodes = () => {
  if (treeRef.value) {
    try {
      allCheckedNodes.value = treeRef.value.getCheckedNodes();
      console.log('所有选中节点:', allCheckedNodes.value);
    } catch (error) {
      console.error('获取选中节点时出错:', error);
      alert('获取选中节点时出错: ' + error.message);
    }
  }
};
</script>

<style>
.container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.options {
  margin-bottom: 20px;
  display: flex;
  gap: 15px;
  flex-wrap: wrap;
  align-items: center;
}

button {
  padding: 6px 12px;
  background-color: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.2s;
}

button:hover {
  background-color: #66b1ff;
}

.tree-wrapper {
  border: 1px solid #e5e7eb;
  border-radius: 4px;
  margin-bottom: 20px;
  max-height: 400px;
  overflow-y: auto;
}

.selected-result, .last-selected, .all-checked-nodes {
  background-color: #f9fafb;
  padding: 15px;
  border-radius: 4px;
  margin-bottom: 15px;
}
</style>

Tree.vue

<script setup>
import { computed, defineComponent, ref, watch } from 'vue'
import TreeNode from './TreeNode.vue'

const props = defineProps({
  treeData: {
    type: Array,
    required: true,
  },
  modelValue: {
    type: [Array, String, Number, null],
    default: () => [],
  },
  keyMap: {
    type: Object,
    default: () => ({
      id: 'id',
      label: 'label',
      children: 'children',
      disabled: 'disabled',
    }),
  },
  config: {
    type: Object,
    default: () => ({
      multiple: false,
      checkStrictly: false,
      expandAll: false,
      showCheckbox: true,
    }),
  },
})

const emit = defineEmits(['update:modelValue', 'nodeCheck'])

function handleCheckChange(value) {
  emit('update:modelValue', value)
}

function handleNodeCheck(node, isChecked) {
  emit('nodeCheck', node, isChecked)
}

// 获取所有选中节点对象的方法
function getCheckedNodes() {
  const checkedIds = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue].filter(id => id !== null)
  const result = []

  const traverse = (nodes) => {
    nodes.forEach((node) => {
      if (checkedIds.includes(node[props.keyMap.id])) {
        // 创建节点的副本,避免循环引用
        const cleanNode = { ...node }
        if (cleanNode.parent) {
          // 移除parent引用,避免循环
          delete cleanNode.parent
        }
        if (cleanNode[props.keyMap.children]) {
          // 递归处理子节点
          cleanNode[props.keyMap.children] = cleanNode[props.keyMap.children].map((child) => {
            const cleanChild = { ...child }
            delete cleanChild.parent
            return cleanChild
          })
        }
        result.push(cleanNode)
      }

      if (node[props.keyMap.children] && node[props.keyMap.children].length > 0) {
        traverse(node[props.keyMap.children])
      }
    })
  }

  traverse(props.treeData)
  return result
}

// 监听配置变化,当从多选切换到单选时清空选中状态
watch(() => props.config.multiple, (newVal, oldVal) => {
  if (oldVal && !newVal) {
    // 从多选切换到单选,清空选中状态
    emit('update:modelValue', null)
  }
})

// 提供对外方法
defineExpose({
  getCheckedNodes,
})
</script>

<template>
  <div class="tree-container">
    <ul class="tree-list">
      <TreeNode
        v-for="node in treeData"
        :key="node[keyMap.id]"
        :node="node"
        :key-map="keyMap"
        :config="config"
        :checked-keys="modelValue"
        @update:model-value="handleCheckChange"
        @node-check="handleNodeCheck"
      />
    </ul>
  </div>
</template>

  <style scoped>
  .tree-container {
  padding: 8px;
  border-radius: 4px;
}

.tree-list {
  list-style: none;
  padding: 0;
  margin: 0;
}
</style>

TreeNode.vue

<script setup>
import { computed, defineComponent, ref, watch } from 'vue'

const props = defineProps({
  node: {
    type: Object,
    required: true,
  },
  keyMap: {
    type: Object,
    required: true,
  },
  config: {
    type: Object,
    required: true,
  },
  checkedKeys: {
    type: [Array, String, Number, null],
    default: () => [],
  },
})

const emit = defineEmits(['update:modelValue', 'nodeCheck'])

const isExpanded = ref(props.config.expandAll)
const hasChildren = computed(() => Array.isArray(props.node[props.keyMap.children]) && props.node[props.keyMap.children].length > 0)
const isDisabled = computed(() => props.node[props.keyMap.disabled] === true)

// 节点选中状态
const isChecked = computed({
  get() {
    if (props.config.multiple) {
      return Array.isArray(props.checkedKeys) && props.checkedKeys.includes(props.node[props.keyMap.id])
    }
    else {
      return props.checkedKeys === props.node[props.keyMap.id]
    }
  },
  set(value) {
    handleCheck(value)
  },
})

// 计算子节点选中状态
const childrenCheckedState = computed(() => {
  if (!hasChildren.value || !props.config.multiple)
    return 'none'

  const children = props.node[props.keyMap.children]
  const checkedCount = children.filter(child =>
    Array.isArray(props.checkedKeys) && props.checkedKeys.includes(child[props.keyMap.id]),
  ).length

  if (checkedCount === 0)
    return 'none'
  if (checkedCount === children.length)
    return 'all'
  return 'partial'
})

// 父子关联逻辑
function handleCheck(newState) {
  if (isDisabled.value)
    return

  const nodeId = props.node[props.keyMap.id]
  let newCheckedKeys

  // 选中状态切换
  const isNodeChecked = isChecked.value
  const shouldCheck = newState !== undefined ? newState : !isNodeChecked

  if (props.config.multiple) {
    // 多选模式
    newCheckedKeys = Array.isArray(props.checkedKeys) ? [...props.checkedKeys] : []

    // 多选模式且父子关联
    if (!props.config.checkStrictly) {
      // 更新子节点
      const updateChildren = (children, checkState) => {
        children.forEach((child) => {
          if (!child[props.keyMap.disabled]) {
            const childId = child[props.keyMap.id]

            if (checkState) {
              if (!newCheckedKeys.includes(childId)) {
                newCheckedKeys.push(childId)
              }
            }
            else {
              newCheckedKeys = newCheckedKeys.filter(id => id !== childId)
            }

            if (child[props.keyMap.children] && child[props.keyMap.children].length > 0) {
              updateChildren(child[props.keyMap.children], checkState)
            }
          }
        })
      }

      // 更新父节点
      const updateParent = (node, checkState) => {
        if (!node.parent)
          return

        const parentId = node.parent[props.keyMap.id]
        const siblings = node.parent[props.keyMap.children]

        // 检查所有兄弟节点的选中状态
        const allSiblingsChecked = siblings.every(sib =>
          sib[props.keyMap.disabled] || newCheckedKeys.includes(sib[props.keyMap.id]),
        )

        const noSiblingsChecked = siblings.every(sib =>
          sib[props.keyMap.disabled] || !newCheckedKeys.includes(sib[props.keyMap.id]),
        )

        // 如果所有兄弟都被选中,则父节点也应被选中
        // 如果没有兄弟被选中,则父节点也应被取消选中
        if (allSiblingsChecked) {
          if (!newCheckedKeys.includes(parentId)) {
            newCheckedKeys.push(parentId)
            emit('nodeCheck', node.parent, true)
          }
        }
        else if (noSiblingsChecked) {
          newCheckedKeys = newCheckedKeys.filter(id => id !== parentId)
          emit('nodeCheck', node.parent, false)
        }

        // 递归更新上层父节点
        updateParent(node.parent, checkState)
      }

      // 处理子节点
      if (hasChildren.value) {
        updateChildren(props.node[props.keyMap.children], shouldCheck)
      }

      // 处理父节点
      if (props.node.parent) {
        updateParent(props.node, shouldCheck)
      }
    }

    // 更新当前节点
    if (shouldCheck) {
      if (!newCheckedKeys.includes(nodeId)) {
        newCheckedKeys.push(nodeId)
      }
    }
    else {
      newCheckedKeys = newCheckedKeys.filter(id => id !== nodeId)
    }
  }
  else {
    // 单选模式
    newCheckedKeys = shouldCheck ? nodeId : null
  }

  emit('update:modelValue', newCheckedKeys)
  emit('nodeCheck', props.node, shouldCheck)
}

function toggleExpand() {
  if (!isDisabled.value) {
    isExpanded.value = !isExpanded.value
  }
}

function handleNodeClick() {
  if (!props.config.showCheckbox && !isDisabled.value) {
    handleCheck()
  }
}

function handleChildCheckChange(newCheckedKeys) {
  emit('update:modelValue', newCheckedKeys)
}

function handleChildNodeCheck(node, isChecked) {
  emit('nodeCheck', node, isChecked)
}

// 添加父节点引用
watch(() => props.node[props.keyMap.children], (children) => {
  if (children && children.length > 0) {
    children.forEach((child) => {
      child.parent = props.node
    })
  }
}, { immediate: true, deep: true })

watch(() => props.config.expandAll, (newVal) => {
  isExpanded.value = newVal
})
</script>

<template>
  <li class="tree-node">
    <div
      class="node-content"
      :class="{
        'node-expanded': isExpanded,
        'node-selected': isChecked,
        'node-disabled': isDisabled,
      }"
    >
      <span
        v-if="hasChildren"
        class="expand-icon"
        @click.stop="toggleExpand"
      >
        {{ isExpanded ? '▾' : '▸' }}
      </span>
      <input
        v-if="config.showCheckbox"
        type="checkbox"
        :checked="isChecked"
        :disabled="isDisabled"
        @change.stop="handleCheck"
      >
      <span class="node-label" @click.stop="handleNodeClick">{{ node[keyMap.label] }}</span>
    </div>
    <ul
      v-if="hasChildren && isExpanded"
      class="children-list"
    >
      <TreeNode
        v-for="child in node[keyMap.children]"
        :key="child[keyMap.id]"
        :node="child"
        :key-map="keyMap"
        :config="config"
        :checked-keys="checkedKeys"
        @update:model-value="handleChildCheckChange"
        @node-check="handleChildNodeCheck"
      />
    </ul>
  </li>
</template>

  <style scoped>
  .tree-node {
  margin: 4px 0;
}

.node-content {
  display: flex;
  align-items: center;
  padding: 4px 8px;
  border-radius: 3px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.node-content:hover:not(.node-disabled) {
  background-color: #f5f7fa;
}

.node-expanded .expand-icon {
  transform: rotate(90deg);
}

.expand-icon {
  display: inline-block;
  width: 16px;
  text-align: center;
  margin-right: 4px;
  transition: transform 0.2s;
}

.node-label {
  margin-left: 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.node-selected {
  background-color: #e6f7ff;
}

.node-disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.children-list {
  list-style: none;
  padding-left: 20px;
  margin: 0;
  overflow: hidden;
  transition: max-height 0.3s ease-in-out;
}
</style>

五、配置详解

const config = {
  multiple: true,        // 是否启用多选模式
  checkStrictly: false,  // 是否父子节点不关联
  expandAll: false,      // 是否全部展开
  showCheckbox: true     // 是否显示复选框
};

3. 键名映射

const keyMap = {
  id: 'id',              // 节点ID的键名
  label: 'name',         // 节点显示文本的键名
  children: 'children',  // 子节点数组的键名
  disabled: 'disabled'   // 禁用状态的键名
};

六、事件处理

<Tree @node-check="handleNodeCheck" />
const handleNodeCheck = (node, isChecked) => {
  // node: 被操作的节点对象
  // isChecked: 当前的选中状态 (true/false)
  console.log('节点勾选变化:', node, '选中状态:', isChecked);
};

2. 获取所有选中节点

const getAllCheckedNodes = () => {
  if (treeRef.value) {
    const nodes = treeRef.value.getCheckedNodes();
    console.log('所有选中节点:', nodes);
  }
};

七、常见问题与解决方案

1. 多选模式切换问题

  • 问题描述:从多选切换到单选时,之前的选中状态未清除
  • 解决方案:组件已自动处理,切换模式时会清空选中状态

2. 循环引用错误

  • 问题描述:调用getCheckedNodes方法时出现循环引用错误
  • 解决方案:组件内部已处理,返回的节点数据不包含循环引用

3. 父子节点关联逻辑

  • 问题描述:父节点与子节点的选择状态不一致
  • 解决方案:确保checkStrictly配置正确,关联模式下会自动同步父子节点状态

八、扩展与定制

1. 自定义节点样式

可以通过修改TreeNode.vue中的样式类来定制节点外观,也可以添加自定义类。

2. 添加新功能

可以在组件基础上扩展新功能,如搜索过滤、拖拽排序等。

3. 事件扩展

可以添加新的事件,如节点展开 / 折叠事件、右键菜单事件等。

九、性能考虑

  • 对于大型树结构,建议实现虚拟滚动以提高性能
  • 避免频繁更新整个树数据,尽量局部更新
  • 可以考虑添加节点懒加载功能,减少初始渲染压力