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、组件优化

相关推荐
月走乂山1 天前
DSH 桌面版用 DeepSeek 报 REQUEST_EXTENSION 的根因定位与修复
electron·node.js·插件系统·deepseek·bug排查·dsh·deepseek harness
mont evergreen2 天前
一封从外部世界寄来的信:二维码数据如何穿越三重门,抵达你的屏幕
electron·vue·事件
fellow993 天前
DSH Desktop:把 DeepSeek Harness 变成真正的桌面应用
electron·agent·ai编程·deepseek
CDwenhuohuo3 天前
electron pc项目打包成桌面端
前端·javascript·electron
不可能片场4 天前
命令行中文变问号 我用环境变量救了场
前端·electron
硅谷魔术师5 天前
openHarmony有没有类似安卓的scrcpy连接工具
openharmony
不可能片场5 天前
AI视频口型对齐 用音频驱动说话镜头
前端·electron
Zoom5 天前
【开源】7 天,我用 Rust 复刻了 FinalShell!Rhost v1.0.0 发布
rust·electron·shell
熊猫钓鱼>_>6 天前
Kotlin Multiplatform for OpenHarmony 实战:为 Landscapist 实现图片加载适配
开发语言·kotlin·华为云·ai编程·harmonyos·鸿蒙·openharmony