Skip to content

Virtualized Tree 虚拟化树形控件

概述

Virtualized Tree 虚拟化树形控件是 Element Plus 提供的一个高性能树形组件,专门用于处理大量层级数据的展示。它通过虚拟滚动技术,只渲染可视区域内的节点,从而实现在展示数万个节点时仍能保持流畅的性能。

学习目标

通过本文档的学习,你将掌握:

  • Virtualized Tree 的基本概念和性能优势
  • 基础用法和树形结构配置
  • 节点展开、选择、搜索等功能
  • 自定义节点渲染和样式定制
  • 懒加载和异步数据处理
  • 实际项目中的应用示例
  • 完整的 API 文档和性能优化技巧

基础用法

基础虚拟化树

最简单的虚拟化树用法:

vue
<template>
  <el-tree-v2
    :data="data"
    :props="defaultProps"
    :height="400"
  />
</template>

<script setup>
import { ref } from 'vue'

const defaultProps = {
  children: 'children',
  label: 'label'
}

const data = 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'
      }
    ]
  }
])
</script>

可选择的树

支持节点选择功能:

vue
<template>
  <div>
    <div style="margin-bottom: 16px">
      <el-button @click="getCheckedNodes">获取选中节点</el-button>
      <el-button @click="getCheckedKeys">获取选中节点 key</el-button>
      <el-button @click="resetChecked">清空选择</el-button>
    </div>
    
    <el-tree-v2
      ref="treeRef"
      :data="data"
      :props="defaultProps"
      :height="400"
      show-checkbox
      node-key="id"
      :default-checked-keys="[5, 9]"
      @check="handleCheck"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const treeRef = ref()

const defaultProps = {
  children: 'children',
  label: 'label'
}

// 生成大量测试数据
const generateTreeData = (level = 1, parentId = '', count = 100) => {
  const data = []
  for (let i = 1; i <= count; i++) {
    const id = parentId ? `${parentId}-${i}` : `${i}`
    const node = {
      id,
      label: `节点 ${id}`,
      children: level < 3 ? generateTreeData(level + 1, id, Math.floor(count / 2)) : []
    }
    data.push(node)
  }
  return data
}

const data = ref(generateTreeData())

const handleCheck = (data, checked) => {
  console.log('节点选择状态改变:', data, checked)
}

const getCheckedNodes = () => {
  const checkedNodes = treeRef.value.getCheckedNodes()
  ElMessage.success(`选中了 ${checkedNodes.length} 个节点`)
  console.log('选中的节点:', checkedNodes)
}

const getCheckedKeys = () => {
  const checkedKeys = treeRef.value.getCheckedKeys()
  ElMessage.success(`选中了 ${checkedKeys.length} 个节点`)
  console.log('选中的节点 keys:', checkedKeys)
}

const resetChecked = () => {
  treeRef.value.setCheckedKeys([])
  ElMessage.success('已清空选择')
}
</script>

自定义节点内容

通过插槽自定义节点显示内容:

vue
<template>
  <el-tree-v2
    :data="data"
    :props="defaultProps"
    :height="400"
    show-checkbox
    node-key="id"
  >
    <template #default="{ node, data }">
      <div class="custom-tree-node">
        <el-icon v-if="data.type === 'folder'" class="node-icon">
          <Folder />
        </el-icon>
        <el-icon v-else-if="data.type === 'file'" class="node-icon">
          <Document />
        </el-icon>
        <el-icon v-else class="node-icon">
          <Grid />
        </el-icon>
        
        <span class="node-label">{{ data.label }}</span>
        
        <div class="node-actions">
          <el-tag v-if="data.size" size="small" type="info">
            {{ formatSize(data.size) }}
          </el-tag>
          
          <el-button
            size="small"
            type="primary"
            link
            @click.stop="handleEdit(data)"
          >
            编辑
          </el-button>
          
          <el-button
            size="small"
            type="danger"
            link
            @click.stop="handleDelete(data)"
          >
            删除
          </el-button>
        </div>
      </div>
    </template>
  </el-tree-v2>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Folder, Document, Grid } from '@element-plus/icons-vue'

const defaultProps = {
  children: 'children',
  label: 'label'
}

const data = ref([
  {
    id: 1,
    label: '项目根目录',
    type: 'folder',
    children: [
      {
        id: 2,
        label: 'src',
        type: 'folder',
        children: [
          {
            id: 3,
            label: 'components',
            type: 'folder',
            children: [
              {
                id: 4,
                label: 'Header.vue',
                type: 'file',
                size: 2048
              },
              {
                id: 5,
                label: 'Footer.vue',
                type: 'file',
                size: 1536
              }
            ]
          },
          {
            id: 6,
            label: 'views',
            type: 'folder',
            children: [
              {
                id: 7,
                label: 'Home.vue',
                type: 'file',
                size: 4096
              },
              {
                id: 8,
                label: 'About.vue',
                type: 'file',
                size: 2560
              }
            ]
          },
          {
            id: 9,
            label: 'main.js',
            type: 'file',
            size: 1024
          }
        ]
      },
      {
        id: 10,
        label: 'public',
        type: 'folder',
        children: [
          {
            id: 11,
            label: 'index.html',
            type: 'file',
            size: 512
          }
        ]
      },
      {
        id: 12,
        label: 'package.json',
        type: 'file',
        size: 2048
      }
    ]
  }
])

const formatSize = (bytes) => {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}

const handleEdit = (data) => {
  ElMessage.info(`编辑: ${data.label}`)
}

const handleDelete = async (data) => {
  try {
    await ElMessageBox.confirm(
      `确定要删除 "${data.label}" 吗?`,
      '确认删除',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
    ElMessage.success(`已删除: ${data.label}`)
  } catch {
    // 用户取消删除
  }
}
</script>

<style scoped>
.custom-tree-node {
  display: flex;
  align-items: center;
  width: 100%;
  padding: 4px 0;
}

.node-icon {
  margin-right: 8px;
  color: #606266;
}

.node-label {
  flex: 1;
  font-size: 14px;
}

.node-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  opacity: 0;
  transition: opacity 0.2s;
}

.custom-tree-node:hover .node-actions {
  opacity: 1;
}
</style>

懒加载树

支持异步加载子节点:

vue
<template>
  <el-tree-v2
    :data="data"
    :props="defaultProps"
    :height="400"
    lazy
    :load="loadNode"
    show-checkbox
    node-key="id"
  />
</template>

<script setup>
import { ref } from 'vue'

const defaultProps = {
  children: 'children',
  label: 'label',
  isLeaf: 'leaf'
}

const data = ref([
  {
    id: 1,
    label: '根节点 1',
    leaf: false
  },
  {
    id: 2,
    label: '根节点 2',
    leaf: false
  }
])

const loadNode = (node, resolve) => {
  if (node.level === 0) {
    return resolve(data.value)
  }
  
  // 模拟异步加载
  setTimeout(() => {
    const children = []
    const childCount = Math.floor(Math.random() * 5) + 1
    
    for (let i = 1; i <= childCount; i++) {
      const childId = `${node.data.id}-${i}`
      children.push({
        id: childId,
        label: `节点 ${childId}`,
        leaf: node.level >= 3 // 限制层级深度
      })
    }
    
    resolve(children)
  }, 500)
}
</script>

可搜索的树

支持节点搜索过滤:

vue
<template>
  <div>
    <div style="margin-bottom: 16px">
      <el-input
        v-model="filterText"
        placeholder="搜索节点"
        clearable
        @input="handleFilter"
      >
        <template #prefix>
          <el-icon><Search /></el-icon>
        </template>
      </el-input>
    </div>
    
    <el-tree-v2
      ref="treeRef"
      :data="filteredData"
      :props="defaultProps"
      :height="400"
      :filter-node-method="filterNode"
      show-checkbox
      node-key="id"
      :expand-on-click-node="false"
      :default-expand-all="!!filterText"
    />
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'

const treeRef = ref()
const filterText = ref('')

const defaultProps = {
  children: 'children',
  label: 'label'
}

// 生成大量测试数据
const generateTreeData = () => {
  const departments = ['技术部', '销售部', '市场部', '人事部', '财务部']
  const positions = ['经理', '主管', '专员', '助理']
  const data = []
  
  departments.forEach((dept, deptIndex) => {
    const deptNode = {
      id: `dept-${deptIndex}`,
      label: dept,
      children: []
    }
    
    positions.forEach((pos, posIndex) => {
      const posNode = {
        id: `pos-${deptIndex}-${posIndex}`,
        label: pos,
        children: []
      }
      
      // 为每个职位添加员工
      for (let i = 1; i <= 10; i++) {
        posNode.children.push({
          id: `emp-${deptIndex}-${posIndex}-${i}`,
          label: `${pos}${i}`,
          email: `${pos.toLowerCase()}${i}@company.com`,
          phone: `138${String(deptIndex * 100 + posIndex * 10 + i).padStart(8, '0')}`
        })
      }
      
      deptNode.children.push(posNode)
    })
    
    data.push(deptNode)
  })
  
  return data
}

const originalData = ref(generateTreeData())

// 过滤节点
const filterNode = (value, data) => {
  if (!value) return true
  return data.label.toLowerCase().includes(value.toLowerCase())
}

// 递归过滤数据
const filterTreeData = (data, keyword) => {
  if (!keyword) return data
  
  const filtered = []
  
  data.forEach(node => {
    const nodeMatches = node.label.toLowerCase().includes(keyword.toLowerCase())
    let childrenMatches = []
    
    if (node.children && node.children.length > 0) {
      childrenMatches = filterTreeData(node.children, keyword)
    }
    
    if (nodeMatches || childrenMatches.length > 0) {
      filtered.push({
        ...node,
        children: childrenMatches
      })
    }
  })
  
  return filtered
}

const filteredData = computed(() => {
  return filterTreeData(originalData.value, filterText.value)
})

const handleFilter = () => {
  // 搜索时自动展开所有节点
  if (filterText.value && treeRef.value) {
    // 延迟执行以确保数据已更新
    setTimeout(() => {
      // 这里可以添加展开逻辑
    }, 100)
  }
}
</script>

拖拽排序

支持节点拖拽排序:

vue
<template>
  <div>
    <div style="margin-bottom: 16px">
      <el-switch
        v-model="draggable"
        active-text="启用拖拽"
        inactive-text="禁用拖拽"
      />
    </div>
    
    <el-tree-v2
      :data="data"
      :props="defaultProps"
      :height="400"
      :draggable="draggable"
      :allow-drop="allowDrop"
      :allow-drag="allowDrag"
      @node-drop="handleDrop"
      node-key="id"
      show-checkbox
    >
      <template #default="{ node, data }">
        <div class="drag-tree-node">
          <el-icon v-if="data.type === 'folder'" class="node-icon">
            <Folder />
          </el-icon>
          <el-icon v-else class="node-icon">
            <Document />
          </el-icon>
          <span>{{ data.label }}</span>
          <el-tag v-if="data.type" size="small" :type="data.type === 'folder' ? 'warning' : 'info'">
            {{ data.type === 'folder' ? '文件夹' : '文件' }}
          </el-tag>
        </div>
      </template>
    </el-tree-v2>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Folder, Document } from '@element-plus/icons-vue'

const draggable = ref(true)

const defaultProps = {
  children: 'children',
  label: 'label'
}

const data = ref([
  {
    id: 1,
    label: '项目文件夹',
    type: 'folder',
    children: [
      {
        id: 2,
        label: 'src',
        type: 'folder',
        children: [
          {
            id: 3,
            label: 'main.js',
            type: 'file'
          },
          {
            id: 4,
            label: 'App.vue',
            type: 'file'
          }
        ]
      },
      {
        id: 5,
        label: 'public',
        type: 'folder',
        children: [
          {
            id: 6,
            label: 'index.html',
            type: 'file'
          }
        ]
      },
      {
        id: 7,
        label: 'package.json',
        type: 'file'
      }
    ]
  },
  {
    id: 8,
    label: '文档文件夹',
    type: 'folder',
    children: [
      {
        id: 9,
        label: 'README.md',
        type: 'file'
      },
      {
        id: 10,
        label: 'CHANGELOG.md',
        type: 'file'
      }
    ]
  }
])

// 允许拖拽的条件
const allowDrag = (draggingNode) => {
  // 不允许拖拽根节点
  return draggingNode.level > 1
}

// 允许放置的条件
const allowDrop = (draggingNode, dropNode, type) => {
  // 文件不能作为容器
  if (type === 'inner' && dropNode.data.type === 'file') {
    return false
  }
  
  // 不允许拖拽到自己的子节点
  if (type === 'inner') {
    let parent = dropNode.parent
    while (parent) {
      if (parent.data.id === draggingNode.data.id) {
        return false
      }
      parent = parent.parent
    }
  }
  
  return true
}

// 处理拖拽完成
const handleDrop = (draggingNode, dropNode, dropType, ev) => {
  const dragLabel = draggingNode.data.label
  const dropLabel = dropNode.data.label
  
  let message = ''
  if (dropType === 'before') {
    message = `"${dragLabel}" 移动到 "${dropLabel}" 之前`
  } else if (dropType === 'after') {
    message = `"${dragLabel}" 移动到 "${dropLabel}" 之后`
  } else {
    message = `"${dragLabel}" 移动到 "${dropLabel}" 内部`
  }
  
  ElMessage.success(message)
  console.log('拖拽完成:', {
    draggingNode: draggingNode.data,
    dropNode: dropNode.data,
    dropType
  })
}
</script>

<style scoped>
.drag-tree-node {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
}

.node-icon {
  color: #606266;
}
</style>

实际应用示例

组织架构树

vue
<template>
  <div class="org-tree-container">
    <div class="tree-header">
      <h3>组织架构管理</h3>
      <div class="header-actions">
        <el-input
          v-model="searchKeyword"
          placeholder="搜索员工或部门"
          style="width: 200px; margin-right: 10px"
          clearable
          @input="handleSearch"
        >
          <template #prefix>
            <el-icon><Search /></el-icon>
          </template>
        </el-input>
        
        <el-button type="primary" @click="handleAddDepartment">
          <el-icon><Plus /></el-icon>
          新增部门
        </el-button>
        
        <el-button @click="expandAll">
          <el-icon><Expand /></el-icon>
          展开全部
        </el-button>
        
        <el-button @click="collapseAll">
          <el-icon><Fold /></el-icon>
          收起全部
        </el-button>
      </div>
    </div>
    
    <div class="tree-content">
      <el-tree-v2
        ref="treeRef"
        :data="filteredData"
        :props="defaultProps"
        :height="600"
        node-key="id"
        :expand-on-click-node="false"
        :default-expand-all="false"
        @node-click="handleNodeClick"
      >
        <template #default="{ node, data }">
          <div class="org-tree-node">
            <div class="node-info">
              <el-avatar
                v-if="data.type === 'employee'"
                :size="32"
                :src="data.avatar"
                class="node-avatar"
              >
                {{ data.name?.charAt(0) }}
              </el-avatar>
              
              <el-icon v-else class="node-icon department-icon">
                <OfficeBuilding />
              </el-icon>
              
              <div class="node-details">
                <div class="node-name">{{ data.name || data.label }}</div>
                <div v-if="data.position" class="node-position">{{ data.position }}</div>
                <div v-if="data.type === 'department'" class="node-count">
                  {{ getEmployeeCount(data) }} 人
                </div>
              </div>
            </div>
            
            <div class="node-actions">
              <el-tag
                v-if="data.status"
                :type="data.status === 'active' ? 'success' : 'danger'"
                size="small"
              >
                {{ data.status === 'active' ? '在职' : '离职' }}
              </el-tag>
              
              <el-dropdown @command="handleCommand">
                <el-button size="small" type="primary" link>
                  <el-icon><More /></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: 'addEmployee', data }"
                    >
                      添加员工
                    </el-dropdown-item>
                    <el-dropdown-item 
                      :command="{ action: 'delete', data }"
                      divided
                    >
                      删除
                    </el-dropdown-item>
                  </el-dropdown-menu>
                </template>
              </el-dropdown>
            </div>
          </div>
        </template>
      </el-tree-v2>
    </div>
    
    <!-- 详情弹窗 -->
    <el-dialog
      v-model="dialogVisible"
      :title="dialogTitle"
      width="600px"
    >
      <el-form
        ref="formRef"
        :model="formData"
        :rules="formRules"
        label-width="80px"
      >
        <el-form-item label="姓名" prop="name">
          <el-input v-model="formData.name" />
        </el-form-item>
        
        <el-form-item v-if="formData.type === 'employee'" label="职位" prop="position">
          <el-input v-model="formData.position" />
        </el-form-item>
        
        <el-form-item v-if="formData.type === 'employee'" label="邮箱" prop="email">
          <el-input v-model="formData.email" />
        </el-form-item>
        
        <el-form-item v-if="formData.type === 'employee'" label="电话" prop="phone">
          <el-input v-model="formData.phone" />
        </el-form-item>
        
        <el-form-item v-if="formData.type === 'employee'" label="状态" prop="status">
          <el-radio-group v-model="formData.status">
            <el-radio label="active">在职</el-radio>
            <el-radio label="inactive">离职</el-radio>
          </el-radio-group>
        </el-form-item>
        
        <el-form-item label="描述" prop="description">
          <el-input
            v-model="formData.description"
            type="textarea"
            :rows="3"
          />
        </el-form-item>
      </el-form>
      
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleSubmit">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { 
  ElMessage, ElMessageBox, ElIcon 
} from 'element-plus'
import { 
  Search, Plus, Expand, Fold, OfficeBuilding, More 
} from '@element-plus/icons-vue'

const treeRef = ref()
const formRef = ref()
const dialogVisible = ref(false)
const dialogMode = ref('view')
const searchKeyword = ref('')

const defaultProps = {
  children: 'children',
  label: 'name'
}

const formData = ref({
  id: null,
  name: '',
  type: 'employee',
  position: '',
  email: '',
  phone: '',
  status: 'active',
  description: ''
})

const formRules = {
  name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
  position: [{ required: true, message: '请输入职位', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
  ]
}

const dialogTitle = computed(() => {
  const typeText = formData.value.type === 'department' ? '部门' : '员工'
  const actionText = dialogMode.value === 'add' ? '新增' : 
                    dialogMode.value === 'edit' ? '编辑' : '查看'
  return `${actionText}${typeText}`
})

// 组织架构数据
const orgData = ref([
  {
    id: 1,
    name: '总经理办公室',
    type: 'department',
    children: [
      {
        id: 101,
        name: '张总',
        type: 'employee',
        position: '总经理',
        email: 'zhang@company.com',
        phone: '13800138001',
        status: 'active',
        avatar: ''
      }
    ]
  },
  {
    id: 2,
    name: '技术部',
    type: 'department',
    children: [
      {
        id: 201,
        name: '李经理',
        type: 'employee',
        position: '技术经理',
        email: 'li@company.com',
        phone: '13800138002',
        status: 'active',
        avatar: ''
      },
      {
        id: 202,
        name: '前端组',
        type: 'department',
        children: [
          {
            id: 2021,
            name: '王工程师',
            type: 'employee',
            position: '前端工程师',
            email: 'wang@company.com',
            phone: '13800138003',
            status: 'active',
            avatar: ''
          },
          {
            id: 2022,
            name: '赵工程师',
            type: 'employee',
            position: '前端工程师',
            email: 'zhao@company.com',
            phone: '13800138004',
            status: 'active',
            avatar: ''
          }
        ]
      },
      {
        id: 203,
        name: '后端组',
        type: 'department',
        children: [
          {
            id: 2031,
            name: '刘工程师',
            type: 'employee',
            position: '后端工程师',
            email: 'liu@company.com',
            phone: '13800138005',
            status: 'active',
            avatar: ''
          }
        ]
      }
    ]
  },
  {
    id: 3,
    name: '销售部',
    type: 'department',
    children: [
      {
        id: 301,
        name: '陈经理',
        type: 'employee',
        position: '销售经理',
        email: 'chen@company.com',
        phone: '13800138006',
        status: 'active',
        avatar: ''
      },
      {
        id: 302,
        name: '孙专员',
        type: 'employee',
        position: '销售专员',
        email: 'sun@company.com',
        phone: '13800138007',
        status: 'active',
        avatar: ''
      }
    ]
  }
])

// 递归搜索过滤
const filterTreeData = (data, keyword) => {
  if (!keyword) return data
  
  const filtered = []
  
  data.forEach(node => {
    const nodeMatches = node.name.toLowerCase().includes(keyword.toLowerCase()) ||
                       (node.position && node.position.toLowerCase().includes(keyword.toLowerCase()))
    
    let childrenMatches = []
    if (node.children && node.children.length > 0) {
      childrenMatches = filterTreeData(node.children, keyword)
    }
    
    if (nodeMatches || childrenMatches.length > 0) {
      filtered.push({
        ...node,
        children: childrenMatches
      })
    }
  })
  
  return filtered
}

const filteredData = computed(() => {
  return filterTreeData(orgData.value, searchKeyword.value)
})

// 计算部门员工数量
const getEmployeeCount = (department) => {
  let count = 0
  
  const countEmployees = (node) => {
    if (node.type === 'employee') {
      count++
    } else if (node.children) {
      node.children.forEach(countEmployees)
    }
  }
  
  if (department.children) {
    department.children.forEach(countEmployees)
  }
  
  return count
}

const handleSearch = () => {
  // 搜索时自动展开
}

const handleNodeClick = (data) => {
  console.log('节点点击:', data)
}

const handleCommand = ({ action, data }) => {
  switch (action) {
    case 'view':
      viewDetails(data)
      break
    case 'edit':
      editNode(data)
      break
    case 'addEmployee':
      addEmployee(data)
      break
    case 'delete':
      deleteNode(data)
      break
  }
}

const viewDetails = (data) => {
  dialogMode.value = 'view'
  formData.value = { ...data }
  dialogVisible.value = true
}

const editNode = (data) => {
  dialogMode.value = 'edit'
  formData.value = { ...data }
  dialogVisible.value = true
}

const addEmployee = (department) => {
  dialogMode.value = 'add'
  formData.value = {
    id: null,
    name: '',
    type: 'employee',
    position: '',
    email: '',
    phone: '',
    status: 'active',
    description: '',
    parentId: department.id
  }
  dialogVisible.value = true
}

const deleteNode = async (data) => {
  try {
    await ElMessageBox.confirm(
      `确定要删除 "${data.name}" 吗?`,
      '确认删除',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
    
    ElMessage.success(`已删除: ${data.name}`)
    // 这里添加删除逻辑
  } catch {
    // 用户取消删除
  }
}

const handleAddDepartment = () => {
  dialogMode.value = 'add'
  formData.value = {
    id: null,
    name: '',
    type: 'department',
    description: ''
  }
  dialogVisible.value = true
}

const expandAll = () => {
  // 展开所有节点的逻辑
  ElMessage.success('已展开全部节点')
}

const collapseAll = () => {
  // 收起所有节点的逻辑
  ElMessage.success('已收起全部节点')
}

const handleSubmit = async () => {
  if (dialogMode.value === 'view') {
    dialogVisible.value = false
    return
  }
  
  try {
    await formRef.value.validate()
    
    if (dialogMode.value === 'add') {
      ElMessage.success('新增成功')
    } else {
      ElMessage.success('编辑成功')
    }
    
    dialogVisible.value = false
  } catch (error) {
    console.error('表单验证失败:', error)
  }
}
</script>

<style scoped>
.org-tree-container {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.tree-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  padding: 16px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.tree-header h3 {
  margin: 0;
  color: #303133;
}

.header-actions {
  display: flex;
  align-items: center;
}

.tree-content {
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  padding: 16px;
}

.org-tree-node {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 8px 0;
}

.node-info {
  display: flex;
  align-items: center;
  flex: 1;
}

.node-avatar {
  margin-right: 12px;
}

.node-icon {
  margin-right: 12px;
  font-size: 24px;
}

.department-icon {
  color: #409eff;
}

.node-details {
  flex: 1;
}

.node-name {
  font-size: 14px;
  font-weight: 500;
  color: #303133;
  margin-bottom: 2px;
}

.node-position {
  font-size: 12px;
  color: #909399;
}

.node-count {
  font-size: 12px;
  color: #67c23a;
}

.node-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  opacity: 0;
  transition: opacity 0.2s;
}

.org-tree-node:hover .node-actions {
  opacity: 1;
}
</style>

文件管理器

vue
<template>
  <div class="file-manager">
    <div class="manager-header">
      <div class="breadcrumb">
        <el-breadcrumb separator="/">
          <el-breadcrumb-item
            v-for="(item, index) in breadcrumbItems"
            :key="index"
            :to="index < breadcrumbItems.length - 1 ? { path: item.path } : null"
          >
            {{ item.name }}
          </el-breadcrumb-item>
        </el-breadcrumb>
      </div>
      
      <div class="header-actions">
        <el-button-group>
          <el-button :type="viewMode === 'tree' ? 'primary' : ''" @click="viewMode = 'tree'">
            <el-icon><List /></el-icon>
            树形视图
          </el-button>
          <el-button :type="viewMode === 'grid' ? 'primary' : ''" @click="viewMode = 'grid'">
            <el-icon><Grid /></el-icon>
            网格视图
          </el-button>
        </el-button-group>
        
        <el-button type="primary" @click="handleUpload">
          <el-icon><Upload /></el-icon>
          上传文件
        </el-button>
        
        <el-button @click="handleNewFolder">
          <el-icon><FolderAdd /></el-icon>
          新建文件夹
        </el-button>
      </div>
    </div>
    
    <div class="manager-content">
      <div class="sidebar">
        <el-tree-v2
          ref="treeRef"
          :data="fileTree"
          :props="defaultProps"
          :height="500"
          node-key="id"
          :expand-on-click-node="false"
          :current-node-key="currentNodeId"
          @node-click="handleNodeClick"
          @node-contextmenu="handleContextMenu"
        >
          <template #default="{ node, data }">
            <div class="file-tree-node">
              <el-icon class="node-icon">
                <Folder v-if="data.type === 'folder'" />
                <Document v-else />
              </el-icon>
              <span class="node-label">{{ data.name }}</span>
              <div class="node-size" v-if="data.size">
                {{ formatFileSize(data.size) }}
              </div>
            </div>
          </template>
        </el-tree-v2>
      </div>
      
      <div class="main-content">
        <div class="content-header">
          <h4>{{ currentFolder?.name || '根目录' }}</h4>
          <div class="content-info">
            <span>{{ currentFolderItems.length }} 项</span>
            <span v-if="selectedItems.length > 0">
              已选择 {{ selectedItems.length }} 项
            </span>
          </div>
        </div>
        
        <div v-if="viewMode === 'grid'" class="grid-view">
          <div
            v-for="item in currentFolderItems"
            :key="item.id"
            class="grid-item"
            :class="{ selected: selectedItems.includes(item.id) }"
            @click="handleItemClick(item)"
            @dblclick="handleItemDoubleClick(item)"
            @contextmenu.prevent="handleItemContextMenu(item, $event)"
          >
            <div class="item-icon">
              <el-icon size="48">
                <Folder v-if="item.type === 'folder'" />
                <Document v-else-if="item.type === 'file'" />
                <Picture v-else-if="item.type === 'image'" />
                <VideoPlay v-else-if="item.type === 'video'" />
              </el-icon>
            </div>
            <div class="item-name">{{ item.name }}</div>
            <div class="item-info">
              <div v-if="item.size" class="item-size">
                {{ formatFileSize(item.size) }}
              </div>
              <div class="item-date">
                {{ formatDate(item.modifiedTime) }}
              </div>
            </div>
          </div>
        </div>
        
        <div v-else class="list-view">
          <el-table
            :data="currentFolderItems"
            @selection-change="handleSelectionChange"
            @row-dblclick="handleItemDoubleClick"
          >
            <el-table-column type="selection" width="55" />
            
            <el-table-column label="名称" min-width="200">
              <template #default="{ row }">
                <div class="list-item-name">
                  <el-icon class="item-icon">
                    <Folder v-if="row.type === 'folder'" />
                    <Document v-else-if="row.type === 'file'" />
                    <Picture v-else-if="row.type === 'image'" />
                    <VideoPlay v-else-if="row.type === 'video'" />
                  </el-icon>
                  <span>{{ row.name }}</span>
                </div>
              </template>
            </el-table-column>
            
            <el-table-column label="大小" width="100">
              <template #default="{ row }">
                {{ row.size ? formatFileSize(row.size) : '-' }}
              </template>
            </el-table-column>
            
            <el-table-column label="类型" width="100">
              <template #default="{ row }">
                {{ getFileTypeText(row.type) }}
              </template>
            </el-table-column>
            
            <el-table-column label="修改时间" width="160">
              <template #default="{ row }">
                {{ formatDate(row.modifiedTime) }}
              </template>
            </el-table-column>
            
            <el-table-column label="操作" width="120">
              <template #default="{ row }">
                <el-button size="small" type="primary" link @click="handleDownload(row)">
                  下载
                </el-button>
                <el-button size="small" type="danger" link @click="handleDelete(row)">
                  删除
                </el-button>
              </template>
            </el-table-column>
          </el-table>
        </div>
      </div>
    </div>
    
    <!-- 右键菜单 -->
    <el-dropdown
      ref="contextMenuRef"
      trigger="contextmenu"
      @command="handleContextMenuCommand"
    >
      <span></span>
      <template #dropdown>
        <el-dropdown-menu>
          <el-dropdown-item command="open">打开</el-dropdown-item>
          <el-dropdown-item command="rename">重命名</el-dropdown-item>
          <el-dropdown-item command="copy">复制</el-dropdown-item>
          <el-dropdown-item command="cut">剪切</el-dropdown-item>
          <el-dropdown-item command="delete" divided>删除</el-dropdown-item>
        </el-dropdown-menu>
      </template>
    </el-dropdown>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import {
  List, Grid, Upload, FolderAdd, Folder, Document, 
  Picture, VideoPlay
} from '@element-plus/icons-vue'

const treeRef = ref()
const contextMenuRef = ref()
const viewMode = ref('tree')
const currentNodeId = ref(1)
const selectedItems = ref([])
const contextMenuItem = ref(null)

const defaultProps = {
  children: 'children',
  label: 'name'
}

// 文件树数据
const fileTree = ref([
  {
    id: 1,
    name: '我的文档',
    type: 'folder',
    path: '/documents',
    children: [
      {
        id: 11,
        name: '工作文档',
        type: 'folder',
        path: '/documents/work',
        children: [
          {
            id: 111,
            name: '项目计划.docx',
            type: 'file',
            size: 1024000,
            modifiedTime: new Date('2024-01-15')
          },
          {
            id: 112,
            name: '会议纪要.pdf',
            type: 'file',
            size: 512000,
            modifiedTime: new Date('2024-01-14')
          }
        ]
      },
      {
        id: 12,
        name: '个人文档',
        type: 'folder',
        path: '/documents/personal',
        children: [
          {
            id: 121,
            name: '简历.pdf',
            type: 'file',
            size: 256000,
            modifiedTime: new Date('2024-01-10')
          }
        ]
      }
    ]
  },
  {
    id: 2,
    name: '图片',
    type: 'folder',
    path: '/images',
    children: [
      {
        id: 21,
        name: '头像.jpg',
        type: 'image',
        size: 128000,
        modifiedTime: new Date('2024-01-12')
      },
      {
        id: 22,
        name: '风景.png',
        type: 'image',
        size: 2048000,
        modifiedTime: new Date('2024-01-11')
      }
    ]
  },
  {
    id: 3,
    name: '视频',
    type: 'folder',
    path: '/videos',
    children: [
      {
        id: 31,
        name: '演示视频.mp4',
        type: 'video',
        size: 10240000,
        modifiedTime: new Date('2024-01-08')
      }
    ]
  }
])

// 当前文件夹
const currentFolder = computed(() => {
  const findNode = (nodes, id) => {
    for (const node of nodes) {
      if (node.id === id) return node
      if (node.children) {
        const found = findNode(node.children, id)
        if (found) return found
      }
    }
    return null
  }
  return findNode(fileTree.value, currentNodeId.value)
})

// 当前文件夹内容
const currentFolderItems = computed(() => {
  return currentFolder.value?.children || []
})

// 面包屑导航
const breadcrumbItems = computed(() => {
  const items = []
  const findPath = (nodes, id, path = []) => {
    for (const node of nodes) {
      const currentPath = [...path, { name: node.name, path: node.path, id: node.id }]
      if (node.id === id) {
        return currentPath
      }
      if (node.children) {
        const found = findPath(node.children, id, currentPath)
        if (found) return found
      }
    }
    return null
  }
  
  const path = findPath(fileTree.value, currentNodeId.value)
  return path || [{ name: '根目录', path: '/', id: 0 }]
})

// 格式化文件大小
const formatFileSize = (bytes) => {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}

// 格式化日期
const formatDate = (date) => {
  return new Date(date).toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit'
  })
}

// 获取文件类型文本
const getFileTypeText = (type) => {
  const typeMap = {
    folder: '文件夹',
    file: '文件',
    image: '图片',
    video: '视频'
  }
  return typeMap[type] || '未知'
}

// 处理节点点击
const handleNodeClick = (data) => {
  if (data.type === 'folder') {
    currentNodeId.value = data.id
    selectedItems.value = []
  }
}

// 处理项目点击
const handleItemClick = (item) => {
  if (selectedItems.value.includes(item.id)) {
    selectedItems.value = selectedItems.value.filter(id => id !== item.id)
  } else {
    selectedItems.value = [item.id]
  }
}

// 处理项目双击
const handleItemDoubleClick = (item) => {
  if (item.type === 'folder') {
    currentNodeId.value = item.id
    selectedItems.value = []
  } else {
    ElMessage.info(`打开文件: ${item.name}`)
  }
}

// 处理选择变化
const handleSelectionChange = (selection) => {
  selectedItems.value = selection.map(item => item.id)
}

// 处理右键菜单
const handleContextMenu = (event, data) => {
  contextMenuItem.value = data
  // 显示右键菜单逻辑
}

const handleItemContextMenu = (item, event) => {
  contextMenuItem.value = item
  // 显示右键菜单逻辑
}

const handleContextMenuCommand = (command) => {
  const item = contextMenuItem.value
  if (!item) return
  
  switch (command) {
    case 'open':
      handleItemDoubleClick(item)
      break
    case 'rename':
      ElMessage.info(`重命名: ${item.name}`)
      break
    case 'copy':
      ElMessage.info(`复制: ${item.name}`)
      break
    case 'cut':
      ElMessage.info(`剪切: ${item.name}`)
      break
    case 'delete':
      handleDelete(item)
      break
  }
}

// 处理上传
const handleUpload = () => {
  ElMessage.info('上传文件功能')
}

// 处理新建文件夹
const handleNewFolder = () => {
  ElMessage.info('新建文件夹功能')
}

// 处理下载
const handleDownload = (item) => {
  ElMessage.info(`下载: ${item.name}`)
}

// 处理删除
const handleDelete = (item) => {
  ElMessage.warning(`删除: ${item.name}`)
}
</script>

<style scoped>
.file-manager {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background: #f5f7fa;
}

.manager-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  background: white;
  border-bottom: 1px solid #e4e7ed;
}

.breadcrumb {
  flex: 1;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.manager-content {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.sidebar {
  width: 300px;
  background: white;
  border-right: 1px solid #e4e7ed;
  padding: 16px;
}

.main-content {
  flex: 1;
  background: white;
  padding: 16px;
  overflow: auto;
}

.content-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid #e4e7ed;
}

.content-header h4 {
  margin: 0;
  color: #303133;
}

.content-info {
  display: flex;
  gap: 16px;
  font-size: 14px;
  color: #909399;
}

.file-tree-node {
   display: flex;
   align-items: center;
   width: 100%;
   padding: 4px 0;
 }
 
 .node-icon {
   margin-right: 8px;
   color: #606266;
 }
 
 .node-label {
   flex: 1;
   font-size: 14px;
 }
 
 .node-size {
   font-size: 12px;
   color: #909399;
 }
 
 .grid-view {
   display: grid;
   grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
   gap: 16px;
   padding: 16px 0;
 }
 
 .grid-item {
   display: flex;
   flex-direction: column;
   align-items: center;
   padding: 12px;
   border: 1px solid transparent;
   border-radius: 8px;
   cursor: pointer;
   transition: all 0.2s;
 }
 
 .grid-item:hover {
   background: #f5f7fa;
   border-color: #e4e7ed;
 }
 
 .grid-item.selected {
   background: #ecf5ff;
   border-color: #409eff;
 }
 
 .item-icon {
   margin-bottom: 8px;
   color: #606266;
 }
 
 .item-name {
   font-size: 12px;
   text-align: center;
   word-break: break-all;
   margin-bottom: 4px;
 }
 
 .item-info {
   font-size: 10px;
   color: #909399;
   text-align: center;
 }
 
 .item-size {
   margin-bottom: 2px;
 }
 
 .list-view {
   margin-top: 16px;
 }
 
 .list-item-name {
   display: flex;
   align-items: center;
 }
 
 .list-item-name .item-icon {
   margin-right: 8px;
   color: #606266;
 }
 </style>

API 文档

Tree Attributes

参数说明类型可选值默认值
data展示数据Array
empty-text内容为空的时候展示的文本String
props配置选项,具体看下表Object
highlight-current是否高亮当前选中节点Booleanfalse
default-expand-all是否默认展开所有节点Booleanfalse
expand-on-click-node是否在点击节点的时候展开或者收缩节点Booleantrue
check-on-click-node是否在点击节点的时候选中节点Booleanfalse
auto-expand-parent展开子节点的时候是否自动展开父节点Booleantrue
default-expanded-keys默认展开的节点的 key 的数组Array
show-checkbox节点是否可被选择Booleanfalse
check-strictly在显示复选框的情况下,是否严格的遵循父子不互相关联的做法Booleanfalse
default-checked-keys默认勾选的节点的 key 的数组Array
current-node-key当前选中的节点String/Number
filter-node-method对树节点进行筛选时执行的方法Function(value, data, node)
accordion是否每次只打开一个同级树节点展开Booleanfalse
indent相邻级节点间的水平缩进,单位为像素Number16
icon自定义树节点的图标String/Component
lazy是否懒加载子节点,需与 load 方法结合使用Booleanfalse
load加载子树数据的方法,仅当 lazy 属性为true 时生效Function(node, resolve)
draggable是否开启拖拽节点功能Booleanfalse
allow-drag判断节点能否被拖拽Function(node)
allow-drop拖拽时判定目标节点能否被放置Function(draggingNode, dropNode, type)
node-key每个树节点用来作为唯一标识的属性,整棵树应该是唯一的String
height树的高度,必须设置Number
item-size每个节点的高度Number26
perfMode性能模式,大数据场景下建议开启Booleantrue

Tree Props

参数说明类型默认值
label指定节点标签为节点对象的某个属性值String/Function(data, node)
children指定子树为节点对象的某个属性值String
disabled指定节点选择框是否禁用为节点对象的某个属性值String/Function(data, node)
isLeaf指定节点是否为叶子节点,仅在指定了 lazy 属性的情况下生效String/Function(data, node)
class自定义节点类名String/Function(data, node)

Tree Events

事件名称说明回调参数
node-click节点被点击时的回调共三个参数,依次为:传递给 data 属性的数组中该节点所对应的对象、节点对应的 Node、节点组件本身
node-contextmenu当某一节点被鼠标右键点击时会触发该事件共四个参数,依次为:event、传递给 data 属性的数组中该节点所对应的对象、节点对应的 Node、节点组件本身
check-change节点选中状态发生变化时的回调共三个参数,依次为:传递给 data 属性的数组中该节点所对应的对象、节点本身是否被选中、节点的子树中是否有被选中的节点
check当复选框被点击的时候触发共两个参数,依次为:传递给 data 属性的数组中该节点所对应的对象、树目前的选中状态对象,包含 checkedNodes、checkedKeys、halfCheckedNodes、halfCheckedKeys 四个属性
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 属性,若没有节点被选中则返回 null
getCurrentNode获取当前被焦点选中的节点的 data,若没有节点被选中则返回 null
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内容为空时的占位符

最佳实践

性能优化

  1. 启用性能模式

    javascript
    // 大数据场景下建议开启性能模式
    <el-tree-v2 :perf-mode="true" />
  2. 合理设置高度

    javascript
    // 必须设置固定高度以启用虚拟滚动
    <el-tree-v2 :height="400" />
  3. 优化节点渲染

    javascript
    // 避免在节点模板中进行复杂计算
    // 将计算结果预处理到数据中
    const processedData = computed(() => {
      return rawData.value.map(item => ({
        ...item,
        displayText: formatDisplayText(item),
        statusColor: getStatusColor(item.status)
      }))
    })
  4. 懒加载优化

    javascript
    // 使用懒加载减少初始渲染压力
    const loadNode = async (node, resolve) => {
      try {
        const children = await fetchNodeChildren(node.data.id)
        resolve(children)
      } catch (error) {
        console.error('加载失败:', error)
        resolve([])
      }
    }

数据处理

  1. 数据结构优化

    javascript
    // 推荐的数据结构
    const treeData = [
      {
        id: 'unique-id',
        label: '显示文本',
        children: [],
        // 其他业务字段
        type: 'folder',
        status: 'active',
        metadata: {}
      }
    ]
  2. 搜索过滤优化

    javascript
    // 使用防抖优化搜索性能
    import { debounce } from 'lodash-es'
    
    const debouncedFilter = debounce((keyword) => {
      filteredData.value = filterTreeData(originalData.value, keyword)
    }, 300)
  3. 状态管理

    javascript
    // 使用 Pinia 管理复杂的树状态
    import { defineStore } from 'pinia'
    
    export const useTreeStore = defineStore('tree', {
      state: () => ({
        treeData: [],
        selectedKeys: [],
        expandedKeys: []
      }),
      actions: {
        updateTreeData(data) {
          this.treeData = data
        },
        toggleNode(key) {
          const index = this.expandedKeys.indexOf(key)
          if (index > -1) {
            this.expandedKeys.splice(index, 1)
          } else {
            this.expandedKeys.push(key)
          }
        }
      }
    })

用户体验

  1. 加载状态

    vue
    <template>
      <div v-loading="loading" class="tree-container">
        <el-tree-v2
          :data="treeData"
          :height="400"
          lazy
          :load="loadNode"
        />
      </div>
    </template>
  2. 错误处理

    javascript
    const loadNode = async (node, resolve) => {
      try {
        const children = await api.getChildren(node.data.id)
        resolve(children)
      } catch (error) {
        ElMessage.error('加载失败,请重试')
        resolve([])
      }
    }
  3. 键盘导航

    vue
    <el-tree-v2
      @keydown="handleKeydown"
      tabindex="0"
    />

响应式设计

  1. 自适应高度

    javascript
    import { useWindowSize } from '@vueuse/core'
    
    const { height } = useWindowSize()
    const treeHeight = computed(() => height.value - 200)
  2. 移动端适配

    css
    @media (max-width: 768px) {
      .tree-container {
        padding: 8px;
      }
      
      .tree-node {
        padding: 12px 8px;
        font-size: 16px;
      }
    }

常见问题

1. 树形数据不显示

问题:树形组件没有显示任何数据

原因

  • 未设置 height 属性
  • 数据结构不正确
  • props 配置错误

解决方案

vue
<template>
  <!-- 必须设置 height -->
  <el-tree-v2
    :data="treeData"
    :height="400"
    :props="{
      children: 'children',
      label: 'label'
    }"
  />
</template>

<script setup>
// 确保数据结构正确
const treeData = ref([
  {
    id: 1,
    label: '节点1',
    children: [
      {
        id: 2,
        label: '子节点1'
      }
    ]
  }
])
</script>

2. 懒加载不生效

问题:设置了 lazy 但子节点不会动态加载

原因

  • 未正确实现 load 方法
  • 节点数据缺少 isLeaf 标识
  • resolve 回调未正确调用

解决方案

javascript
const props = {
  children: 'children',
  label: 'label',
  isLeaf: 'isLeaf' // 重要:指定叶子节点标识
}

const loadNode = (node, resolve) => {
  // 根节点
  if (node.level === 0) {
    return resolve(rootData)
  }
  
  // 异步加载子节点
  setTimeout(() => {
    const children = generateChildren(node.data)
    resolve(children) // 必须调用 resolve
  }, 500)
}

3. 自定义节点内容不显示

问题:使用插槽自定义节点内容但不显示

原因

  • 插槽语法错误
  • 作用域插槽参数错误
  • CSS 样式覆盖

解决方案

vue
<template>
  <el-tree-v2 :data="data" :height="400">
    <!-- 正确的插槽语法 -->
    <template #default="{ node, data }">
      <div class="custom-node">
        <span>{{ data.label }}</span>
        <el-button size="small" @click.stop="handleEdit(data)">
          编辑
        </el-button>
      </div>
    </template>
  </el-tree-v2>
</template>

<style scoped>
.custom-node {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
}
</style>

4. 拖拽功能异常

问题:拖拽功能不工作或行为异常

原因

  • 未设置 node-key
  • allow-dragallow-drop 返回值错误
  • 事件处理逻辑错误

解决方案

vue
<template>
  <el-tree-v2
    :data="data"
    :height="400"
    draggable
    node-key="id"
    :allow-drag="allowDrag"
    :allow-drop="allowDrop"
    @node-drop="handleDrop"
  />
</template>

<script setup>
const allowDrag = (draggingNode) => {
  // 返回 boolean 值
  return !draggingNode.data.disabled
}

const allowDrop = (draggingNode, dropNode, type) => {
  // 检查拖拽规则
  if (type === 'inner' && dropNode.data.type === 'file') {
    return false
  }
  return true
}

const handleDrop = (draggingNode, dropNode, dropType) => {
  console.log('拖拽完成', {
    from: draggingNode.data,
    to: dropNode.data,
    type: dropType
  })
}
</script>

5. 性能问题

问题:大量数据时出现卡顿

原因

  • 未启用虚拟滚动
  • 节点渲染过于复杂
  • 频繁的数据更新

解决方案

vue
<template>
  <!-- 启用性能模式 -->
  <el-tree-v2
    :data="data"
    :height="400"
    :perf-mode="true"
    :item-size="32"
  >
    <template #default="{ data }">
      <!-- 简化节点内容 -->
      <span>{{ data.label }}</span>
    </template>
  </el-tree-v2>
</template>

<script setup>
// 使用 shallowRef 优化大数据
import { shallowRef } from 'vue'

const data = shallowRef([])

// 批量更新数据
const updateData = (newData) => {
  data.value = newData
}
</script>

总结

Virtualized Tree 虚拟化树形控件是 Element Plus 中处理大量层级数据的最佳选择。通过本文档的学习,你应该掌握了:

核心优势

  1. 高性能:虚拟滚动技术,支持万级数据展示
  2. 功能丰富:支持选择、搜索、拖拽、懒加载等功能
  3. 高度可定制:灵活的插槽和配置选项
  4. 易于使用:简洁的 API 设计

适用场景

  • 组织架构管理
  • 文件目录浏览
  • 分类数据展示
  • 权限树管理
  • 大数据量的层级结构

关键要点

  1. 必须设置 height:启用虚拟滚动的前提
  2. 合理使用懒加载:优化大数据场景的性能
  3. 注意数据结构:确保符合组件要求的格式
  4. 性能优化:启用性能模式,简化节点渲染
  5. 用户体验:提供加载状态和错误处理

通过合理使用 Virtualized Tree 组件,你可以构建出高性能、用户友好的树形数据展示界面。

参考资料

Element Plus Study Guide