Electron for OpenHarmony 跨平台实战开发:Electron 文件系统操作实战

前言

在文档管理系统里,文件操作是最基础的功能。用户需要创建、读取、更新、删除文件,还要能管理文件夹。这些操作都需要在主进程中完成,因为渲染进程出于安全考虑,不能直接访问文件系统。

所以我们需要通过 IPC(Inter-Process Communication)在主进程和渲染进程之间通信。这篇文章就来说说怎么实现这些文件操作,以及 IPC 通信是怎么工作的。

一、理解 Electron 的进程架构

1.1 主进程和渲染进程

Electron 应用有两个进程:

  • 主进程:只有一个,负责创建窗口、管理应用生命周期,可以访问 Node.js API 和文件系统
  • 渲染进程:可以有多个,每个窗口对应一个,运行我们的 Vue3 应用,但出于安全考虑,不能直接访问文件系统

渲染进程想要操作文件,必须通过 IPC 告诉主进程,让主进程来执行。

1.2 IPC 通信流程

IPC 通信的基本流程是这样的:

  1. 渲染进程 :调用 window.electronAPI.file.create()
  2. 预加载脚本 :通过 contextBridge 暴露 API,转发到主进程
  3. 主进程 :通过 ipcMain.handle() 接收请求,执行文件操作
  4. 返回结果:主进程把结果返回给渲染进程

这个流程看起来复杂,但用起来其实挺简单的。我们一步步来看。

二、实现文件操作模块

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)}`,
    }
  }
}

这里有几个注意点:

  1. 路径处理 :支持绝对路径和相对路径,如果是相对路径,就拼接到 documents 目录下
  2. 父目录检查:创建文件前先确保父目录存在,避免创建失败
  3. 错误处理:用 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)}`,
    }
  }
}

这个函数做了几件事:

  1. 递归读取:遇到文件夹就递归读取子目录
  2. 过滤文件 :只显示 .md 文件,其他文件忽略
  3. 路径处理:返回相对路径,方便前端使用
  4. 排序:文件夹在前,文件在后,同类型按名称排序


五、注册 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 返回的树形数据转换成前端需要的格式,添加了 idisExpandedisSelected 等字段。

八、最佳实践和注意事项

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 路径安全

处理路径的时候要注意:

  1. 路径规范化 :使用 path.join() 而不是字符串拼接
  2. 路径验证 :防止路径遍历攻击(../
  3. 相对路径和绝对路径:统一处理,避免混乱
typescript 复制代码
// 防止路径遍历
const normalizedPath = path.normalize(filePath)
if (normalizedPath.includes('..')) {
  return { success: false, message: '非法路径' }
}

8.3 性能优化

读取大目录的时候可能会慢,可以考虑:

  1. 懒加载:只读取当前展开的目录
  2. 缓存:缓存目录树结构,减少重复读取
  3. 异步处理:大文件操作放到后台线程

8.4 代码组织

文件操作相关的代码组织建议:

复制代码
electron/
├── file/
│   ├── file-manager.ts    # 文件操作核心逻辑
│   └── file-watcher.ts    # 文件监听(可选)
├── main.ts                # IPC handlers 注册
└── preload.ts             # API 暴露

把文件操作逻辑单独抽出来,主进程文件只负责注册 IPC 。/handlers,代码更清晰。/

总结

这篇文章介绍了 Electron 文件系统操作的完整实现,主要包括:

文件操作是 Electron 应用的基础功能,掌握了这些,就可以在此基础上实现更复杂的功能,比如文件搜索、文件监听、文件同步等。

在实际开发中,可能还会遇到一些问题,比如文件权限、路径编码、大文件处理等,这些都需要根据具体场景来处理。

相关推荐
wordbaby2 小时前
Tanstack Router 文件命名速查表
前端
1024肥宅2 小时前
工程化工具类:模块化系统全解析与实践
前端·javascript·面试
软件技术NINI2 小时前
如何学习前端
前端·学习
weixin_422555422 小时前
ezuikit-js官网使用示例
前端·javascript·vue·ezuikit-js
梓仁沐白2 小时前
CSAPP-Attacklab
前端
郑州光合科技余经理2 小时前
海外国际版同城服务系统开发:PHP技术栈
java·大数据·开发语言·前端·人工智能·架构·php
一行注释2 小时前
前端数据加密:保护用户数据的第一道防线
前端
running up2 小时前
Java集合框架之ArrayList与LinkedList详解
javascript·ubuntu·typescript
纪伊路上盛名在2 小时前
记1次BioPython Entrez模块Elink的debug
前端·数据库·python·debug·工具开发