前言
在文档管理系统里,文件操作是最基础的功能。用户需要创建、读取、更新、删除文件,还要能管理文件夹。这些操作都需要在主进程中完成,因为渲染进程出于安全考虑,不能直接访问文件系统。
所以我们需要通过 IPC(Inter-Process Communication)在主进程和渲染进程之间通信。这篇文章就来说说怎么实现这些文件操作,以及 IPC 通信是怎么工作的。

一、理解 Electron 的进程架构
1.1 主进程和渲染进程
Electron 应用有两个进程:
- 主进程:只有一个,负责创建窗口、管理应用生命周期,可以访问 Node.js API 和文件系统
- 渲染进程:可以有多个,每个窗口对应一个,运行我们的 Vue3 应用,但出于安全考虑,不能直接访问文件系统
渲染进程想要操作文件,必须通过 IPC 告诉主进程,让主进程来执行。
1.2 IPC 通信流程
IPC 通信的基本流程是这样的:
- 渲染进程 :调用
window.electronAPI.file.create() - 预加载脚本 :通过
contextBridge暴露 API,转发到主进程 - 主进程 :通过
ipcMain.handle()接收请求,执行文件操作 - 返回结果:主进程把结果返回给渲染进程
这个流程看起来复杂,但用起来其实挺简单的。我们一步步来看。

二、实现文件操作模块
2.1 文件管理器模块
首先在主进程中创建一个文件管理器模块 electron/file/file-manager.ts,封装所有的文件操作:
typescript
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// 获取文档存储目录
const getDocumentsPath = () => {
// 项目目录下的 documents 文件夹
return path.join(__dirname, '..', '..', 'documents')
}
// 确保目录存在
const ensureDirectoryExists = async (dirPath: string) => {
try {
await fs.access(dirPath)
} catch {
await fs.mkdir(dirPath, { recursive: true })
}
}
这里用了 fs/promises,是 Node.js 的 Promise 版本,用起来比回调函数舒服多了。ensureDirectoryExists 函数用来确保目录存在,如果不存在就创建,避免后续操作出错。
2.2 创建文件
typescript
export const createFile = async (filePath: string, content: string = '') => {
try {
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(getDocumentsPath(), filePath)
// 确保父目录存在
const dir = path.dirname(fullPath)
await ensureDirectoryExists(dir)
// 创建文件
await fs.writeFile(fullPath, content, 'utf-8')
return { success: true, message: '文件创建成功', data: { path: fullPath } }
} catch (error) {
return {
success: false,
message: `创建文件失败: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
这里有几个注意点:
- 路径处理 :支持绝对路径和相对路径,如果是相对路径,就拼接到
documents目录下 - 父目录检查:创建文件前先确保父目录存在,避免创建失败
- 错误处理:用 try-catch 包裹,返回统一的错误格式
2.3 读取文件
typescript
export const readFile = async (filePath: string) => {
try {
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(getDocumentsPath(), filePath)
const content = await fs.readFile(fullPath, 'utf-8')
return { success: true, data: { content, path: fullPath } }
} catch (error) {
return {
success: false,
message: `读取文件失败: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
读取文件比较简单,直接读就行。记得指定编码为 utf-8,否则读出来可能是 Buffer。
2.4 更新和删除文件
更新文件和创建文件差不多,都是写文件:
typescript
export const updateFile = async (filePath: string, content: string) => {
try {
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(getDocumentsPath(), filePath)
await fs.writeFile(fullPath, content, 'utf-8')
return { success: true, message: '文件更新成功', data: { path: fullPath } }
} catch (error) {
return {
success: false,
message: `更新文件失败: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
export const deleteFile = async (filePath: string) => {
try {
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(getDocumentsPath(), filePath)
await fs.unlink(fullPath)
return { success: true, message: '文件删除成功' }
} catch (error) {
return {
success: false,
message: `删除文件失败: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
删除文件用 fs.unlink(),这个 API 名字有点奇怪,但确实是用来删除文件的。






三、实现文件夹操作
文件夹操作和文件操作类似,但有几个区别:
3.1 创建文件夹
typescript
export const createFolder = async (folderPath: string) => {
try {
const fullPath = path.isAbsolute(folderPath)
? folderPath
: path.join(getDocumentsPath(), folderPath)
await ensureDirectoryExists(fullPath)
return { success: true, message: '文件夹创建成功', data: { path: fullPath } }
} catch (error) {
return {
success: false,
message: `创建文件夹失败: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
创建文件夹直接用 ensureDirectoryExists,因为 fs.mkdir() 的 recursive: true 选项可以创建多级目录。
3.2 删除文件夹
删除文件夹要小心,因为文件夹里可能有文件。我们用 recursive: true 选项来递归删除:
typescript
export const deleteFolder = async (folderPath: string) => {
try {
const fullPath = path.isAbsolute(folderPath)
? folderPath
: path.join(getDocumentsPath(), folderPath)
await fs.rmdir(fullPath, { recursive: true })
return { success: true, message: '文件夹删除成功' }
} catch (error) {
return {
success: false,
message: `删除文件夹失败: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
注意:fs.rmdir() 的 recursive 选项在新版本的 Node.js 中已经被废弃,应该用 fs.rm(),但为了兼容性,这里还是用了 rmdir。
3.3 重命名文件或文件夹
重命名用 fs.rename(),文件和文件夹都可以用:
typescript
export const renameItem = async (oldPath: string, newName: string) => {
try {
const oldFullPath = path.isAbsolute(oldPath)
? oldPath
: path.join(getDocumentsPath(), oldPath)
const dir = path.dirname(oldFullPath)
const newFullPath = path.join(dir, newName)
await fs.rename(oldFullPath, newFullPath)
return {
success: true,
message: '重命名成功',
data: { oldPath: oldFullPath, newPath: newFullPath },
}
} catch (error) {
return {
success: false,
message: `重命名失败: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
重命名的时候,新路径是在同一个目录下,只是文件名变了。

四、读取目录树
文件树组件需要显示整个目录结构,所以我们需要一个函数来递归读取目录:
typescript
type DirectoryTreeNode = {
name: string
path: string
type: 'file' | 'folder'
children?: DirectoryTreeNode[]
}
export const readDirectoryTree = async (dirPath?: string) => {
try {
const basePath = dirPath
? path.isAbsolute(dirPath)
? dirPath
: path.join(getDocumentsPath(), dirPath)
: getDocumentsPath()
await ensureDirectoryExists(basePath)
const tree: DirectoryTreeNode[] = []
const items = await fs.readdir(basePath, { withFileTypes: true })
for (const item of items) {
const itemPath = path.join(basePath, item.name)
const relativePath = path.relative(getDocumentsPath(), itemPath)
if (item.isDirectory()) {
// 递归读取子目录
const childrenResult = await readDirectoryTree(itemPath)
if (childrenResult.success && childrenResult.data) {
tree.push({
name: item.name,
path: relativePath,
type: 'folder',
children: childrenResult.data,
})
}
} else if (item.isFile() && item.name.endsWith('.md')) {
// 只显示 Markdown 文件
tree.push({
name: item.name,
path: relativePath,
type: 'file',
})
}
}
// 排序:文件夹在前,文件在后
tree.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1
}
return a.name.localeCompare(b.name)
})
return { success: true, data: tree }
} catch (error) {
return {
success: false,
message: `读取目录失败: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
这个函数做了几件事:
- 递归读取:遇到文件夹就递归读取子目录
- 过滤文件 :只显示
.md文件,其他文件忽略 - 路径处理:返回相对路径,方便前端使用
- 排序:文件夹在前,文件在后,同类型按名称排序


五、注册 IPC Handlers
文件操作模块写好了,接下来要在主进程中注册 IPC handlers,让渲染进程能调用这些功能。
5.1 在主进程中注册
在 electron/main.ts 中:
typescript
import { ipcMain } from 'electron'
import * as fileManager from './file/file-manager'
function registerIpcHandlers() {
// 文件操作
ipcMain.handle('file:readTree', async () => {
return await fileManager.readDirectoryTree()
})
ipcMain.handle('file:create', async (_event, filePath: string, content?: string) => {
return await fileManager.createFile(filePath, content)
})
ipcMain.handle('file:read', async (_event, filePath: string) => {
return await fileManager.readFile(filePath)
})
ipcMain.handle('file:update', async (_event, filePath: string, content: string) => {
return await fileManager.updateFile(filePath, content)
})
ipcMain.handle('file:delete', async (_event, filePath: string) => {
return await fileManager.deleteFile(filePath)
})
// 文件夹操作
ipcMain.handle('folder:create', async (_event, folderPath: string) => {
return await fileManager.createFolder(folderPath)
})
ipcMain.handle('folder:delete', async (_event, folderPath: string) => {
return await fileManager.deleteFolder(folderPath)
})
// 重命名
ipcMain.handle('item:rename', async (_event, oldPath: string, newName: string) => {
return await fileManager.renameItem(oldPath, newName)
})
}
app.whenReady().then(() => {
registerIpcHandlers()
createWindow()
})
ipcMain.handle() 用来注册异步处理器,第一个参数是 channel 名称,第二个参数是处理函数。处理函数可以接收事件对象和参数,返回 Promise。

5.2 在预加载脚本中暴露 API
预加载脚本 electron/preload.ts 负责把主进程的 API 暴露给渲染进程:
typescript
import { ipcRenderer, contextBridge } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
file: {
readTree: () => ipcRenderer.invoke('file:readTree'),
create: (filePath: string, content?: string) =>
ipcRenderer.invoke('file:create', filePath, content),
read: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
update: (filePath: string, content: string) =>
ipcRenderer.invoke('file:update', filePath, content),
delete: (filePath: string) => ipcRenderer.invoke('file:delete', filePath),
},
folder: {
create: (folderPath: string) => ipcRenderer.invoke('folder:create', folderPath),
delete: (folderPath: string) => ipcRenderer.invoke('folder:delete', folderPath),
},
item: {
rename: (oldPath: string, newName: string) =>
ipcRenderer.invoke('item:rename', oldPath, newName),
},
})
contextBridge.exposeInMainWorld() 用来安全地暴露 API 到 window 对象。这样渲染进程就可以通过 window.electronAPI 调用这些方法了。


六、在渲染进程中使用
6.1 类型定义
先用 TypeScript 定义一下 API 的类型,方便使用:
typescript
// src/types/index.ts
declare global {
interface Window {
electronAPI: {
file: {
readTree: () => Promise<{
success: boolean
message?: string
data?: Array<{
name: string
path: string
type: 'file' | 'folder'
children?: unknown[]
}>
}>
create: (filePath: string, content?: string) => Promise<{
success: boolean
message?: string
data?: { path: string }
}>
// ... 其他方法
}
// ... 其他 API
}
}
}
6.2 在 Pinia Store 中使用
在 src/stores/files.ts 中封装文件操作:
typescript
import { defineStore } from 'pinia'
export const useFilesStore = defineStore('files', {
state: () => ({
fileTree: [] as FileTreeNode[],
loading: false,
}),
actions: {
async loadFileTree() {
if (!window.electronAPI) {
console.error('Electron API not available')
return
}
this.loading = true
try {
const result = await window.electronAPI.file.readTree()
if (result.success && result.data) {
this.fileTree = this.convertToFileTreeNodes(result.data)
}
} catch (error) {
console.error('加载文件树失败:', error)
} finally {
this.loading = false
}
},
async createFile(filePath: string, content: string = '') {
if (!window.electronAPI) return
const result = await window.electronAPI.file.create(filePath, content)
if (result.success) {
await this.loadFileTree()
}
return result
},
},
})
这样组件就可以通过 store 来操作文件了,不需要直接调用 window.electronAPI。


七、文件树数据转换
API 返回的数据格式和前端需要的格式可能不一样,需要转换一下:
typescript
convertToFileTreeNodes(
data: Array<{
name: string
path: string
type: 'file' | 'folder'
children?: unknown[]
}>,
parentId: string | null = null,
): FileTreeNode[] {
return data.map((item, index) => {
const node: FileTreeNode = {
id: item.path || `${parentId || 'root'}-${index}`,
name: item.name,
path: item.path,
type: item.type,
parentId,
isExpanded: false,
isSelected: false,
}
if (item.type === 'folder' && item.children) {
node.children = this.convertToFileTreeNodes(
item.children as Array<{
name: string
path: string
type: 'file' | 'folder'
children?: unknown[]
}>,
node.id,
)
}
return node
})
}
这个函数递归地把 API 返回的树形数据转换成前端需要的格式,添加了 id、isExpanded、isSelected 等字段。
八、最佳实践和注意事项
8.1 错误处理
文件操作可能会失败,比如文件不存在、权限不足等。我们统一返回 { success: boolean, message?: string } 格式,方便前端处理:
typescript
try {
// 文件操作
return { success: true, data: result }
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : String(error),
}
}
8.2 路径安全
处理路径的时候要注意:
- 路径规范化 :使用
path.join()而不是字符串拼接 - 路径验证 :防止路径遍历攻击(
../) - 相对路径和绝对路径:统一处理,避免混乱
typescript
// 防止路径遍历
const normalizedPath = path.normalize(filePath)
if (normalizedPath.includes('..')) {
return { success: false, message: '非法路径' }
}
8.3 性能优化
读取大目录的时候可能会慢,可以考虑:
- 懒加载:只读取当前展开的目录
- 缓存:缓存目录树结构,减少重复读取
- 异步处理:大文件操作放到后台线程
8.4 代码组织
文件操作相关的代码组织建议:
electron/
├── file/
│ ├── file-manager.ts # 文件操作核心逻辑
│ └── file-watcher.ts # 文件监听(可选)
├── main.ts # IPC handlers 注册
└── preload.ts # API 暴露
把文件操作逻辑单独抽出来,主进程文件只负责注册 IPC 。/handlers,代码更清晰。/

总结
这篇文章介绍了 Electron 文件系统操作的完整实现,主要包括:
文件操作是 Electron 应用的基础功能,掌握了这些,就可以在此基础上实现更复杂的功能,比如文件搜索、文件监听、文件同步等。
在实际开发中,可能还会遇到一些问题,比如文件权限、路径编码、大文件处理等,这些都需要根据具体场景来处理。