Vue 3 Tree 组件
一个功能完整、高度可定制的 Vue 3 树形组件,适用于企业级应用开发。
📋 目录
🌟 功能特性
核心功能
- ✅ 树形结构展示:支持无限层级的树形数据展示
- ✅ 展开/折叠:可控制节点展开状态,支持手风琴模式
- ✅ 节点选择:单选和多选支持,带有选中状态高亮
- ✅ 复选框选择:可选的复选框模式,支持父子节点联动
- ✅ 搜索过滤:内置搜索功能,支持自定义过滤方法
- ✅ 拖拽排序:支持节点拖拽重新排序和移动
- ✅ 懒加载:支持大数据量的懒加载机制
- ✅ 虚拟滚动:处理大数据集时的性能优化
- ✅ 自定义渲染:支持自定义节点内容和图标
- ✅ 右键菜单:可定制的上下文菜单
- ✅ 键盘导航:完整的键盘操作支持
- ✅ 响应式设计:完美适配桌面端和移动端
设计特色
- 🎨 现代化 UI:简洁美观的界面设计,支持主题定制
- 📱 移动端优化:针对触摸设备优化的交互体验
- ⚡ 高性能:虚拟滚动和懒加载确保大数据量下的流畅体验
- 🔧 高可扩展性:丰富的插槽和事件系统,便于功能扩展
- 🛡️ 类型安全:完整的 TypeScript 类型定义(可选)
- ♿ 无障碍:遵循 WCAG 标准的无障碍设计
🚀 快速开始
安装
npm install vue3-tree-component
# 或
yarn add vue3-tree-component
基本使用
<template>
<Tree
:data="treeData"
:show-checkbox="true"
:filterable="true"
@node-click="handleNodeClick"
/>
</template>
<script setup>
import { ref } from 'vue'
import Tree from 'vue3-tree-component'
const treeData = ref([
{
id: 1,
label: '节点1',
children: [
{
id: 2,
label: '子节点1-1'
},
{
id: 3,
label: '子节点1-2'
}
]
}
])
const handleNodeClick = (node) => {
console.log('点击了节点:', node)
}
</script>
📖 基础用法
基本树形结构
<template>
<Tree :data="basicData" />
</template>
<script setup>
const basicData = [
{
id: '1',
label: '一级节点',
children: [
{
id: '1-1',
label: '二级节点1',
children: [
{ id: '1-1-1', label: '三级节点1' },
{ id: '1-1-2', label: '三级节点2' }
]
},
{
id: '1-2',
label: '二级节点2'
}
]
}
]
</script>
带复选框的树
<template>
<Tree
:data="checkboxData"
:show-checkbox="true"
:default-checked-keys="['1-1', '1-2-1']"
@check-change="handleCheckChange"
/>
</template>
<script setup>
const handleCheckChange = (node, checked, checkedKeys) => {
console.log('选中状态改变:', { node, checked, checkedKeys })
}
</script>
可搜索的树
<template>
<Tree
:data="searchableData"
:filterable="true"
:filter-node-method="filterNode"
filter-placeholder="请输入关键字搜索"
/>
</template>
<script setup>
const filterNode = (value, data) => {
if (!value) return true
return data.label.includes(value)
}
</script>
可拖拽的树
<template>
<Tree
:data="draggableData"
:draggable="true"
@node-drop="handleNodeDrop"
/>
</template>
<script setup>
const handleNodeDrop = (dragNode, dropNode, position) => {
console.log('节点拖拽:', { dragNode, dropNode, position })
// 在这里处理数据更新逻辑
}
</script>
📚 API 文档
Tree Props
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
data | 展示数据 | Array | [] |
nodeKey | 每个树节点用来作为唯一标识的属性 | String | 'id' |
showCheckbox | 是否显示复选框 | Boolean | false |
showLine | 是否显示连接线 | Boolean | false |
draggable | 是否开启拖拽功能 | Boolean | false |
filterable | 是否可过滤 | Boolean | false |
filterPlaceholder | 过滤输入框的占位符 | String | '请输入关键字进行过滤' |
emptyText | 内容为空的时候展示的文本 | String | '暂无数据' |
defaultExpandedKeys | 默认展开的节点的 key 的数组 | Array | [] |
defaultSelectedKeys | 默认选中的节点的 key 的数组 | Array | [] |
defaultCheckedKeys | 默认勾选的节点的 key 的数组 | Array | [] |
defaultExpandAll | 是否默认展开所有节点 | Boolean | false |
highlightCurrent | 是否高亮当前选中节点 | Boolean | false |
accordion | 是否每次只打开一个同级树节点展开 | Boolean | false |
checkStrictly | 在显示复选框的情况下,是否严格的遵循父子不互相关联的做法 | Boolean | false |
lazy | 是否懒加载子节点 | Boolean | false |
load | 加载子树数据的方法 | Function | - |
filterNodeMethod | 对树节点进行筛选时执行的方法 | Function | - |
Tree Events
事件名称 | 说明 | 回调参数 |
---|---|---|
node-click | 节点被点击时的回调 | (node, event) |
node-expand | 节点被展开时的回调 | (node) |
node-collapse | 节点被收起时的回调 | (node) |
check-change | 节点选中状态发生变化时的回调 | (node, checked, checkedKeys) |
current-change | 当前选中节点变化时的回调 | (current, previous) |
node-drag-start | 节点开始拖拽时的回调 | (node, event) |
node-drag-end | 节点拖拽结束时的回调 | (node, event) |
node-drop | 拖拽成功完成时的回调 | (dragNode, dropNode, position, event) |
node-context-menu | 当某一节点被鼠标右键点击时的回调 | (node, event) |
Tree Methods
方法名 | 说明 | 参数 |
---|---|---|
getNode | 根据 key 获取树节点 | (key) |
getCheckedNodes | 获取目前被选中的节点 | (leafOnly) |
setCheckedKeys | 通过 keys 设置目前勾选的节点 | (keys) |
setCurrentNode | 设置当前选中的节点 | (node) |
expandNode | 展开指定节点 | (node) |
collapseNode | 收起指定节点 | (node) |
expandAllNodes | 展开所有节点 | - |
collapseAllNodes | 收起所有节点 | - |
Tree Slots
插槽名 | 说明 | 参数 |
---|---|---|
default | 自定义树节点的内容 | { node } |
icon | 自定义树节点的图标 | { node, expanded } |
empty | 无数据时的内容 | - |
context-menu | 自定义右键菜单内容 | { node } |
🔧 高级功能
懒加载
懒加载适用于数据量较大或需要动态加载子节点的场景:
<template>
<Tree
:data="lazyData"
:lazy="true"
:load="loadNode"
node-key="id"
/>
</template>
<script setup>
import { ref } from 'vue'
const lazyData = ref([
{
id: 1,
label: '懒加载节点',
isLeaf: false
}
])
const loadNode = async (node) => {
// 模拟异步加载
return new Promise((resolve) => {
setTimeout(() => {
const children = []
for (let i = 1; i <= 3; i++) {
children.push({
id: `${node.id}-${i}`,
label: `子节点 ${node.id}-${i}`,
isLeaf: i === 3
})
}
resolve(children)
}, 1000)
})
}
</script>
虚拟滚动
对于大数据量的树,启用虚拟滚动可以提升性能:
<template>
<Tree
:data="bigData"
:virtual-scroll="true"
:item-size="32"
:buffer-size="5"
/>
</template>
<script setup>
// 生成大量数据
const bigData = ref(generateBigData(10000))
function generateBigData(count) {
const data = []
for (let i = 0; i < count; i++) {
data.push({
id: i,
label: `节点 ${i}`,
children: i % 10 === 0 ? [
{ id: `${i}-1`, label: `子节点 ${i}-1` },
{ id: `${i}-2`, label: `子节点 ${i}-2` }
] : undefined
})
}
return data
}
</script>
自定义节点渲染
使用插槽完全自定义节点的外观:
<template>
<Tree :data="customData">
<template #default="{ node }">
<div class="custom-node">
<span class="node-label">{{ node.label }}</span>
<span class="node-badge" v-if="node.badge">{{ node.badge }}</span>
<button
class="node-action"
@click.stop="editNode(node)"
>
编辑
</button>
</div>
</template>
<template #icon="{ node, expanded }">
<i :class="getIconClass(node, expanded)"></i>
</template>
</Tree>
</template>
<script setup>
const customData = ref([
{
id: 1,
label: '项目文件',
type: 'folder',
badge: 'New',
children: [
{
id: 2,
label: 'src',
type: 'folder',
children: [
{ id: 3, label: 'App.vue', type: 'vue' },
{ id: 4, label: 'main.js', type: 'js' }
]
}
]
}
])
const getIconClass = (node, expanded) => {
if (node.type === 'folder') {
return expanded ? 'icon-folder-open' : 'icon-folder'
}
return `icon-${node.type}`
}
const editNode = (node) => {
console.log('编辑节点:', node)
}
</script>
<style scoped>
.custom-node {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.node-label {
flex: 1;
}
.node-badge {
background: #ff4757;
color: white;
padding: 2px 6px;
border-radius: 10px;
font-size: 10px;
}
.node-action {
background: #3742fa;
color: white;
border: none;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
</style>
🔨 技术实现
架构设计
本组件采用组合式 API 和响应式设计,主要包含以下核心模块:
- Tree.vue - 主组件,负责整体逻辑控制
- TreeNode.vue - 节点组件,处理单个节点的展示和交互
- useTree - 组合式函数,封装树形数据处理逻辑
- useVirtualScroll - 虚拟滚动实现
- useDragDrop - 拖拽功能实现
核心算法
1. 树形数据扁平化
// 将树形结构转换为扁平化映射,便于快速查找和操作
const buildFlattenNodes = (nodes, map = new Map(), parent = null) => {
nodes.forEach(node => {
const processedNode = { ...node, parent }
map.set(node[nodeKey], processedNode)
if (node.children) {
buildFlattenNodes(node.children, map, processedNode)
}
})
return map
}
2. 复选框状态联动
// 计算父子节点复选框状态联动
const updateCheckedState = (node, checked) => {
// 更新子节点
const updateChildren = (children, checked) => {
if (!children) return
children.forEach(child => {
child.checked = checked
updateChildren(child.children, checked)
})
}
// 更新父节点
const updateParent = (parent) => {
if (!parent) return
const siblings = parent.children
const checkedCount = siblings.filter(s => s.checked).length
const indeterminateCount = siblings.filter(s => s.indeterminate).length
if (checkedCount === siblings.length) {
parent.checked = true
parent.indeterminate = false
} else if (checkedCount > 0 || indeterminateCount > 0) {
parent.checked = false
parent.indeterminate = true
} else {
parent.checked = false
parent.indeterminate = false
}
updateParent(parent.parent)
}
updateChildren(node.children, checked)
updateParent(node.parent)
}
3. 虚拟滚动实现
// 虚拟滚动核心逻辑
const useVirtualScroll = (items, itemSize, containerHeight) => {
const scrollTop = ref(0)
const visibleCount = computed(() => Math.ceil(containerHeight / itemSize))
const startIndex = computed(() => Math.floor(scrollTop.value / itemSize))
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, items.length))
const visibleItems = computed(() => {
return items.slice(startIndex.value, endIndex.value)
})
const offsetY = computed(() => startIndex.value * itemSize)
const totalHeight = computed(() => items.length * itemSize)
return {
visibleItems,
offsetY,
totalHeight,
scrollTop
}
}
性能优化策略
- 虚拟滚动:大数据量时只渲染可见区域的节点
- 懒加载:按需加载子节点数据
- 事件委托:减少事件监听器数量
- 防抖优化:搜索和过滤功能使用防抖
- 响应式优化:合理使用
computed
和watch
移动端适配
- 触摸优化:增大触摸区域,优化触摸反馈
- 响应式布局:使用 CSS Grid 和 Flexbox
- 手势支持:支持滑动展开/收起
- 性能优化:移动端特殊的性能考虑
💡 最佳实践
1. 数据结构设计
// 推荐的数据结构
const treeDataStructure = {
id: 'unique-id', // 唯一标识,必须
label: '显示文本', // 显示文本,必须
children: [], // 子节点数组,可选
disabled: false, // 是否禁用,可选
expanded: false, // 是否展开,可选
checked: false, // 是否选中,可选
icon: 'icon-name', // 图标名称,可选
class: 'custom-class', // 自定义CSS类,可选
// 其他自定义属性...
}
2. 性能优化建议
// 1. 使用 nodeKey 确保唯一性
<Tree :data="data" node-key="id" />
// 2. 大数据量时启用虚拟滚动
<Tree :data="bigData" :virtual-scroll="true" />
// 3. 懒加载大数据集
<Tree :data="data" :lazy="true" :load="loadFunction" />
// 4. 搜索防抖
const debouncedFilter = debounce((value) => {
// 过滤逻辑
}, 300)
3. 事件处理模式
// 统一的事件处理模式
const treeEventHandlers = {
onNodeClick: (node, event) => {
console.log('Node clicked:', node)
},
onCheckChange: (node, checked, checkedKeys) => {
// 处理选中状态变化
updateCheckedStatus(checkedKeys)
},
onNodeDrop: (dragNode, dropNode, position) => {
// 处理拖拽结果
updateTreeStructure(dragNode, dropNode, position)
}
}
4. 状态管理
// 使用 Pinia 或 Vuex 管理树状态
import { defineStore } from 'pinia'
export const useTreeStore = defineStore('tree', {
state: () => ({
treeData: [],
selectedNode: null,
checkedNodes: [],
expandedNodes: []
}),
actions: {
updateTreeData(data) {
this.treeData = data
},
selectNode(node) {
this.selectedNode = node
},
toggleNodeExpansion(nodeId) {
const index = this.expandedNodes.indexOf(nodeId)
if (index > -1) {
this.expandedNodes.splice(index, 1)
} else {
this.expandedNodes.push(nodeId)
}
}
}
})
❓ 常见问题
Q1: 如何处理大数据量的性能问题?
A: 对于大数据量(>1000个节点),建议:
- 启用虚拟滚动:
<Tree :virtual-scroll="true" />
- 使用懒加载:
<Tree :lazy="true" :load="loadFunction" />
- 避免默认展开所有节点
- 使用搜索功能限制显示数量
Q2: 如何自定义节点样式?
A: 可以通过以下方式自定义:
- 使用
default
插槽自定义节点内容 - 使用
icon
插槽自定义图标 - 通过 CSS 变量修改主题色彩
- 添加自定义 CSS 类
Q3: 拖拽功能不工作怎么办?
A: 检查以下几点:
- 确保设置了
draggable="true"
- 检查节点是否被设置为
disabled
- 确保浏览器支持拖拽 API
- 检查是否有其他事件阻止了拖拽
Q4: 复选框状态不正确怎么办?
A: 可能的原因:
- 检查
check-strictly
属性设置 - 确保
nodeKey
属性正确设置 - 检查数据结构是否正确
- 使用
setCheckedKeys
方法手动设置
Q5: 如何实现右键菜单?
A: 使用 context-menu
插槽:
<Tree @node-context-menu="handleContextMenu">
<template #context-menu="{ node }">
<div class="context-menu">
<div @click="editNode(node)">编辑</div>
<div @click="deleteNode(node)">删除</div>
</div>
</template>
</Tree>
Q6: 如何实现节点搜索高亮?
A: 自定义过滤方法和节点渲染:
<Tree
:filter-node-method="filterNode"
:filterable="true"
>
<template #default="{ node }">
<span v-html="highlightText(node.label, searchText)"></span>
</template>
</Tree>
🤝 贡献指南
欢迎贡献代码!请按照以下步骤:
- Fork 项目
- 创建特性分支:
git checkout -b feature/new-feature
- 提交更改:
git commit -am 'Add new feature'
- 推送分支:
git push origin feature/new-feature
- 提交 Pull Request
📄 许可证
MIT License
🙏 致谢
感谢所有贡献者和使用者的支持!
gitee.com/xie-yaozu/t… 如果你觉得这个组件有用,请给个 ⭐ Star 支持一下!