HelloElectron—文件的打开与保存(六)

647 阅读10分钟

Hello大家好,好久不见了今天与大家聊一聊桌面端最基础的功能,文件的打开与保存。相比浏览器的纯沙盒环境在Electron中我们可以调用NodeAPI相关的本地功能,直接访问磁盘等功能。今天我们就简单给大家展示下如果通过原生API来打开、保存文件吧 GitHub项目地址:

本次示例的所有代码:文件打开与保存 · Jakentop/plantuml-editor@86ba24b (github.com)

Electron打开文件

使用Electron打开文件,在官方的中文文档中也提供了示例主要是通过dialog.showOpenDialogSync这个方法实现的,当我们选中文件后,该方法会返回此文件的路径等信息;接下来我们只需要使用NodeJS API fs即可完成文件的读取操作了。然而由于这些调用都是Electron或者Node的API因此这里有避不开进程间通信了(IPC),我们需要从主进程发送事件到渲染进程中,事件中包括了读取到内存中的数据以及文件元信息等,话不多说我们开始讲下这该如何实现。

梳理下实现流程

我们可以参考这张图示,通过menu触发文件打开事件,同时选中文件后fs开始读取文件内容到内存中。当数据读取成功后,就通过IPC通信由主进程send事件到渲染进程中。渲染进程拿到数据后更新pinia维护的editor状态,最终实现文件的打开。

文件打开实现代码

打开和读取文件 utils/index.ts

// new src/main/utils/index.ts

import * as fs from 'fs/promises'
import { dialog } from 'electron'

/**
 * 文件读取返回的类型,包括路径和数据
 */
export interface IFileInfo {
  filePath?: string
  data: string
}
/**
 * 文件操作工具类
 */
export const fileUtils = {
  /**
   * 打开文件,并读取文件的text
   * @returns 读取到的text文件
   */
  readFile: async (): Promise<IFileInfo> => {
    const { canceled, filePaths } = await dialog.showOpenDialog({})
    let filePath = ''
    if (!canceled)
      filePath = filePaths[0]
    const data = await fs.readFile(filePath)
    return { filePath, data: data.toString() }
  },
}

  • 在main模块下创建了一个utils工具类,该类今后会存放一些通用的工具例如:文件操作等
  • 定义了一个fileUtils类,其中的方法都是与文件操作相关的。
  • IFileInfo注意该类型,包括了文件的数据和文件的path。path我们会在保存阶段使用
  • dialog.showOpenDialog此方法是一个异步方法,会返回一个Promise其中包括了是否取消canceled以及选中的文件路径filePaths等变量
  • 注意另外一个dialog.showOpenDialogSync是一个同步方法,意味着在打开阶段主进程是无法响应其他操作的。
  • 再获取到filePath我们直接调用fs.readFile()方法来读取这个文件的内容
  • 最后将他们返回出去即可完成文件的读取了
Node 'fs'和'fs/promise'

fs是Nodejs的文件系统模块,他主要负责对操作系统的IO进行封装。传统的fs模块他支持同步和异步的方式比较原始,类似于JQuery的那套回调的方式,因此没有引入Promise这种ES6的新特性。因此在后来官方也提供了fs/promise模块是的Nodejs的文件IO可以支持Promise这种异步的方式。对于fs文件IO的快速入门大家可以看这篇文章:nodejs中的文件系统 - 腾讯云开发者社区-腾讯云 (tencent.com)

我们这里的代码示例中通过import * as fs from 'fs/promises'这种写法可以在业务代码中直接使用fs.*来调用Promise的API

注册主进程->渲染进程事件

这里我们改动了event文件,上一篇我们把主进程发送的消息直接写在了menu中,但是长久来看menu应该只保留界面和菜单中的配置,将业务代码侵入显然是不合理的。因此我们在event文件中添加了一个mainEvent对象。

// 修改 src/main/main.window.ts
 menu.append(new MenuItem({
   label: '操作',
   submenu: [
     {
       label: '打开',
       click: () => mainEvent.commonFileOpen(window),
       accelerator: process.platform === 'darwin' ? 'Cmd+o' : 'Ctrl+o',
     },
     {
       label: '查询',
       click: () => window.webContents.send('vevent:uml:update'),
       accelerator: process.platform === 'darwin' ? 'Cmd+Enter' : 'Ctrl+Enter',
     },
   ],
 }))

// 新增 src/main/event/index.ts
import { BrowserWindow, ipcMain } from 'electron'
import { fileUtils } from '@main/utils'
export const mainEvent = {
  /**
   * 从主进程打开文件
   * @param window 窗口对象
   */
  commonFileOpen: async (window: BrowserWindow): Promise<void> => {
    const file = await fileUtils.readFile()
    window.webContents.send('common:openFile', file)
  },
  /**
   * 从主进程发送保存通知
   */
  commonFileSavePre: async (window: BrowserWindow): Promise<void> => {
    window.webContents.send('common:openFile:pre')
  },
}
  • 在这里的commonFileOpen方法会先去获取文件的数据流,其次想渲染进程发送数据
  • 我们发送的事件名称为common:openFile,因此在渲染进程中我们也需要注册该事件的回调
// src/preload/index.ts
contextBridge.exposeInMainWorld('electronAPI', <IElectronAPI>{
  onVEventUmlUpdate: callback => ipcRenderer.on('vevent:uml:update', callback),
  /**
   * 通用事件注册,监听主进程发来的消息
   * @param eventName 事件名称
   * @param callback 回调方法
   */
  onVEventRegister: (eventName: string, callback: (event: IpcRendererEvent, ...args: any[]) => void): IpcRenderer => ipcRenderer.on(eventName, callback),
})

// src/render/views/WorkSpace.vue

// 注册打开文件的事件回调
window.electronAPI.onVEventRegister('common:openFile', (_event: IpcRendererEvent, file: IFileInfo) => {
  editor.value.setValue(file)
  update()
})
  • 对于preload脚本我们定义了一个通用的注册回调的方法给window
    • 这是由于主进程提供给渲染器进程的事件有很多因此我们没有必要去给每一个都写一个独立的方法
    • 当然这样也会有缺陷,即在渲染器进程中调用时会导致TS的类型缺失(因为我们定义了一个any的callback类型)
  • 通过这种方式就可以完成事件的注册啦

渲染进程改动

在之前我们讲渲染页面中编辑器的结构分为了三层分别是:Editor.vue -> EditorStore.ts -> Monaco-Editor API。对于Editor组件层对外暴露各种编辑器的API方法(通过ref和defineExpose);EditorStore则是利用Pinia做状态管理的中间层,他屏蔽了直接调用MonacoAPI,同时他会缓存一些响应式的内容;而Monaco-Editor API则只有EditorStore才可以直接调用。

这样做的好处在于:既可以让Monaco通过Pinia实现一些简单的响应式功能,同时也可以让MonacoEditor与Editor组件完全解耦。如果今后我们遇到了更好的编辑器(获取不会遇到了)也许替换起来比较方便。

EditorStore改动

了解了为什么要这样做后,我们直接聊下这次对EditorStore的改动吧:

  • 首先添加了setValueaction,支持set文件路径和文件的内容
  • 其次在state中加上了editorValuecurFilePath分别保存当前编辑器的值以及当前编辑器打开的path
  • 添加了flushaction,该方法的行为是手动同步MonacoEditor与中间层中的数据
// src/render/store/EditorStore.ts

// 编辑器状态管理
import { defineStore } from 'pinia'
import { toRaw } from 'vue'

export default defineStore('editor', {
  state: () => {
    return {
      editor: null, // 当前编辑器实例
      editorValue: '', // 当前编辑器的内容
      curFilePath: '', // 当前打开的文本路径
    }
  },
  actions: {
    /**
     * 设置编辑器value
     * @param data 需要设置的值
     */
    setValue(data: string, filePath?: string): void {
      toRaw(this.editor)?.setValue(data)
      this.editorValue = data
      this.curFilePath = filePath
    },
    /**
     * 刷新编辑器和store的状态
     */
    flush() {
      this.editorValue = toRaw(this.editor)?.getValue()
    },
  },
})

Editor.vue、WorkSpace.vue改动
// render/views/WorkSpace.vue
// 注册打开文件的事件回调
window.electronAPI.onVEventRegister('common:openFile', (_event: IpcRendererEvent, file: IFileInfo) => {
  editor.value.setValue(file)
  update()
})
// render/components/workspace/Editor.vue
function setValue(file: IFileInfo) {
  editorStore.setValue(file.data, file.filePath)
}

Electron文件保存功能

至此我们就实现了简单的文件打开功能,除了打开之外,我们还需要实现文件的保存,基本的诉求是对于通过打开的文件调用保存则直接把新的数据写入文件;而对于不是打开的文件则弹出保存对话框,待用户选择保存位置后再写入到指定位置中下面是示例

  • 保存通过打开窗口的文件

  • 保存新建的文件

实现流程

由于Electron并没有直接实现主进程和渲染进程的双向通信,因此我们相当于需要发送两次事件来实现。下面的示例我们直接定义了一个主进程->渲染进程的事件和一个渲染进程->主进程的事件。当然根据官方文档,我们其实可以直接调用event.sender.send()方法而不需要通过preload在注册一个事件的代理了。 此外我们在保存成功后,主进程还需要给渲染返回保存的路径,渲染进程中需要刷新EditorStore的状态

代码改动

主进程

// src/main/main.windows.ts
/**
 * 初始化界面
 * @param window windows对象
 * @returns 返回menu对象
 */
function initMenu(window: BrowserWindow): Menu {
  const menu = new Menu()
  menu.append(new MenuItem({
    label: '操作',
    submenu: [
      {
        label: '保存',
        click: () => mainEvent.commonFileSavePre(window),
        accelerator: process.platform === 'darwin' ? 'Cmd+s' : 'Ctrl+s',
      },
    ],
  }))
  Menu.setApplicationMenu(menu)
  return menu
}

// src/main/event/index.ts
export const mainEvent = {
  /**
   * 从主进程发送保存通知
   */
  commonFileSavePre: async (window: BrowserWindow): Promise<void> => {
    window.webContents.send('common:fileSave:pre')
  },
}
/**
 * 初始化渲染器待注册的事件
 */
export const initEvent = () => {
  /**
   * 获取保存事件(渲染器发出)并存储
   */
  ipcMain.handle('common:fileSave', async (_event, data: string, filePath?: string) => {
    console.log(data, filePath)
    return await fileUtils.saveFile(data, filePath)
  })
}

// src/main/utils/index.ts

/**
 * 文件操作工具类
 */
export const fileUtils = {
  /**
   * 全量保存编辑器中的text文件
   * @param data 需要保存的文本文件
   * @param curPath 如果存在则指定了保存路径
   * @return 保存的filePath
   */
  saveFile: async (data: string, curPath?: string): Promise<string> => {
    if (curPath) {
      await fs.writeFile(curPath, data)
      return curPath
    }
    else {
      const { canceled, filePath } = await dialog.showSaveDialog({})
      if (!canceled)
        await fs.writeFile(filePath, data)
      return filePath
    }
  },
}

// src/preload/index.ts
contextBridge.exposeInMainWorld('electronAPI', <IElectronAPI>{
  /**
   * 渲染器通知主进程保存文件
   * @param data editor中的数据
   * @param filePath (可选)需要保存的路径,没有则触发选择路径
   */
  commonFileSave: async (data: string, filePath?: string) => await ipcRenderer.invoke('common:fileSave', data, filePath),

})
  • dialog.showSaveDialog此方法是保存文件的对话框,相比打开文件对话框保存文件对话框允许用户设置一个不存在的文件名称并且新建他
  • 同时在这里我们注册了两个事件分别是:common:fileSave:precommon:fileSave第一个事件名称是用来通知渲染进程将保存的数据通过第二个事件名称传递给主进程的
  • 传递完成后就会执行event中的ipcMain.handle('common:fileSave',...)方法,该方法调用了fileUtils执行文件保存,同时有吧fileUtils中返回的保存路径传递给渲染器(调用方)

渲染进程

// render/store/EditorStore.ts
import { defineStore } from 'pinia'
export default defineStore('editor', {
  state: () => {
    return {
      editor: null, // 当前编辑器实例
      editorValue: '', // 当前编辑器的内容
      curFilePath: '', // 当前打开的文本路径
    }
  },
  actions: {
    /**
     * 设置新的路径
     * @param path 新的路径
     */
    setCurFilePath(path: string): void {
      this.curFilePath = path
    },
  },
})

// render/components/workspace/Editor.vue

// 添加设置当前Editor的路径功能
function setCurFilePath(path: string) {
  editorStore.setCurFilePath(path)
}
defineExpose({ setCurFilePath })

// render/views/WorkSpace.vue

// 注册保存文件预处理回调
window.electronAPI.onVEventRegister('common:fileSave:pre', async () => {
  const data = editor.value.getValue()
  if (data) {
    const newPath = await window.electronAPI.commonFileSave(data, editor.value.getCurFilePath())
    if (newPath !== editor.value.getCurFilePath())
      editor.value.setCurFilePath(newPath)
  }
})
  • 渲染进程这块改动并不多,就是常规的事件调用
  • 主要是给EditorStore添加了一个设置当前路径的能力

总结

本篇主要与大家阐述了如何使用Electron的对话框,同时对于对话框的一些特殊参数,大家可以参考对应的API文档。同时也与大家演示了如何使用NodeAPI的fs模块,建议配合GitHub中相应的提交一同食用,当然其实这里还是留下了很多问题:比如使用打开功能后就不可以新建文件了,同时也不知道当前的状态是否是新建文件还是打开的文件,同时还有一些优化问题例如保存文件我们其实不需要这么麻烦的定义事件。另外Electron从渲染器到主进程的双向通信还有别的方案可以替代。

因此我会在后续写一篇该功能的补充版,将今天的这些坑填上。最后如果有任何疑问也欢迎大家在评论区讨论和给出建议。