这是一个非常专业的需求。要将一个涉及 Main/Preload/Renderer 三端的复杂业务封装成一个“开箱即用”的库,我们需要遵循 “关注点分离” 和 “依赖注入” 的原则。
为了满足“封装成库”且“参考 VS Code”的要求,我设计了一个名为 electron-tree-explorer 的本地库结构。你可以直接将其视为一个独立的 npm 包来维护。
📚 库的设计架构
这个库将包含三个导出入口:
main: 提供给 Electron 主进程注册 IPC 监听器。
preload: 提供给 Preload 脚本暴露 API。
renderer: 提供 Vue 组件 (FileExplorer) 和 CSS。
🚀 1. 核心类型定义 (types.ts)
首先定义通信协议,保证前后端类型一致。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
export interface FileNode { name: string path: string type: 'file' | 'directory' children?: FileNode[] isExpanded?: boolean isEditing?: boolean }
export type ExplorerEvents = | 'ete:read-dir' | 'ete:create' | 'ete:rename' | 'ete:delete' | 'ete:open-file'
export interface CreatePayload { basePath: string name: string type: 'file' | 'directory' }
|
🏗️ 2. 主进程逻辑 (main.ts)
这里封装了所有 fs 操作。为了安全,我们不直接暴露 fs,而是暴露业务方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
| import { ipcMain, shell } from 'electron' import fs from 'fs-extra' import path from 'path' import { FileNode, CreatePayload } from './types'
export class FileExplorerServer { constructor() { this.registerHandlers() }
private registerHandlers() { ipcMain.handle('ete:read-dir', async (_, dirPath: string): Promise<FileNode[]> => { try { const names = await fs.readdir(dirPath) const nodes: FileNode[] = [] for (const name of names) { if (name.startsWith('.')) continue const fullPath = path.join(dirPath, name) try { const stats = await fs.stat(fullPath) nodes.push({ name, path: fullPath, type: stats.isDirectory() ? 'directory' : 'file', children: undefined }) } catch (e) { } }
return nodes.sort((a, b) => { if (a.type === b.type) return a.name.localeCompare(b.name) return a.type === 'directory' ? -1 : 1 }) } catch (error) { console.error('Read dir failed:', error) return [] } })
ipcMain.handle('ete:create', async (_, { basePath, name, type }: CreatePayload) => { const targetPath = path.join(basePath, name) if (await fs.pathExists(targetPath)) throw new Error('Exist') if (type === 'directory') { await fs.ensureDir(targetPath) } else { await fs.ensureFile(targetPath) } return targetPath })
ipcMain.handle('ete:rename', async (_, { oldPath, newName }) => { const dir = path.dirname(oldPath) const newPath = path.join(dir, newName) await fs.rename(oldPath, newPath) return newPath })
ipcMain.handle('ete:delete', async (_, filePath: string) => { await shell.trashedItem(filePath) return true })
ipcMain.handle('ete:open-file', async (_, filePath: string) => { shell.openPath(filePath) }) } }
export function registerFileExplorer() { new FileExplorerServer() }
|
🌉 3. Preload 桥接 (preload.ts)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { ipcRenderer, contextBridge } from 'electron' import { CreatePayload } from './types'
const api = { readDir: (path: string) => ipcRenderer.invoke('ete:read-dir', path), create: (payload: CreatePayload) => ipcRenderer.invoke('ete:create', payload), rename: (oldPath: string, newName: string) => ipcRenderer.invoke('ete:rename', { oldPath, newName }), delete: (path: string) => ipcRenderer.invoke('ete:delete', path), openFile: (path: string) => ipcRenderer.invoke('ete:open-file', path) }
export function exposeExplorerAPI() { if (process.contextIsolated) { contextBridge.exposeInMainWorld('fileExplorerAPI', api) } else { window.fileExplorerAPI = api } }
|
🎨 4. Renderer 组件 (Vue)
这里是逻辑最复杂的地方。为了封装性,我们将 ContextMenu 和 TreeItem 都集成在一起。
4.1 树节点组件 (TreeItem.vue)
处理递归渲染、重命名输入框、右键菜单事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
| <template> <div class="ete-node"> <!-- 节点行 --> <div class="ete-label" :class="{ 'selected': isSelected }" :style="{ paddingLeft: `${depth * 12 + 10}px` }" @click="onLeftClick" @contextmenu.prevent="onRightClick" > <!-- 箭头 --> <span class="ete-arrow" :class="{ 'open': isOpen, 'hidden': node.type === 'file' }"> ▶ </span> <!-- 图标 --> <span class="ete-icon">{{ node.type === 'directory' ? '📁' : '📄' }}</span>
<!-- 正常名称 --> <span v-if="!isRenaming" class="ete-name">{{ node.name }}</span> <!-- 重命名/新建 输入框 --> <input v-else ref="inputRef" v-model="editName" class="ete-input" @blur="confirmEdit" @keydown.enter="confirmEdit" @keydown.esc="cancelEdit" @click.stop /> </div>
<!-- 子节点递归 --> <div v-if="node.type === 'directory' && isOpen"> <TreeItem v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" :selectedPath="selectedPath" @select="$emit('select', $event)" @context="$emit('context', $event)" @refresh="$emit('refresh', node)" /> </div> </div> </template>
<script setup lang="ts"> import { ref, nextTick, watch, toRef } from 'vue' import { FileNode } from './types'
const props = defineProps<{ node: FileNode depth: number selectedPath: string }>()
const emit = defineEmits(['select', 'context', 'refresh'])
const isOpen = ref(false) const isRenaming = ref(false) const editName = ref('') const inputRef = ref<HTMLInputElement>()
// 监听外部控制的编辑状态 (用于新建) watch(() => props.node.isEditing, (val) => { if (val) startEdit() })
const isSelected = toRef(() => props.selectedPath === props.node.path)
// 加载子目录 const loadChildren = async () => { if (props.node.children) return // 已加载过 // @ts-ignore const children = await window.fileExplorerAPI.readDir(props.node.path) props.node.children = children }
const onLeftClick = async () => { emit('select', props.node) if (props.node.type === 'directory') { isOpen.value = !isOpen.value if (isOpen.value) await loadChildren() } }
const onRightClick = (e: MouseEvent) => { emit('select', props.node) // 右键同时也选中 emit('context', { event: e, node: props.node }) }
// === 编辑逻辑 === const startEdit = () => { isRenaming.value = true editName.value = props.node.name nextTick(() => inputRef.value?.focus()) }
const confirmEdit = async () => { if (!isRenaming.value) return isRenaming.value = false if (!editName.value || editName.value === props.node.name) { if (props.node.name === '') { // 如果是新建文件且名字为空,触发刷新父级以移除临时节点 emit('refresh', null) } return }
try { if (props.node.path.endsWith('__temp__')) { // 新建逻辑 const basePath = props.node.path.replace('__temp__', '') // @ts-ignore await window.fileExplorerAPI.create({ basePath, name: editName.value, type: props.node.type }) } else { // 重命名逻辑 // @ts-ignore await window.fileExplorerAPI.rename(props.node.path, editName.value) } // 成功后请求父级刷新 emit('refresh', 'parent') } catch (e) { alert('操作失败: ' + e) // 失败如果是新建,需要移除 if (props.node.path.endsWith('__temp__')) emit('refresh', null) } }
const cancelEdit = () => { isRenaming.value = false if (props.node.path.endsWith('__temp__')) emit('refresh', null) }
defineExpose({ startEdit }) </script>
<style> /* 包含 VS Code 风格的基础样式 */ .ete-label { display: flex; align-items: center; cursor: pointer; height: 22px; color: #cccccc; font-size: 13px; user-select: none; border: 1px solid transparent; } .ete-label:hover { background-color: #2a2d2e; } .ete-label.selected { background-color: #37373d; color: #fff; }
.ete-arrow { font-size: 10px; margin-right: 4px; transition: transform 0.1s; color: #999; width: 14px; text-align: center; } .ete-arrow.open { transform: rotate(90deg); } .ete-arrow.hidden { visibility: hidden; }
.ete-icon { margin-right: 6px; } .ete-input { background: #3c3c3c; color: white; border: 1px solid #007fd4; outline: none; height: 20px; width: 100%; } </style>
|
4.2 主入口组件 (FileExplorer.vue)
负责管理根节点、右键菜单的显示与隐藏。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
| <template> <div class="ete-container" @click="selectedPath = ''" @contextmenu.prevent="onRootContext"> <!-- 递归树 --> <TreeItem v-for="node in tree" :key="node.path" ref="itemRefs" :node="node" :depth="0" :selectedPath="selectedPath" @select="onSelect" @context="onContext" @refresh="refreshNode" />
<!-- 右键菜单 --> <div v-if="menu.visible" class="ete-context-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }"> <div @click="handleAction('new_file')">新建文件</div> <div @click="handleAction('new_folder')">新建文件夹</div> <div @click="handleAction('rename')">重命名</div> <div class="ete-divider"></div> <div @click="handleAction('delete')" class="ete-danger">删除</div> </div> </div> </template>
<script setup lang="ts"> import { ref, onMounted, reactive, nextTick } from 'vue' import TreeItem from './TreeItem.vue' import { FileNode } from './types'
const props = defineProps<{ rootPath: string }>() const emit = defineEmits(['open-file'])
const tree = ref<FileNode[]>([]) const selectedPath = ref('') const itemRefs = ref([]) // 用于获取子组件实例调用 startEdit
const menu = reactive({ visible: false, x: 0, y: 0, targetNode: null as FileNode | null })
// 初始化 onMounted(async () => { await loadRoot() })
const loadRoot = async () => { if (!props.rootPath) return // @ts-ignore tree.value = await window.fileExplorerAPI.readDir(props.rootPath) }
const onSelect = (node: FileNode) => { selectedPath.value = node.path if (node.type === 'file') emit('open-file', node.path) }
const onContext = ({ event, node }: any) => { menu.visible = true menu.x = event.clientX menu.y = event.clientY menu.targetNode = node }
const onRootContext = (event: MouseEvent) => { // 空白处右键 menu.visible = true menu.x = event.clientX menu.y = event.clientY menu.targetNode = null // null 代表根目录 }
// 刷新逻辑:如果是 'parent',说明子节点有变动,这里简单处理,重新加载整个父节点 // 在生产级库中,应该通过 path 找到父节点并只刷新它 const refreshNode = () => { // 简单实现:全量刷新根目录 (优化点:根据 path 局部刷新) loadRoot() }
const handleAction = async (action: string) => { menu.visible = false const target = menu.targetNode if (action === 'delete') { if (!target) return if (confirm(`删除 ${target.name}?`)) { // @ts-ignore await window.fileExplorerAPI.delete(target.path) loadRoot() } return }
if (action === 'rename') { if (!target) return // 找到对应的组件实例调用 startEdit // 实际库中一般使用 bus 或 provide/inject 来控制编辑状态 target.isEditing = true return }
if (action === 'new_file' || action === 'new_folder') { // 1. 确定插入位置(当前文件夹下,或者当前文件同级,或者根目录) let insertList = tree.value let basePath = props.rootPath if (target) { if (target.type === 'directory') { // 插入到该文件夹内部(需要先展开) if (!target.children) target.children = [] insertList = target.children basePath = target.path } else { // 插入到同级 (这里简化,暂时只支持根目录或者文件夹内) // 实际需要递归查找 parent } }
// 2. 插入临时节点 const tempNode: FileNode = { name: '', path: basePath + '/__temp__', // 特殊标记 type: action === 'new_file' ? 'file' : 'directory', isEditing: true } insertList.unshift(tempNode) } }
// 点击空白关闭菜单 window.addEventListener('click', () => { menu.visible = false }) </script>
<style scoped> .ete-container { height: 100%; overflow-y: auto; background: #252526; color: #ccc; font-family: 'Segoe UI', sans-serif; } .ete-context-menu { position: fixed; background: #333; border: 1px solid #454545; box-shadow: 0 2px 8px rgba(0,0,0,0.3); z-index: 9999; min-width: 120px; padding: 4px 0; } .ete-context-menu div { padding: 6px 16px; cursor: pointer; font-size: 13px; } .ete-context-menu div:hover { background: #094771; color: white; } .ete-divider { height: 1px; background: #454545; margin: 4px 0; padding: 0 !important; } .ete-danger:hover { background: #800000 !important; } </style>
|
📦 5. 如何在你的项目中使用
现在你已经有了一个名为 electron-tree-explorer 的本地库(文件夹)。
步骤 1: 注册主进程
在 src/main/index.ts 中:
1 2 3 4
| import { registerFileExplorer } from '../packages/electron-tree-explorer/src/main'
registerFileExplorer()
|
步骤 2: 注册 Preload
在 src/preload/index.ts 中:
1 2 3
| import { exposeExplorerAPI } from '../packages/electron-tree-explorer/src/preload'
exposeExplorerAPI()
|
步骤 3: 在 Vue 中使用
在你的业务页面 (比如 Sidebar.vue):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <template> <div class="sidebar"> <FileExplorer root-path="D:/MyProject" @open-file="handleOpenFile" /> </div> </template>
<script setup lang="ts"> import FileExplorer from '../packages/electron-tree-explorer/src/FileExplorer.vue'
const handleOpenFile = (path: string) => { console.log('用户请求打开文件:', path) // 这里调用你自己的 Tab 系统打开文件 } </script>
|
✅ 方案亮点
- 高度封装:主项目不需要关心
fs.readdir 怎么写,也不需要写 CSS。
- 安全:通过
contextBridge 只暴露了 5 个必要的 API,没有暴露整个 fs 模块。
- VS Code 体验:
- 懒加载:只有点开文件夹才读取子目录,性能好。
- 内联重命名:不是弹窗,而是直接在树上变成输入框。
- 右键菜单:自定义 DOM 菜单,样式统一。
- 可扩展:如果你想发包,只需要把
packages/electron-tree-explorer 用 tsup 打包,并在 package.json 定义 exports 即可。
如果你的文件目录结构或者 CSS 样式有特殊要求,直接修改这个 packages 目录下的文件即可,不会污染你的业务代码。
Prev: 独占式文件锁定1
Next: 文件比对标红显示