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 应用的基础功能,掌握了这些,就可以在此基础上实现更复杂的功能,比如文件搜索、文件监听、文件同步等。

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

相关推荐
Nan_Shu_61414 分钟前
学习: Threejs (2)
前端·javascript·学习
G_G#22 分钟前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界37 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路1 小时前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug1 小时前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu121381 小时前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中1 小时前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 小时前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
冰暮流星1 小时前
javascript逻辑运算符
开发语言·javascript·ecmascript