一、组件概述
这个树组件基于 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. 事件扩展
可以添加新的事件,如节点展开 / 折叠事件、右键菜单事件等。
九、性能考虑
- 对于大型树结构,建议实现虚拟滚动以提高性能
- 避免频繁更新整个树数据,尽量局部更新
- 可以考虑添加节点懒加载功能,减少初始渲染压力