Tree 树形控件
概述
Tree 树形控件用于展示具有层级关系的数据结构,如文件目录、组织架构、分类菜单等。Element Plus 的 Tree 组件提供了丰富的功能,包括节点选择、展开折叠、拖拽排序、懒加载等,能够满足各种复杂的业务需求。
学习目标
通过本文档的学习,你将掌握:
- Tree 组件的基础用法和数据结构
- 树形节点的各种操作方式
- 树形选择功能的实现
- 树形拖拽排序的应用
- 树形懒加载的性能优化
- 自定义节点内容的渲染
- 树形搜索过滤功能
- 树形组件的性能优化技巧
基础用法
基础树形结构
最简单的树形展示:
vue
<template>
<div class="basic-tree-demo">
<h4>基础树形结构</h4>
<el-tree
:data="basicTreeData"
:props="defaultProps"
@node-click="handleNodeClick"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const basicTreeData = ref([
{
id: 1,
label: '一级 1',
children: [
{
id: 4,
label: '二级 1-1',
children: [
{
id: 9,
label: '三级 1-1-1'
},
{
id: 10,
label: '三级 1-1-2'
}
]
}
]
},
{
id: 2,
label: '一级 2',
children: [
{
id: 5,
label: '二级 2-1'
},
{
id: 6,
label: '二级 2-2'
}
]
},
{
id: 3,
label: '一级 3',
children: [
{
id: 7,
label: '二级 3-1'
},
{
id: 8,
label: '二级 3-2'
}
]
}
])
const defaultProps = {
children: 'children',
label: 'label'
}
const handleNodeClick = (data) => {
ElMessage.info(`点击了节点:${data.label}`)
}
</script>
<style scoped>
.basic-tree-demo {
padding: 20px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.basic-tree-demo h4 {
margin: 0 0 15px 0;
color: #303133;
}
</style>
可选择树形
带有复选框的树形结构:
vue
<template>
<div class="selectable-tree-demo">
<div class="demo-section">
<h4>可选择树形(复选框)</h4>
<el-tree
ref="treeRef"
:data="selectableTreeData"
:props="defaultProps"
show-checkbox
node-key="id"
:default-expanded-keys="[2, 3]"
:default-checked-keys="[5]"
@check-change="handleCheckChange"
/>
<div class="tree-actions">
<el-button @click="getCheckedNodes">获取选中节点</el-button>
<el-button @click="getCheckedKeys">获取选中键值</el-button>
<el-button @click="setCheckedNodes">设置选中节点</el-button>
<el-button @click="resetChecked">重置选择</el-button>
</div>
</div>
<div class="demo-section">
<h4>单选树形</h4>
<el-tree
:data="selectableTreeData"
:props="defaultProps"
:highlight-current="true"
node-key="id"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const treeRef = ref()
const selectableTreeData = ref([
{
id: 1,
label: '一级 1',
children: [
{
id: 4,
label: '二级 1-1',
children: [
{
id: 9,
label: '三级 1-1-1'
},
{
id: 10,
label: '三级 1-1-2'
}
]
}
]
},
{
id: 2,
label: '一级 2',
children: [
{
id: 5,
label: '二级 2-1'
},
{
id: 6,
label: '二级 2-2'
}
]
},
{
id: 3,
label: '一级 3',
children: [
{
id: 7,
label: '二级 3-1'
},
{
id: 8,
label: '二级 3-2'
}
]
}
])
const defaultProps = {
children: 'children',
label: 'label'
}
const handleCheckChange = (data, checked, indeterminate) => {
console.log('节点选择变化:', data, checked, indeterminate)
}
const handleCurrentChange = (data, node) => {
ElMessage.info(`当前选中:${data ? data.label : '无'}`)
}
const getCheckedNodes = () => {
const checkedNodes = treeRef.value.getCheckedNodes()
ElMessage.info(`选中节点数量:${checkedNodes.length}`)
console.log('选中的节点:', checkedNodes)
}
const getCheckedKeys = () => {
const checkedKeys = treeRef.value.getCheckedKeys()
ElMessage.info(`选中键值:${checkedKeys.join(', ')}`)
}
const setCheckedNodes = () => {
treeRef.value.setCheckedKeys([1, 4, 9])
ElMessage.success('已设置选中节点')
}
const resetChecked = () => {
treeRef.value.setCheckedKeys([])
ElMessage.success('已重置选择')
}
</script>
<style scoped>
.selectable-tree-demo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
padding: 20px;
}
.demo-section {
padding: 20px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.demo-section h4 {
margin: 0 0 15px 0;
color: #303133;
}
.tree-actions {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
</style>
自定义节点内容
使用插槽自定义节点的显示内容:
vue
<template>
<div class="custom-tree-demo">
<h4>自定义节点内容</h4>
<el-tree
:data="customTreeData"
:props="defaultProps"
node-key="id"
:default-expanded-keys="[1, 2]"
>
<template #default="{ node, data }">
<div class="custom-tree-node">
<div class="node-content">
<el-icon class="node-icon">
<component :is="getNodeIcon(data)" />
</el-icon>
<span class="node-label">{{ node.label }}</span>
<el-tag v-if="data.type" :type="getTagType(data.type)" size="small">
{{ data.type }}
</el-tag>
</div>
<div class="node-actions">
<el-button
size="small"
type="primary"
@click="() => append(data)"
>
添加
</el-button>
<el-button
size="small"
@click="() => edit(data)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="() => remove(node, data)"
>
删除
</el-button>
</div>
</div>
</template>
</el-tree>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Folder, Document, Picture, VideoPlay } from '@element-plus/icons-vue'
let id = 1000
const customTreeData = ref([
{
id: 1,
label: '项目文件夹',
type: 'folder',
children: [
{
id: 4,
label: 'src',
type: 'folder',
children: [
{
id: 9,
label: 'main.js',
type: 'file'
},
{
id: 10,
label: 'App.vue',
type: 'file'
}
]
},
{
id: 5,
label: 'public',
type: 'folder',
children: [
{
id: 11,
label: 'index.html',
type: 'file'
},
{
id: 12,
label: 'favicon.ico',
type: 'image'
}
]
}
]
},
{
id: 2,
label: '媒体文件',
type: 'folder',
children: [
{
id: 6,
label: 'images',
type: 'folder',
children: [
{
id: 13,
label: 'logo.png',
type: 'image'
}
]
},
{
id: 7,
label: 'videos',
type: 'folder',
children: [
{
id: 14,
label: 'intro.mp4',
type: 'video'
}
]
}
]
}
])
const defaultProps = {
children: 'children',
label: 'label'
}
const getNodeIcon = (data) => {
switch (data.type) {
case 'folder':
return Folder
case 'image':
return Picture
case 'video':
return VideoPlay
default:
return Document
}
}
const getTagType = (type) => {
switch (type) {
case 'folder':
return 'primary'
case 'file':
return 'success'
case 'image':
return 'warning'
case 'video':
return 'danger'
default:
return 'info'
}
}
const append = async (data) => {
try {
const { value: name } = await ElMessageBox.prompt('请输入节点名称', '添加节点', {
confirmButtonText: '确定',
cancelButtonText: '取消'
})
const newChild = {
id: id++,
label: name,
type: data.type === 'folder' ? 'file' : 'folder',
children: []
}
if (!data.children) {
data.children = []
}
data.children.push(newChild)
ElMessage.success('添加成功')
} catch {
ElMessage.info('已取消添加')
}
}
const edit = async (data) => {
try {
const { value: name } = await ElMessageBox.prompt('请输入新名称', '编辑节点', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: data.label
})
data.label = name
ElMessage.success('编辑成功')
} catch {
ElMessage.info('已取消编辑')
}
}
const remove = async (node, data) => {
try {
await ElMessageBox.confirm(
`确定要删除节点 "${data.label}" 吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const parent = node.parent
const children = parent.data.children || parent.data
const index = children.findIndex(d => d.id === data.id)
children.splice(index, 1)
ElMessage.success('删除成功')
} catch {
ElMessage.info('已取消删除')
}
}
</script>
<style scoped>
.custom-tree-demo {
padding: 20px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.custom-tree-demo h4 {
margin: 0 0 15px 0;
color: #303133;
}
.custom-tree-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0;
}
.node-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.node-icon {
color: #606266;
}
.node-label {
font-size: 14px;
color: #303133;
}
.node-actions {
display: flex;
gap: 5px;
opacity: 0;
transition: opacity 0.3s;
}
.custom-tree-node:hover .node-actions {
opacity: 1;
}
</style>
高级功能
树形拖拽排序
支持节点拖拽重新排序:
vue
<template>
<div class="draggable-tree-demo">
<h4>可拖拽树形</h4>
<div class="demo-controls">
<el-switch
v-model="draggable"
active-text="启用拖拽"
inactive-text="禁用拖拽"
/>
</div>
<el-tree
:data="draggableTreeData"
:props="defaultProps"
:draggable="draggable"
:allow-drop="allowDrop"
:allow-drag="allowDrag"
node-key="id"
@node-drop="handleDrop"
>
<template #default="{ node, data }">
<div class="draggable-node">
<el-icon class="drag-handle">
<component :is="getDragIcon(data)" />
</el-icon>
<span>{{ node.label }}</span>
<el-tag v-if="data.level" size="small" type="info">
Level {{ data.level }}
</el-tag>
</div>
</template>
</el-tree>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Folder, Document, Menu } from '@element-plus/icons-vue'
const draggable = ref(true)
const draggableTreeData = ref([
{
id: 1,
label: '一级 1',
level: 1,
type: 'folder',
children: [
{
id: 4,
label: '二级 1-1',
level: 2,
type: 'folder',
children: [
{
id: 9,
label: '三级 1-1-1',
level: 3,
type: 'file'
},
{
id: 10,
label: '三级 1-1-2',
level: 3,
type: 'file'
}
]
}
]
},
{
id: 2,
label: '一级 2',
level: 1,
type: 'folder',
children: [
{
id: 5,
label: '二级 2-1',
level: 2,
type: 'file'
},
{
id: 6,
label: '二级 2-2',
level: 2,
type: 'file'
}
]
},
{
id: 3,
label: '一级 3',
level: 1,
type: 'folder',
children: [
{
id: 7,
label: '二级 3-1',
level: 2,
type: 'file'
},
{
id: 8,
label: '二级 3-2',
level: 2,
type: 'file'
}
]
}
])
const defaultProps = {
children: 'children',
label: 'label'
}
const getDragIcon = (data) => {
if (data.type === 'folder') {
return Folder
} else if (data.type === 'file') {
return Document
}
return Menu
}
const allowDrop = (draggingNode, dropNode, type) => {
// 不允许拖拽到根节点之外
if (dropNode.data.level === 1 && type === 'prev') {
return false
}
// 文件不能包含子节点
if (dropNode.data.type === 'file' && type === 'inner') {
return false
}
return true
}
const allowDrag = (draggingNode) => {
// 可以根据需要限制某些节点不能拖拽
return draggingNode.data.label.indexOf('三级') === -1
}
const handleDrop = (draggingNode, dropNode, dropType, ev) => {
ElMessage.success(`拖拽成功:${draggingNode.data.label} -> ${dropNode.data.label} (${dropType})`)
console.log('拖拽详情:', {
dragging: draggingNode.data,
drop: dropNode.data,
type: dropType
})
}
</script>
<style scoped>
.draggable-tree-demo {
padding: 20px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.draggable-tree-demo h4 {
margin: 0 0 15px 0;
color: #303133;
}
.demo-controls {
margin-bottom: 20px;
}
.draggable-node {
display: flex;
align-items: center;
gap: 8px;
}
.drag-handle {
color: #909399;
cursor: move;
}
</style>
树形懒加载
大数据量时使用懒加载提升性能:
vue
<template>
<div class="lazy-tree-demo">
<h4>懒加载树形</h4>
<div class="demo-info">
<p>节点数据将在展开时动态加载</p>
<el-button @click="refreshTree">刷新树形</el-button>
</div>
<el-tree
:props="lazyProps"
:load="loadNode"
lazy
show-checkbox
node-key="id"
@check-change="handleLazyCheckChange"
>
<template #default="{ node, data }">
<div class="lazy-node">
<el-icon>
<component :is="getLazyNodeIcon(data)" />
</el-icon>
<span>{{ node.label }}</span>
<el-tag v-if="node.loading" size="small" type="warning">加载中...</el-tag>
<el-tag v-else-if="data.children && data.children.length" size="small" type="success">
{{ data.children.length }} 项
</el-tag>
</div>
</template>
</el-tree>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Folder, Document, Loading } from '@element-plus/icons-vue'
const lazyProps = {
label: 'name',
children: 'zones',
isLeaf: 'leaf'
}
const getLazyNodeIcon = (data) => {
if (data.loading) {
return Loading
}
return data.leaf ? Document : Folder
}
const loadNode = (node, resolve) => {
if (node.level === 0) {
// 根节点
setTimeout(() => {
resolve([
{
id: 'region1',
name: '华北地区',
leaf: false
},
{
id: 'region2',
name: '华东地区',
leaf: false
},
{
id: 'region3',
name: '华南地区',
leaf: false
}
])
}, 500)
return
}
if (node.level > 3) {
// 超过3级的节点设为叶子节点
resolve([])
return
}
// 模拟异步加载
setTimeout(() => {
const data = []
for (let i = 1; i <= Math.floor(Math.random() * 5) + 1; i++) {
data.push({
id: `${node.data.id}-${i}`,
name: `${node.data.name}-子节点${i}`,
leaf: node.level >= 2 // 第3级设为叶子节点
})
}
resolve(data)
}, Math.random() * 1000 + 500) // 随机延迟
}
const handleLazyCheckChange = (data, checked, indeterminate) => {
console.log('懒加载节点选择变化:', data, checked, indeterminate)
}
const refreshTree = () => {
ElMessage.info('树形组件已刷新')
// 在实际应用中,这里可以重新加载根节点数据
}
</script>
<style scoped>
.lazy-tree-demo {
padding: 20px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.lazy-tree-demo h4 {
margin: 0 0 15px 0;
color: #303133;
}
.demo-info {
margin-bottom: 20px;
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
}
.demo-info p {
margin: 0 0 10px 0;
color: #606266;
font-size: 14px;
}
.lazy-node {
display: flex;
align-items: center;
gap: 8px;
}
</style>
树形搜索过滤
实现树形数据的搜索和过滤功能:
vue
<template>
<div class="filterable-tree-demo">
<h4>可搜索树形</h4>
<div class="search-controls">
<el-input
v-model="filterText"
placeholder="输入关键字进行过滤"
clearable
@input="handleFilterChange"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="filter-options">
<el-checkbox v-model="highlightMatch">高亮匹配</el-checkbox>
<el-checkbox v-model="expandOnFilter">过滤时展开</el-checkbox>
</div>
</div>
<el-tree
ref="filterTreeRef"
:data="filterableTreeData"
:props="defaultProps"
:filter-node-method="filterNode"
node-key="id"
:default-expand-all="expandOnFilter && filterText"
>
<template #default="{ node, data }">
<div class="filterable-node">
<el-icon>
<component :is="getFilterNodeIcon(data)" />
</el-icon>
<span
class="node-text"
:class="{ 'highlight': highlightMatch && isMatched(data.label) }"
v-html="getHighlightedText(data.label)"
></span>
<el-tag v-if="data.category" size="small" :type="getCategoryType(data.category)">
{{ data.category }}
</el-tag>
</div>
</template>
</el-tree>
<div v-if="filterText && !hasMatchedNodes" class="no-results">
<el-empty description="没有找到匹配的节点" :image-size="60" />
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { Search, Folder, Document, User, Setting } from '@element-plus/icons-vue'
const filterTreeRef = ref()
const filterText = ref('')
const highlightMatch = ref(true)
const expandOnFilter = ref(true)
const hasMatchedNodes = ref(true)
const filterableTreeData = ref([
{
id: 1,
label: '系统管理',
category: 'system',
children: [
{
id: 4,
label: '用户管理',
category: 'user',
children: [
{
id: 9,
label: '用户列表',
category: 'page'
},
{
id: 10,
label: '角色管理',
category: 'page'
},
{
id: 11,
label: '权限设置',
category: 'page'
}
]
},
{
id: 5,
label: '系统设置',
category: 'setting',
children: [
{
id: 12,
label: '基础配置',
category: 'page'
},
{
id: 13,
label: '安全设置',
category: 'page'
}
]
}
]
},
{
id: 2,
label: '内容管理',
category: 'content',
children: [
{
id: 6,
label: '文章管理',
category: 'article',
children: [
{
id: 14,
label: '文章列表',
category: 'page'
},
{
id: 15,
label: '分类管理',
category: 'page'
}
]
},
{
id: 7,
label: '媒体库',
category: 'media',
children: [
{
id: 16,
label: '图片管理',
category: 'page'
},
{
id: 17,
label: '视频管理',
category: 'page'
}
]
}
]
},
{
id: 3,
label: '数据统计',
category: 'analytics',
children: [
{
id: 8,
label: '访问统计',
category: 'stats'
},
{
id: 18,
label: '用户行为分析',
category: 'stats'
}
]
}
])
const defaultProps = {
children: 'children',
label: 'label'
}
const getFilterNodeIcon = (data) => {
switch (data.category) {
case 'system':
case 'setting':
return Setting
case 'user':
return User
case 'content':
case 'article':
case 'media':
return Folder
default:
return Document
}
}
const getCategoryType = (category) => {
const typeMap = {
system: 'danger',
user: 'primary',
setting: 'warning',
content: 'success',
article: 'info',
media: 'warning',
analytics: 'primary',
stats: 'success',
page: 'info'
}
return typeMap[category] || 'info'
}
const filterNode = (value, data) => {
if (!value) return true
return data.label.toLowerCase().includes(value.toLowerCase())
}
const isMatched = (text) => {
if (!filterText.value) return false
return text.toLowerCase().includes(filterText.value.toLowerCase())
}
const getHighlightedText = (text) => {
if (!highlightMatch.value || !filterText.value) {
return text
}
const regex = new RegExp(`(${filterText.value})`, 'gi')
return text.replace(regex, '<mark>$1</mark>')
}
const handleFilterChange = () => {
// 检查是否有匹配的节点
checkMatchedNodes()
}
const checkMatchedNodes = () => {
if (!filterText.value) {
hasMatchedNodes.value = true
return
}
const checkNode = (nodes) => {
for (const node of nodes) {
if (filterNode(filterText.value, node)) {
return true
}
if (node.children && checkNode(node.children)) {
return true
}
}
return false
}
hasMatchedNodes.value = checkNode(filterableTreeData.value)
}
watch(filterText, (val) => {
filterTreeRef.value?.filter(val)
if (expandOnFilter.value && val) {
nextTick(() => {
// 展开所有匹配的节点
const expandMatchedNodes = (nodes) => {
nodes.forEach(node => {
if (node.children) {
const hasMatchedChild = node.children.some(child =>
filterNode(val, child) || (child.children && hasMatchedChild)
)
if (hasMatchedChild) {
filterTreeRef.value?.getNode(node.id)?.expand()
}
expandMatchedNodes(node.children)
}
})
}
expandMatchedNodes(filterableTreeData.value)
})
}
})
</script>
<style scoped>
.filterable-tree-demo {
padding: 20px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.filterable-tree-demo h4 {
margin: 0 0 15px 0;
color: #303133;
}
.search-controls {
margin-bottom: 20px;
}
.filter-options {
margin-top: 10px;
display: flex;
gap: 15px;
}
.filterable-node {
display: flex;
align-items: center;
gap: 8px;
}
.node-text {
flex: 1;
}
.node-text.highlight {
font-weight: 500;
}
.node-text :deep(mark) {
background-color: #f56c6c;
color: white;
padding: 1px 2px;
border-radius: 2px;
}
.no-results {
margin-top: 20px;
text-align: center;
}
</style>
实际应用示例
组织架构树
企业组织架构管理系统:
vue
<template>
<div class="organization-tree-demo">
<div class="demo-header">
<h3>组织架构管理</h3>
<div class="header-actions">
<el-button type="primary" @click="addDepartment">
<el-icon><Plus /></el-icon>
添加部门
</el-button>
<el-button @click="expandAll">展开全部</el-button>
<el-button @click="collapseAll">收起全部</el-button>
</div>
</div>
<div class="tree-container">
<el-tree
ref="orgTreeRef"
:data="organizationData"
:props="orgProps"
node-key="id"
:default-expanded-keys="[1]"
draggable
:allow-drop="allowOrgDrop"
@node-drop="handleOrgDrop"
>
<template #default="{ node, data }">
<div class="org-node">
<div class="node-info">
<el-avatar
:size="24"
:src="data.avatar"
:icon="data.type === 'department' ? OfficeBuilding : User"
/>
<div class="node-details">
<div class="node-name">{{ data.name }}</div>
<div class="node-meta">
<span class="position">{{ data.position || data.type }}</span>
<span v-if="data.employeeCount" class="count">
{{ data.employeeCount }}人
</span>
</div>
</div>
</div>
<div class="node-actions">
<el-dropdown @command="handleOrgCommand">
<el-button size="small" type="text">
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="{ action: 'view', data }">查看详情</el-dropdown-item>
<el-dropdown-item :command="{ action: 'edit', data }">编辑</el-dropdown-item>
<el-dropdown-item v-if="data.type === 'department'" :command="{ action: 'addChild', data }">
添加子部门
</el-dropdown-item>
<el-dropdown-item :command="{ action: 'addEmployee', data }">添加员工</el-dropdown-item>
<el-dropdown-item divided :command="{ action: 'delete', data }" class="danger">
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
</el-tree>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="详细信息" width="500px">
<div v-if="selectedNode" class="detail-content">
<div class="detail-header">
<el-avatar :size="60" :src="selectedNode.avatar" />
<div class="detail-info">
<h4>{{ selectedNode.name }}</h4>
<p>{{ selectedNode.position || selectedNode.type }}</p>
</div>
</div>
<el-descriptions :column="1" border>
<el-descriptions-item label="类型">
{{ selectedNode.type === 'department' ? '部门' : '员工' }}
</el-descriptions-item>
<el-descriptions-item v-if="selectedNode.email" label="邮箱">
{{ selectedNode.email }}
</el-descriptions-item>
<el-descriptions-item v-if="selectedNode.phone" label="电话">
{{ selectedNode.phone }}
</el-descriptions-item>
<el-descriptions-item v-if="selectedNode.employeeCount" label="员工数量">
{{ selectedNode.employeeCount }}人
</el-descriptions-item>
<el-descriptions-item v-if="selectedNode.description" label="描述">
{{ selectedNode.description }}
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, OfficeBuilding, User, MoreFilled } from '@element-plus/icons-vue'
const orgTreeRef = ref()
const detailDialogVisible = ref(false)
const selectedNode = ref(null)
const organizationData = ref([
{
id: 1,
name: '科技有限公司',
type: 'department',
position: '总公司',
employeeCount: 156,
description: '一家专注于技术创新的公司',
children: [
{
id: 2,
name: '技术部',
type: 'department',
position: '技术部门',
employeeCount: 45,
children: [
{
id: 5,
name: '前端组',
type: 'department',
position: '前端开发组',
employeeCount: 15,
children: [
{
id: 9,
name: '张三',
type: 'employee',
position: '前端工程师',
email: 'zhangsan@company.com',
phone: '138****1234',
avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
},
{
id: 10,
name: '李四',
type: 'employee',
position: '高级前端工程师',
email: 'lisi@company.com',
phone: '139****5678'
}
]
},
{
id: 6,
name: '后端组',
type: 'department',
position: '后端开发组',
employeeCount: 20,
children: [
{
id: 11,
name: '王五',
type: 'employee',
position: 'Java工程师',
email: 'wangwu@company.com',
phone: '137****9012'
}
]
}
]
},
{
id: 3,
name: '市场部',
type: 'department',
position: '市场营销部',
employeeCount: 25,
children: [
{
id: 7,
name: '赵六',
type: 'employee',
position: '市场经理',
email: 'zhaoliu@company.com',
phone: '136****3456'
}
]
},
{
id: 4,
name: '人事部',
type: 'department',
position: '人力资源部',
employeeCount: 12,
children: [
{
id: 8,
name: '孙七',
type: 'employee',
position: 'HR专员',
email: 'sunqi@company.com',
phone: '135****7890'
}
]
}
]
}
])
const orgProps = {
children: 'children',
label: 'name'
}
const allowOrgDrop = (draggingNode, dropNode, type) => {
// 员工不能包含子节点
if (dropNode.data.type === 'employee' && type === 'inner') {
return false
}
// 不允许将部门拖拽到员工下面
if (draggingNode.data.type === 'department' && dropNode.data.type === 'employee') {
return false
}
return true
}
const handleOrgDrop = (draggingNode, dropNode, dropType) => {
ElMessage.success(`组织架构调整成功:${draggingNode.data.name} -> ${dropNode.data.name}`)
// 这里可以调用API更新组织架构
}
const handleOrgCommand = ({ action, data }) => {
switch (action) {
case 'view':
selectedNode.value = data
detailDialogVisible.value = true
break
case 'edit':
ElMessage.info(`编辑:${data.name}`)
break
case 'addChild':
ElMessage.info(`为 ${data.name} 添加子部门`)
break
case 'addEmployee':
ElMessage.info(`为 ${data.name} 添加员工`)
break
case 'delete':
handleDelete(data)
break
}
}
const handleDelete = async (data) => {
try {
await ElMessageBox.confirm(
`确定要删除 "${data.name}" 吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
ElMessage.success('删除成功')
} catch {
ElMessage.info('已取消删除')
}
}
const addDepartment = () => {
ElMessage.info('添加新部门')
}
const expandAll = () => {
// 展开所有节点
const expandNode = (nodes) => {
nodes.forEach(node => {
orgTreeRef.value?.getNode(node.id)?.expand()
if (node.children) {
expandNode(node.children)
}
})
}
expandNode(organizationData.value)
}
const collapseAll = () => {
// 收起所有节点
const collapseNode = (nodes) => {
nodes.forEach(node => {
if (node.children) {
collapseNode(node.children)
orgTreeRef.value?.getNode(node.id)?.collapse()
}
})
}
collapseNode(organizationData.value)
}
</script>
<style scoped>
.organization-tree-demo {
padding: 20px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: white;
}
.demo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #ebeef5;
}
.demo-header h3 {
margin: 0;
color: #303133;
}
.header-actions {
display: flex;
gap: 10px;
}
.tree-container {
max-height: 600px;
overflow-y: auto;
}
.org-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 0;
}
.node-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.node-details {
flex: 1;
}
.node-name {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 2px;
}
.node-meta {
display: flex;
gap: 10px;
font-size: 12px;
color: #909399;
}
.position {
color: #606266;
}
.count {
color: #409eff;
}
.node-actions {
opacity: 0;
transition: opacity 0.3s;
}
.org-node:hover .node-actions {
opacity: 1;
}
.detail-content {
padding: 10px 0;
}
.detail-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #ebeef5;
}
.detail-info h4 {
margin: 0 0 5px 0;
color: #303133;
}
.detail-info p {
margin: 0;
color: #606266;
font-size: 14px;
}
:deep(.el-dropdown-menu__item.danger) {
color: #f56c6c;
}
</style>
API 文档
Tree Attributes
参数 | 说明 | 类型 | 可选值 | 默认值 |
---|---|---|---|---|
data | 展示数据 | array | — | — |
empty-text | 内容为空的时候展示的文本 | string | — | — |
node-key | 每个树节点用来作为唯一标识的属性 | string | — | — |
props | 配置选项 | object | — | — |
render-after-expand | 是否在第一次展开某个树节点后才渲染其子节点 | boolean | — | true |
load | 加载子树数据的方法,仅当 lazy 属性为true 时生效 | function(node, resolve) | — | — |
render-content | 树节点的内容区的渲染 Function | Function(h, { node, data, store }) | — | — |
highlight-current | 是否高亮当前选中节点 | boolean | — | false |
default-expand-all | 是否默认展开所有节点 | boolean | — | false |
expand-on-click-node | 是否在点击节点的时候展开或者收缩节点 | boolean | — | true |
check-on-click-node | 是否在点击节点的时候选中节点 | boolean | — | false |
auto-expand-parent | 展开子节点的时候是否自动展开父节点 | boolean | — | true |
default-expanded-keys | 默认展开的节点的 key 的数组 | array | — | — |
show-checkbox | 节点是否可被选择 | boolean | — | false |
check-strictly | 在显示复选框的情况下,是否严格的遵循父子不互相关联的做法 | boolean | — | false |
default-checked-keys | 默认勾选的节点的 key 的数组 | array | — | — |
current-node-key | 当前选中的节点 | string, number | — | — |
filter-node-method | 对树节点进行筛选时执行的方法 | Function(value, data, node) | — | — |
accordion | 是否每次只打开一个同级树节点展开 | boolean | — | false |
indent | 相邻级节点间的水平缩进,单位为像素 | number | — | 18 |
icon | 自定义树节点的图标 | string / Component | — | — |
lazy | 是否懒加载子节点,需与 load 方法结合使用 | boolean | — | false |
draggable | 是否开启拖拽节点功能 | boolean | — | false |
allow-drag | 判断节点能否被拖拽 | Function(node) | — | — |
allow-drop | 拖拽时判定目标节点能否被放置 | Function(draggingNode, dropNode, type) | — | — |
Tree Events
事件名 | 说明 | 回调参数 |
---|---|---|
node-click | 节点被点击时的回调 | 共三个参数,依次为:传递给 data 属性的数组中该节点所对应的对象、节点对应的 Node、节点组件本身 |
node-contextmenu | 当某一节点被鼠标右键点击时会触发该事件 | 共四个参数,依次为:event、传递给 data 属性的数组中该节点所对应的对象、节点对应的 Node、节点组件本身 |
check-change | 节点选中状态发生变化时的回调 | 共三个参数,依次为:传递给 data 属性的数组中该节点所对应的对象、节点本身是否被选中、节点的子树中是否有被选中的节点 |
check | 当复选框被点击的时候触发 | 共两个参数,依次为:传递给 data 属性的数组中该节点所对应的对象、树目前的选中状态对象 |
current-change | 当前选中节点变化时触发的事件 | 共两个参数,依次为:当前节点的数据,当前节点的 Node 对象 |
node-expand | 节点被展开时触发的事件 | 共三个参数,依次为:传递给 data 属性的数组中该节点所对应的对象、节点对应的 Node、节点组件本身 |
node-collapse | 节点被关闭时触发的事件 | 共三个参数,依次为:传递给 data 属性的数组中该节点所对应的对象、节点对应的 Node、节点组件本身 |
node-drag-start | 节点开始拖拽时触发的事件 | 共两个参数,依次为:被拖拽节点对应的 Node、event |
node-drag-enter | 拖拽进入其他节点时触发的事件 | 共三个参数,依次为:被拖拽节点对应的 Node、所进入节点对应的 Node、event |
node-drag-leave | 拖拽离开某个节点时触发的事件 | 共三个参数,依次为:被拖拽节点对应的 Node、所离开节点对应的 Node、event |
node-drag-over | 在拖拽节点时触发的事件(类似浏览器的 mouseover 事件) | 共三个参数,依次为:被拖拽节点对应的 Node、当前进入节点对应的 Node、event |
node-drag-end | 拖拽结束时(可能未成功)触发的事件 | 共四个参数,依次为:被拖拽节点对应的 Node、结束拖拽时最后进入的节点(可能为空)、被拖拽节点的放置位置(before、after、inner)、event |
node-drop | 拖拽成功完成时触发的事件 | 共四个参数,依次为:被拖拽节点对应的 Node、结束拖拽时最后进入的节点、被拖拽节点的放置位置(before、after、inner)、event |
Tree Methods
方法名 | 说明 | 参数 |
---|---|---|
filter | 对树节点进行筛选操作 | 接收一个任意类型的参数,该参数会在 filter-node-method 中作为第一个参数 |
updateKeyChildren | 通过 keys 设置节点子元素,使用此方法必须设置 node-key 属性 | (key, data) 接收两个参数,1. 节点 key 2. 节点数据的数组 |
getCheckedNodes | 若节点可被选择(即 show-checkbox 为 true),则返回目前被选中的节点所组成的数组 | (leafOnly, includeHalfChecked) 接收两个 boolean 类型的参数,1. 是否只是叶子节点,默认值为 false 2. 是否包含半选节点,默认值为 false |
setCheckedNodes | 设置目前勾选的节点,使用此方法必须设置 node-key 属性 | (nodes) 接收勾选节点数据的数组 |
getCheckedKeys | 若节点可被选择(即 show-checkbox 为 true),则返回目前被选中的节点的 key 所组成的数组 | (leafOnly) 接收一个 boolean 类型的参数,若为 true 则仅返回被选中的叶子节点的 keys,默认值为 false |
setCheckedKeys | 通过 keys 设置目前勾选的节点,使用此方法必须设置 node-key 属性 | (keys, leafOnly) 接收两个参数,1. 勾选节点的 key 的数组 2. boolean 类型,若为 true 则仅设置叶子节点的选中状态,默认值为 false |
setChecked | 通过 key / data 设置某个节点的勾选状态,使用此方法必须设置 node-key 属性 | (key/data, checked, deep) 接收三个参数,1. 勾选节点的 key 或者 data 2. boolean 类型,节点是否选中 3. boolean 类型,是否设置子节点,默认为 false |
getHalfCheckedNodes | 若节点可被选择(即 show-checkbox 为 true),则返回目前半选中的节点所组成的数组 | — |
getHalfCheckedKeys | 若节点可被选择(即 show-checkbox 为 true),则返回目前半选中的节点的 key 所组成的数组 | — |
getCurrentKey | 获取当前被焦点的节点的 key,使用此方法必须设置 node-key 属性 | — |
getCurrentNode | 获取当前被焦点的节点,使用此方法必须设置 node-key 属性 | — |
setCurrentKey | 通过 key 设置某个节点的当前选中状态,使用此方法必须设置 node-key 属性 | (key) 待被选节点的 key,若为 null 则取消当前高亮的节点 |
setCurrentNode | 通过 node 设置某个节点的当前选中状态,使用此方法必须设置 node-key 属性 | (node) 待被选节点的 node |
getNode | 根据 data 或者 key 拿到 Tree 组件中的 node | (data) 要获得 node 的 key 或者 data |
remove | 删除 Tree 中的一个节点,使用此方法必须设置 node-key 属性 | (data) 要删除的节点的 data 或者 node |
append | 为 Tree 中的一个节点追加一个子节点 | (data, parentNode) 接收两个参数,1. 要追加的子节点的 data 2. 子节点的 parent 的 data、key 或者 node |
insertBefore | 为 Tree 的一个节点的前面增加一个节点 | (data, refNode) 接收两个参数,1. 要增加的节点的 data 2. 要增加的节点的位置的 data、key 或者 node |
insertAfter | 为 Tree 的一个节点的后面增加一个节点 | (data, refNode) 接收两个参数,1. 要增加的节点的 data 2. 要增加的节点的位置的 data、key 或者 node |
Tree Slots
插槽名 | 说明 | 子标签 |
---|---|---|
default | 自定义树节点的内容,参数为 | — |
empty | 内容为空的时候的占位符 | — |
props 配置项
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
label | 指定节点标签为节点对象的某个属性值 | string, function(data, node) | — |
children | 指定子树为节点对象的某个属性值 | string | — |
disabled | 指定节点选择框是否禁用为节点对象的某个属性值 | string, function(data, node) | — |
isLeaf | 指定节点是否为叶子节点,仅在指定了 lazy 属性的情况下生效 | string, function(data, node) | — |
class | 自定义节点类名 | string, function(data, node) | — |
性能优化
虚拟滚动
对于大量数据的树形结构,可以考虑使用虚拟滚动:
vue
<template>
<div class="virtual-tree-demo">
<h4>虚拟滚动树形(大数据量)</h4>
<div class="tree-stats">
<el-tag>总节点数:{{ totalNodes }}</el-tag>
<el-tag type="success">可见节点数:{{ visibleNodes }}</el-tag>
</div>
<div class="virtual-tree-container">
<el-tree
ref="virtualTreeRef"
:data="virtualTreeData"
:props="defaultProps"
node-key="id"
:render-after-expand="false"
:default-expand-all="false"
virtual
:height="400"
:item-size="26"
>
<template #default="{ node, data }">
<div class="virtual-node">
<span>{{ node.label }}</span>
<el-tag v-if="data.level" size="small">
Level {{ data.level }}
</el-tag>
</div>
</template>
</el-tree>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const virtualTreeRef = ref()
const virtualTreeData = ref([])
const totalNodes = computed(() => {
const countNodes = (nodes) => {
let count = 0
nodes.forEach(node => {
count++
if (node.children) {
count += countNodes(node.children)
}
})
return count
}
return countNodes(virtualTreeData.value)
})
const visibleNodes = computed(() => {
// 这里可以根据实际的可见区域计算
return Math.min(totalNodes.value, 15)
})
const defaultProps = {
children: 'children',
label: 'label'
}
// 生成大量测试数据
const generateLargeTreeData = () => {
const data = []
let id = 1
for (let i = 1; i <= 10; i++) {
const level1 = {
id: id++,
label: `一级节点 ${i}`,
level: 1,
children: []
}
for (let j = 1; j <= 20; j++) {
const level2 = {
id: id++,
label: `二级节点 ${i}-${j}`,
level: 2,
children: []
}
for (let k = 1; k <= 10; k++) {
level2.children.push({
id: id++,
label: `三级节点 ${i}-${j}-${k}`,
level: 3
})
}
level1.children.push(level2)
}
data.push(level1)
}
return data
}
onMounted(() => {
virtualTreeData.value = generateLargeTreeData()
})
</script>
<style scoped>
.virtual-tree-demo {
padding: 20px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.virtual-tree-demo h4 {
margin: 0 0 15px 0;
color: #303133;
}
.tree-stats {
margin-bottom: 15px;
display: flex;
gap: 10px;
}
.virtual-tree-container {
border: 1px solid #dcdfe6;
border-radius: 4px;
}
.virtual-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 8px;
}
</style>
懒加载优化
对于动态数据,合理使用懒加载可以显著提升性能:
javascript
// 懒加载最佳实践
const loadNode = async (node, resolve) => {
try {
// 显示加载状态
node.loading = true
// 调用API获取数据
const response = await api.getTreeNodes({
parentId: node.data?.id || null,
level: node.level
})
// 处理数据
const children = response.data.map(item => ({
...item,
leaf: item.type === 'file' // 根据业务逻辑判断是否为叶子节点
}))
resolve(children)
} catch (error) {
console.error('加载节点失败:', error)
resolve([])
} finally {
node.loading = false
}
}
最佳实践
数据结构设计
- 统一的数据格式:
javascript
const treeNodeStructure = {
id: 'unique_id', // 唯一标识
label: 'display_name', // 显示名称
children: [], // 子节点数组
disabled: false, // 是否禁用
leaf: false, // 是否为叶子节点
level: 1, // 节点层级
type: 'folder', // 节点类型
metadata: {} // 额外数据
}
- 性能考虑:
- 避免过深的嵌套层级(建议不超过5层)
- 单个节点的子节点数量控制在合理范围内
- 使用懒加载处理大数据量
用户体验优化
- 交互反馈:
javascript
// 提供清晰的操作反馈
const handleNodeOperation = async (operation, node) => {
const loading = ElLoading.service({
target: '.tree-container',
text: '处理中...'
})
try {
await performOperation(operation, node)
ElMessage.success('操作成功')
} catch (error) {
ElMessage.error('操作失败:' + error.message)
} finally {
loading.close()
}
}
- 键盘导航支持:
javascript
// 支持键盘操作
const handleKeydown = (event) => {
switch (event.key) {
case 'ArrowUp':
case 'ArrowDown':
// 上下箭头导航
break
case 'ArrowLeft':
// 折叠节点
break
case 'ArrowRight':
// 展开节点
break
case 'Enter':
case ' ':
// 选择节点
break
}
}
可访问性
- ARIA 属性:
vue
<el-tree
:data="treeData"
role="tree"
:aria-label="'文件目录树'"
:aria-multiselectable="showCheckbox"
>
<template #default="{ node, data }">
<div
:role="'treeitem'"
:aria-expanded="node.expanded"
:aria-selected="node.checked"
:aria-level="node.level"
>
{{ node.label }}
</div>
</template>
</el-tree>
- 焦点管理:
javascript
// 确保焦点在树形组件内正确移动
const manageFocus = () => {
const currentNode = treeRef.value.getCurrentNode()
if (currentNode) {
// 将焦点设置到当前选中的节点
const nodeElement = treeRef.value.$el.querySelector(`[data-key="${currentNode.key}"]`)
nodeElement?.focus()
}
}
常见问题
1. 数据更新不响应
问题:修改树形数据后,视图没有更新。
解决方案:
javascript
// 错误做法
data.children.push(newNode) // 直接修改可能不会触发响应式更新
// 正确做法
data.children = [...data.children, newNode]
// 或者使用 Vue 3 的响应式 API
import { nextTick } from 'vue'
data.children.push(newNode)
await nextTick() // 等待 DOM 更新
2. 节点选择状态异常
问题:父子节点选择状态不同步。
解决方案:
vue
<el-tree
:check-strictly="false" <!-- 确保父子节点关联 -->
:check-on-click-node="false" <!-- 避免点击节点时意外选择 -->
/>
3. 拖拽功能限制
问题:需要限制某些节点的拖拽行为。
解决方案:
javascript
const allowDrag = (draggingNode) => {
// 根据业务规则限制拖拽
return !draggingNode.data.readonly &&
draggingNode.data.type !== 'system'
}
const allowDrop = (draggingNode, dropNode, type) => {
// 限制放置位置
if (dropNode.data.type === 'file' && type === 'inner') {
return false // 文件不能包含子节点
}
return true
}
总结
Tree 树形控件是 Element Plus 中功能最丰富的组件之一,适用于各种层级数据的展示和操作。通过本文档的学习,你应该能够:
- 掌握 Tree 组件的基础用法和配置
- 实现复杂的树形交互功能
- 优化大数据量下的性能表现
- 提供良好的用户体验和可访问性
- 解决常见的开发问题
在实际项目中,建议根据具体的业务需求选择合适的功能组合,并注意性能优化和用户体验的平衡。