Electron for OpenHarmony 跨平台实战开发(二):文件树组件实现与优化

前言

文件树是文档管理系统最核心的 UI 组件之一。用户需要通过它来浏览、选择、管理文件和文件夹。看起来简单,但实现起来有不少细节要考虑,比如递归渲染、状态管理、交互优化等。

这篇文章就来说说怎么用 Vue3 实现一个功能完整的文件树组件,包括递归组件设计、状态管理、右键菜单、对话框交互,还有性能优化的一些技巧。

一、递归组件设计

1.1 Vue3 递归组件的原理

文件树是典型的树形结构,每个节点可能有子节点,子节点又可能有子节点。这种结构最适合用递归组件来实现。

Vue3 的递归组件很简单,就是组件可以在自己的模板中引用自己。比如 TreeNode 组件渲染子节点时,可以再次使用 <TreeNode> 组件。

typescript 复制代码
// TreeNode.vue
<template>
  <div>
    <!-- 当前节点 -->
    <div>{{ node.name }}</div>
    <!-- 递归渲染子节点 -->
    <TreeNode 
      v-for="child in node.children" 
      :key="child.id"
      :node="child"
    />
  </div>
</template>

这样就能自动处理任意深度的树形结构了。

1.2 TreeNode 组件实现

先看看 TreeNode 组件的完整实现:

typescript 复制代码
// src/components/FileTree/TreeNode.vue
<script setup lang="ts">
import type { FileTreeNode } from '@/types/file'

interface Props {
  node: FileTreeNode
  level?: number
}

const props = withDefaults(defineProps<Props>(), {
  level: 0,
})

const emit = defineEmits<{
  toggle: [e: Event, node: FileTreeNode]
  click: [node: FileTreeNode]
  contextmenu: [e: MouseEvent, node: FileTreeNode]
}>()

const handleToggle = (e: Event) => {
  emit('toggle', e, props.node)
}

const handleClick = () => {
  emit('click', props.node)
}

const handleContextMenu = (e: MouseEvent) => {
  e.preventDefault()
  e.stopPropagation()
  emit('contextmenu', e, props.node)
}
</script>

这个组件接收一个 node 属性和可选的 level 属性(用来控制缩进)。通过 emit 定义三个事件:toggle(展开/折叠)、click(点击选择)、contextmenu(右键菜单)。

1.3 递归渲染逻辑

模板部分的关键是递归渲染:

vue 复制代码
<template>
  <div class="tree-node-wrapper">
    <div
      :class="['tree-node', { 'is-selected': node.isSelected, 'is-folder': node.type === 'folder' }]"
      :style="{ paddingLeft: `${level * 16 + 8}px` }"
      @click="handleClick"
      @contextmenu="handleContextMenu"
    >
      <!-- 文件夹图标,可点击展开/折叠 -->
      <span
        v-if="node.type === 'folder'"
        :class="['tree-node-icon', 'folder-icon', { expanded: node.isExpanded }]"
        @click.stop="handleToggle"
      >
        ▶
      </span>
      <span v-else class="tree-node-icon file-icon">📄</span>
      <span class="tree-node-name">{{ node.name }}</span>
    </div>
    <!-- 递归渲染子节点 -->
    <div v-if="node.type === 'folder' && node.isExpanded && node.children" class="tree-node-children">
      <TreeNode
        v-for="child in node.children"
        :key="child.id"
        :node="child"
        :level="level + 1"
        @toggle="emit('toggle', $event, child)"
        @click="emit('click', child)"
        @contextmenu="emit('contextmenu', $event, child)"
      />
    </div>
  </div>
</template>

几个关键点:

  1. 条件渲染:只有文件夹且展开时才渲染子节点
  2. 事件传递 :子节点的事件通过 emit 向上传递,保持事件冒泡
  3. 缩进控制 :通过 level 属性计算 paddingLeft,实现层级缩进
  4. 图标切换 :文件夹图标根据 isExpanded 状态旋转

1.4 组件通信机制

事件传递是递归组件的关键。子组件触发事件后,需要一层层向上传递到 FileTree 组件:

复制代码
TreeNode (子节点) 
  → emit('click', child)
TreeNode (父节点)
  → emit('click', child)  
FileTree (容器组件)
  → handleClick(node)

这样 FileTree 组件就能统一处理所有节点的点击事件了。

折叠情况:

展开状态:

二、文件树状态管理

2.1 Pinia Store 设计

文件树的状态管理用 Pinia 来实现。在 src/stores/files.ts 中:

typescript 复制代码
export const useFilesStore = defineStore('files', {
  state: () => ({
    fileTree: [] as FileTreeNode[],
    selectedNode: null as FileTreeNode | null,
    loading: false,
  }),

  actions: {
    // 加载文件树
    async loadFileTree() {
      // ...
    },

    // 切换节点展开/折叠
    toggleNode(nodeId: string) {
      // ...
    },

    // 选择节点
    selectNode(node: FileTreeNode | null) {
      // ...
    },
  },
})

fileTree 存储整个树的数据,selectedNode 存储当前选中的节点,loading 表示加载状态。

2.2 展开/折叠状态管理

展开和折叠的逻辑在 toggleNode 方法中:

typescript 复制代码
toggleNode(nodeId: string) {
  const findNode = (nodes: FileTreeNode[]): FileTreeNode | null => {
    for (const node of nodes) {
      if (node.id === nodeId) {
        return node
      }
      if (node.children) {
        const found = findNode(node.children)
        if (found) return found
      }
    }
    return null
  }

  const node = findNode(this.fileTree)
  if (node && node.type === 'folder') {
    node.isExpanded = !node.isExpanded
  }
}

这里用递归查找节点,找到后切换 isExpanded 状态。Vue3 的响应式系统会自动更新视图。

2.3 选中状态管理

选中节点的逻辑稍微复杂一点,因为要取消之前选中的节点:

typescript 复制代码
selectNode(node: FileTreeNode | null) {
  // 取消之前选中的节点
  const clearSelection = (nodes: FileTreeNode[]) => {
    nodes.forEach(n => {
      n.isSelected = false
      if (n.children) {
        clearSelection(n.children)
      }
    })
  }

  clearSelection(this.fileTree)

  // 选中新节点
  if (node) {
    node.isSelected = true
    this.selectedNode = node

    // 如果是文件,加载文档内容
    if (node.type === 'file') {
      this.loadDocument(node.path)
    }
  } else {
    this.selectedNode = null
  }
}

先递归清除所有节点的选中状态,然后设置新节点的选中状态。如果是文件,还会自动加载文档内容。

三、交互功能实现

3.1 右键菜单实现

右键菜单是文件树的重要功能。实现思路是:

  1. 监听节点的 contextmenu 事件
  2. 记录鼠标位置和当前节点
  3. 显示菜单,定位到鼠标位置
  4. 点击菜单项执行对应操作
typescript 复制代码
// FileTree.vue
const showContextMenu = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const contextMenuNode = ref<FileTreeNode | null>(null)

const handleContextMenu = (e: MouseEvent, node: FileTreeNode) => {
  e.preventDefault()
  e.stopPropagation()
  contextMenuX.value = e.clientX
  contextMenuY.value = e.clientY
  contextMenuNode.value = node
  showContextMenu.value = true
}

菜单组件 ContextMenu.vue 接收位置和节点信息,显示菜单项:

vue 复制代码
<ContextMenu
  :visible="showContextMenu"
  :x="contextMenuX"
  :y="contextMenuY"
  :node="contextMenuNode"
  @rename="handleContextRename"
  @delete="handleContextDelete"
  @create-file="handleContextCreateFile"
  @create-folder="handleContextCreateFolder"
  @close="handleCloseContextMenu"
/>

3.2 创建对话框

创建文件和文件夹用同一个对话框组件,通过 type 属性区分:

typescript 复制代码
const showCreateDialog = ref(false)
const createType = ref<'file' | 'folder'>('file')
const createParentPath = ref<string>('')

const openCreateDialog = (type: 'file' | 'folder', parentPath: string = '') => {
  createType.value = type
  createParentPath.value = parentPath
  showCreateDialog.value = true
}

const handleCreateConfirm = async (name: string) => {
  try {
    const fullPath = createParentPath.value
      ? `${createParentPath.value}/${name}`
      : name

    let result
    if (createType.value === 'file') {
      result = await filesStore.createFile(fullPath, '', createParentPath.value)
    } else {
      result = await filesStore.createFolder(fullPath, createParentPath.value)
    }

    if (result?.success) {
      showCreateDialog.value = false
    } else {
      alert(result?.message || '创建失败')
    }
  } catch (error) {
    console.error('创建失败:', error)
    alert('创建失败,请重试')
  }
}

对话框组件 CreateDialog.vue 接收类型和父路径,用户输入名称后确认创建。


3.3 删除和重命名对话框

删除和重命名的实现类似,都是先显示对话框,用户确认后执行操作:

typescript 复制代码
// 删除
const handleDeleteConfirm = async () => {
  if (!deleteNode.value) return

  try {
    const result = await filesStore.deleteItem(deleteNode.value)
    if (result?.success) {
      showDeleteDialog.value = false
      deleteNode.value = null
    } else {
      alert(result?.message || '删除失败')
    }
  } catch (error) {
    console.error('删除失败:', error)
    alert('删除失败,请重试')
  }
}

// 重命名
const handleRenameConfirm = async (newName: string) => {
  if (!renameNode.value) return

  try {
    const result = await filesStore.renameItem(renameNode.value, newName)
    if (result?.success) {
      showRenameDialog.value = false
      renameNode.value = null
    } else {
      alert(result?.message || '重命名失败')
    }
  } catch (error) {
    console.error('重命名失败:', error)
    alert('重命名失败,请重试')
  }
}

3.4 事件处理机制

文件树组件的事件处理流程:

  1. 用户操作:点击、右键、展开/折叠
  2. 事件触发TreeNode 组件触发事件
  3. 事件传递 :通过 emit 向上传递到 FileTree
  4. 状态更新FileTree 调用 store 的方法更新状态
  5. 视图更新:Vue3 响应式系统自动更新视图

这个流程保证了数据流的单向性,状态管理更清晰。

四、性能优化

4.1 文件夹状态保持优化

文件操作后(创建、删除、重命名),需要重新加载文件树。但直接重新加载会导致所有文件夹都折叠,用户体验不好。

解决方案是保存展开状态,重新加载后恢复:

typescript 复制代码
// 收集当前展开的文件夹路径
getExpandedPaths(): string[] {
  const expandedPaths: string[] = []
  const collectExpanded = (nodes: FileTreeNode[]) => {
    nodes.forEach((node) => {
      if (node.type === 'folder' && node.isExpanded) {
        expandedPaths.push(node.path)
        if (node.children) {
          collectExpanded(node.children)
        }
      } else if (node.children) {
        collectExpanded(node.children)
      }
    })
  }
  collectExpanded(this.fileTree)
  return expandedPaths
}

// 加载文件树时保存和恢复展开状态
async loadFileTree() {
  // 保存当前展开的文件夹路径
  const expandedPaths = this.getExpandedPaths()

  this.loading = true
  try {
    const result = await window.electronAPI.file.readTree()
    if (result.success && result.data) {
      // 转换数据时传入展开路径
      this.fileTree = this.convertToFileTreeNodes(result.data, null, expandedPaths)
      // 恢复展开状态
      this.restoreExpandedState(expandedPaths)
    }
  } finally {
    this.loading = false
  }
}

这样文件操作后,用户展开的文件夹会保持展开状态,体验更好。

4.2 渲染性能优化

对于大文件树,可以考虑以下优化:

  1. 虚拟滚动:只渲染可见区域的节点
  2. 懒加载:只加载展开的文件夹内容
  3. 防抖处理:搜索、过滤等操作防抖

目前我们的实现比较简单,如果文件数量不多(几百个),性能已经足够了。如果文件数量很大,可以考虑引入虚拟滚动库,比如 vue-virtual-scroller

4.3 内存管理

递归组件要注意内存泄漏问题:

  1. 及时清理:组件销毁时清理事件监听
  2. 避免循环引用:数据结构中避免循环引用
  3. 合理使用 ref :大对象用 shallowRef 而不是 ref

我们的实现中,文件树数据是响应式的,Vue3 会自动管理内存。只要注意不要在组件外部持有对节点的引用,一般不会有问题。

五、FileTree 容器组件

最后看看 FileTree.vue 容器组件的完整结构:

vue 复制代码
<template>
  <div class="file-tree">
    <div class="file-tree-header">
      <h3 class="file-tree-title">文件</h3>
      <div class="header-actions">
        <button @click="openCreateDialog('file')" title="创建文件">📄</button>
        <button @click="openCreateDialog('folder')" title="创建文件夹">📁</button>
        <button @click="filesStore.loadFileTree()" title="刷新">🔄</button>
      </div>
    </div>
    <div class="file-tree-content">
      <div v-if="filesStore.loading" class="loading-state">加载中...</div>
      <div v-else-if="filesStore.fileTree.length === 0" class="empty-state">
        <p>暂无文件</p>
      </div>
      <div v-else class="tree-nodes">
        <TreeNode
          v-for="node in filesStore.fileTree"
          :key="node.id"
          :node="node"
          :level="0"
          @toggle="handleToggle"
          @click="handleClick"
          @contextmenu="handleContextMenu"
        />
      </div>
    </div>
    <!-- 对话框组件 -->
    <CreateDialog ... />
    <ContextMenu ... />
    <DeleteDialog ... />
    <RenameDialog ... />
  </div>
</template>

组件结构清晰:

  • 头部:标题和操作按钮
  • 内容区:加载状态、空状态、文件树
  • 对话框:创建、删除、重命名、右键菜单










总结

这篇文章介绍了文件树组件的完整实现,主要包括:

  1. ✅ 递归组件设计:用 Vue3 递归组件实现树形结构渲染
  2. ✅ 状态管理:用 Pinia 管理文件树状态、展开/折叠、选中状态
  3. ✅ 交互功能:右键菜单、创建/删除/重命名对话框
  4. ✅ 性能优化:文件夹状态保持、渲染优化、内存管理

文件树组件虽然看起来简单,但要做好用户体验,需要考虑很多细节。递归组件的设计、状态管理的优化、交互的流畅性,都需要仔细处理。

在实际开发中,可能还会遇到其他问题,比如拖拽排序、多选、搜索过滤等,这些都可以在现有基础上扩展。关键是要保持代码结构清晰,方便后续维护和扩展。


作者 :ckk
标签:Vue3、递归组件、文件树、Pinia、组件优化

相关推荐
爱艺江河7 小时前
[鸿蒙2025领航者闯关]基于MetaStudio的数字人与鸿蒙PC本地智能体融合:金融法务合规业务的技术实现与场景创新
金融·openharmony·鸿蒙2025领航者闯关
QC七哥14 小时前
基于tauri构建全平台应用
rust·electron·nodejs·tauri
fakerth15 小时前
【OpenHarmony】Hiview架构
架构·操作系统·openharmony
光影少年1 天前
前端开发桌面应用开发,Flutter 与 Electron如何选?
javascript·flutter·electron
weixin_462446232 天前
Electron 禁止复制粘帖
前端·javascript·electron
多看书少吃饭3 天前
从 ScriptProcessor 到 AudioWorklet:Electron 桌面端录音实践总结
前端·javascript·electron
静待雨落4 天前
Electron无边框窗口如何拖拽以及最大化和还原窗口
前端·electron
梵尔纳多5 天前
Electron 主进程和渲染进程通信
javascript·arcgis·electron
鸿蒙小白龙5 天前
鸿蒙UniProton操作系统编译开发指导
harmonyos·鸿蒙系统·openharmony·uniproton