likes
comments
collection
share

Electron 入门 06 | 以记事本为例:运用 Menu 和 File 配置 Electron 菜单及新建文档

作者站长头像
站长
· 阅读数 23

今天我们以记事本为例,将其界面雏形给配置出来,并且为其实现“新建”的功能。由于 Electron 帮我们做好了原生的必要工作,我们配合一些简单的 js 就能实现窗口和菜单的基本界面显示,本讲的演示代码我们尽量会使用原生的 js,避免使用其他第三方库影响大家的理解。

我们知道记事本需要完成一些基本功能包括新建文件、打开文件、保存文件等。因此我们需要打开一个窗口,并且设置其顶部菜单里面包括这些操作入口。下面加入窗口和菜单相关逻辑。

窗口定制

默认打开的窗口是比较原始的,我们为默认的窗口进行一些基本设置以及 ui 的调整。首先对背景色、宽高、缩放、拖拽、最大最小化等基本的进行设置。另外窗口的默认宽高我们用屏幕的大小的百分比(宽 80%,高 80%)来设置(利用 screen 模块获取屏幕相关信息),注意宽高需要是整数,否则会不生效。这样用户打开软件时的界面尺寸能更加舒适,同时我们设置一下窗口的能缩放的最小宽高,避免用户将窗口可以缩小到 0。注意 Electron 默认情况下是禁用了在渲染进程使用 nodejs 和 remote 模块的,我们这里将设置放开。如下面的局部代码:

// main.js
const { app, BrowserWindow, screen } = require('electron')
function createWindow () {
  // 获取屏幕的宽高
  const { width, height } = screen.getPrimaryDisplay().workAreaSize
  const win = new BrowserWindow({
    title: '记事本', // 窗口的标题,如果打开的 html 中有设置 title,此属性将忽略
    backgroundColor: '#f7f7f7', // 设置窗口默认背景是浅灰色
    width: parseInt(width * 0.8),
    height: parseInt(height * 0.8),
    minWidth: 400, // 最小宽度
    minHeight: 300, // 最小高度
    resizable: true, // 允许调整尺寸
    movable: true, // 允许拖动
    minimizable: true, // 允许最小化
    maximizable: true, // 允许最大化
    webPreferences: {
      nodeIntegration: true, // 允许渲染进程访问 nodejs api,如 后面将要使用的 fs 模块
      enableRemoteModule: true // 允许在渲染进程使用 remote 模块
    }
  })
  win.loadFile('dist/index.html') // 根据自己的构建目录修改
  return win
 }

上面的一些参数也可以通过 api 动态设置,如最大最小化也可以用下面的方式:

win.setMaximumSize(400, 300)

菜单定制

接下来,我们为窗口添加顶部菜单项,我们看一下菜单主要选项为 4 个主菜单,各自下面有一些子菜单,并且有分割线隔开。

  • 文件 (新建、打开、保存、退出)
  • 编辑 (撤销 undo、重做 redo、复制 copy、剪切 cut、粘贴 paste)
  • 视图 (全屏切换 Toggle Full Screen)
  • 关于 (主页 Homepage)

Electron 入门 06 | 以记事本为例:运用 Menu 和 File 配置 Electron 菜单及新建文档

windows 效果

Electron 入门 06 | 以记事本为例:运用 Menu 和 File 配置 Electron 菜单及新建文档

mac 效果

注:mac 的开发环境下,第一项菜单名固定是 Electron,打包后就会按设置显示,因此开发时暂时可以忽略显示

前面章节的 api 已经介绍,我们知道菜单是通过 Electron 的 Menu 和 MenuItem 来实现的,下面我们来看看如何去具体设置。

首先我们需要把整个菜单的参数配置出来,比如名称、快捷方式、点击事件等。它是一个数组如下所示:

// main.js
const menuTemplate = [    {      label: '文件',      submenu: [        { label: '新建', accelerator: 'CmdOrCtrl+N', click: () => {}},        { label: '打开', accelerator: 'CmdOrCtrl+O', click: () => {} },        { label: '保存', accelerator: 'CmdOrCtrl+S', click: () => {} },        { label: '退出', click: () => {} },      ]
    },
    {
      label: '编辑',
      submenu: [
        { label: '撤销', click: () => {}},
        { label: '重做', click: () => {}},
        { label: '复制', click: () => {}},
        { label: '剪切', click: () => {}},
        { label: '粘贴', click: () => {}},
      ]
    },
    {
      label: '视图',
      submenu: [
        { label: '全屏切换', click: () => {}},
      ]
    },
    {
      label: '关于',
      submenu: [
        { label: '主页', accelerator: 'CmdOrCtrl+N', click: () => {}}
      ]
    }
]

其中

  • label 是菜单项的名称;
  • submenu 为下面的子菜单;
  • accelerator 表示可以通过键盘的组合快捷键触发,也可以不设置,里面的 CmdOrCtrl 是 Electron 提供的兼容写法,表示在 windows 环境就是 Ctrl 键,在 Mac 环境就是 Cmd 键;
  • click 表示点击后触发的函数,可以由我们来决定点击后的业务逻辑 。

其实像复制粘贴这类功能是很多软件都比较通用的一些功能,如果我们自己去监听点击事件后实现是比较麻烦的,而 Electron 已经帮我们实现了这类通用性功能,所以这里我们用 Electron 提供的 role 字段来使用 Electron 内置的功能即可,而不用去为每个功能实现 click 的处理事件。菜单中的 “编辑” 和 “视图” 里的子菜单功能基本都已经有现成的实现了,如复制对应的 role = copy,粘贴对应的 role = paste。当然包括他们对应的通用快捷键我们也不需要去专门实现了,我们只要指定了对应的 role 就可以了。

另外子菜单之间还有部分分割线,这个只需要在菜单项中增加一项 type: 'separator',Electron 就会给我们展示出分割线。

综合上面两点,我们对 menuTemplate 的配置进行部分修改,然后调用 Menu 模块的静态方法 buildFromTemplate,把上面的配置参数传递进去,这样就构造好了我们的 menu,最后通过 Menu 的静态方法 setApplicationMenu 将构造好的 menu 菜单设置到应用上即可。完整代码为

// main.js
const { app, BrowserWindow, Menu, screen } = require('electron')
function createWindow () {
  const win = new BrowserWindow({
    title: '记事本', // 窗口的标题,如果打开的 html 中有设置 title,此属性将忽略
    backgroundColor: '#f7f7f7', // 设置窗口默认背景是浅灰色
    width: parseInt(width * 0.8),
    height: parseInt(height * 0.8),
    minWidth: 400, // 最小宽度
    minHeight: 300, // 最小高度
    resizable: true, // 允许调整尺寸
    movable: true, // 允许拖动
    minimizable: true, // 允许最小化
    maximizable: true, // 允许最大化
    webPreferences: {
      nodeIntegration: true, // 允许渲染进程访问 nodejs api
      enableRemoteModule: true // 允许在渲染进程使用 remote 模块
    }
  })
  // 菜单配置项
  const menuTemplate = [
  {
      label: '文件',
      submenu: [
        { label: '新建', accelerator: 'CmdOrCtrl+N', click: () => {}},
        { label: '打开', accelerator: 'CmdOrCtrl+O', click: () => {} },
        { label: '保存', accelerator: 'CmdOrCtrl+S', click: () => {} },
        { type: 'separator' }, // 分割线
        { label: '退出', click: () => {} },
      ]
    },
    {
      label: '编辑',
      submenu: [
        { label: '撤销', role: 'undo'},
        { label: '重做', role: 'redo'},
        { type: 'separator' }, // 分割线
        { label: '复制', role: 'copy'},
        { label: '剪切', role: 'cut'},
        { label: '粘贴', role: 'paste'},
      ]
    },
    {
      label: '视图',
      submenu: [
        { label: '全屏切换', role: 'togglefullscreen',
      ]
    },
    {
      label: '关于',
      submenu: [
        { label: '主页', alick: () => {}}
      ]
    }
  ]
  Menu.setApplicationMenu(menu); // 应用菜单
  win.loadFile('dist/index.html') // 根据自己的构建目录修改
  return win
}

上面我们只是用到了 Menu 模块通过配置参数来实现的整体菜单。相当于 Menu 利用我们传入的配置参数自动帮我们创建了 MenuItem。其实这里我们也可以直接使用 MenuItem 来添加子菜单。如下我们开始只初始化几个主菜单,然后通过 MenuItem 实例添加子菜单:

// main.js
const { Menu, MenuItem } = require('electron')
const menuTemplate = [
  {label: '文件'},
  {label: '编辑'},
  {label: '视图'},
  {label: '关于'}
]
const menuItem = new MenuItem({label: '新建', click: () => {}})
menu.items[0].submenu.append(menuItem); // 给第一个主菜单加入子菜单"新建"

关于菜单的配置项一般比较冗长而又相对独立,所以实际项目中,我们经常将配置项独立在另一个 js 文件中,主进程文件 main.js 通过引入的方式来调用,而不是直接写在 main.js 里面。

现在可以运行一下,打开的窗口顶部已经包含了我们预期的菜单。不过除了通过 role 设置的内置功能外,其余的需要处理 click 事件的逻辑需要我们自己根据业务补充进去。也就是“新建”、“打开”、“保存”,以及“退出”和“主页”。

记事本创建

接下来我们就来完成“新建”记事本的功能,也就是主要利用 Nodejs 中的 fs 模块来实现文件的创建。fs 模块主要功能是来操作文件和文件夹的,前面也有讲过。除了需要创建文件,我们还需要有文件夹选择对话框来供用户选择本地文件夹目录保存文件,这里就可以用前面介绍过的 Electron 的对话框 dialog 模块来实现,dialog 主要提供与文件路径有关的弹窗选择功能。

方案分析

用户点击菜单中的“新建”时,会触发我们绑定的 click 函数,那么我们只需要在 click 函数中调用 fs 模块的 writeFile 或 writeFileSync 创建一个新的文件即可,但是这样我们并不知道用户创建的记事本名称是什么,因此需要给用户一个输入框输入记事本的名字。这里就需要在渲染进程也就是我们的窗口中处理了。所以最终是我们在主进程中监听到用户“新建”的点击事件,然后通知渲染进程(窗口 window),渲染进程给用户一个输入框,用户输入完成后,再将信息传递给主进程来创建文件,这时主进程通过 dialog 弹出文件目录选择框,用户选择后保存文件,所以这里还需要用到主进程和渲染进程的通信。

主进程通信逻辑

首先我们建立渲染进程和主进程的通信处理逻辑,让它们能相互发送消息并且区分消息分类,根据前面的分析,主进程需要发送一个“新建”的消息,我们定义消息为 main-message,这个名称可以随便取,这个主要是为了识别各种不同消息,实际项目中我们一般可以加一些统一前缀避免消息类型过多导致维护不变。然后还需要接收一个渲染进程发送过来的带用户输入名称的“创建”消息,我们定义为 render-message。同时我们还需要一个 type 字段表示操作分类和 data 字段表示数据,在这里主要是表示用户输入的名称。参考下面代码,用户点击“新建”后完善了 click 里面的给渲染进程发送消息的逻辑,以及主进程收到消息后获取到了用户输入的名称(data)。

// main.js 主进程
const { app, ipcMain, BrowserWindow, Menu } = require('electron')
let mainWin // 我们将打开的窗口引用放在外层,方便多个地方使用
function createWindow () {
  const win = new BrowserWindow({
    // ...省略
  })
  // 菜单配置项
  const menuTemplate = [
  {
      label: '文件',
      submenu: [
        { label: '新建', accelerator: 'CmdOrCtrl+N', click: () => {
          //点击新建后向渲染进程发送“新建文件”的命令
          win.webContents.send('main-message', 'create')
        }},
        // ...省略
      ]
    },
    // ...省略
  ]
  const menu = Menu.buildFromTemplate(menuTemplate)
  Menu.setApplicationMenu(menu); // 应用菜单
  win.loadFile('dist/index.html') // 根据自己的构建目录修改
  return win
}


function init() {
  mainWin = createWindow()
}
app.whenReady().then(init) // app read 后初始化创建窗口
ipcMain.on('render-message', (event, type, data) => {
  switch(type) {
    case 'createWithName':
      const fileName = data // 用户输入的文件名称
  }
  console.log(arg) // prints "ping"
})

主进程保存逻辑

收到用户输入的名称后,我们就可以弹出文件目录对话框让用户选择要新建的目录了。实际这里我们可以先不选择目录,而是保存在内存中,等用户后面点击“保存”时再让选择目录,这里为了让学习流程更加顺畅,我们先走保存逻辑,后面再对这里进行调整。

请看下面代码,我们引入 dialog 模块,并用 dialog.showSaveDialog 打开文件夹选择对话框,同时我们将用户设置的文件名设为默认路径(defaultPath),这样打开对话框时就会自动将文件名填充进去,当然我们也可以在这里拼接一下后缀,如.txt。

前面我们已经知道了 showSaveDialog 是返回一个 Promise 对象,所以这里我们在 promise 成功后去获取用户选择的最终路径。(这里也可以用 showSaveDialogSync 来用同步的形式执行)

// main.js 主进程
const { app, BrowserWindow, Menu, dialog } = require('electron')
... // 省略
ipcMain.on('render-message', (event, type, data) => {
  switch(type) {
    case 'createWithName':
      const fileName = data // 用户输入的文件名称
      dialog.showSaveDialog({
          title: '保存位置',
          defaultPath: fileName + '.txt'
        }).then((result) => {
          console.log('用户选择的路径为', result.filePath) // 最终路径会带上文件名+后缀
        })
  }
})

如下图

Electron 入门 06 | 以记事本为例:运用 Menu 和 File 配置 Electron 菜单及新建文档

windows 效果

Electron 入门 06 | 以记事本为例:运用 Menu 和 File 配置 Electron 菜单及新建文档

mac 效果

我们知道文件选择对话框只是让用户选择一个目录,我们拿到最终路径后,还需要我们去真正的创建这个本地文件,这里我们引入 nodejs 的 fs 模块来创建一个空文件。

// main.js 主进程
const { app, BrowserWindow, Menu, dialog } = require('electron')
const fs = require('fs')  // 引入 nodejs fs 模块
... // 省略
ipcMain.on('render-message', (event, type, data) => {
  switch(type) {
    case 'createWithName':
      const fileName = data // 用户输入的文件名称
      dialog.showSaveDialog({
          title: '保存位置',
          defaultPath: fileName + '.txt'
        }).then((result) => {
          if (result.canceled) return // 用户取消操作
          console.log('用户选择的路径为', result.filePath) // 最终路径会带上文件名+后缀
          // 开始创建新文件
          fs.writeFile(result.filePath, '',
            function(err) {
              if(err) {
                  return console.log(err)
              }
              console.log('文件创建成功')
          })
         // 同步写法:fs.writeFileSync(result.filePath, '')
      })
  }
})

如上,主进程接收渲染进程的消息后在本地创建文件的逻辑就完成了。下面我们将进行渲染进程的逻辑补充。

渲染进程交互逻辑

我们先看渲染进程的 html 大致页面,里面包含一个用于输入文件名称的输入框。

<!-- index.html 渲染进程 html-->
<div id="j-name-dialog">
   <div>
      <input type="text" id="j-input" placeholder="请输入名称" />
   </div>
   <div class="btns">
     <button id="j-cancel">取消</button>
     <button id="j-confirm">确定</button>
   </div>
</div>
<script src="render.js"></script>

渲染进程发送的消息和接收的消息就分别是 render-message 和 main-message 了,逻辑相对简单。如下代码就完成了完整功能,这里仅用原生 js 演示,更容易理解,当然实际项目在 vue 或者 react 中原理一样。

// render.js 渲染进程 js
import { ipcRenderer } from 'electron'
function init() {
  // 输入名称弹窗
  const nameDialog = document.getElementById('j-name-dialog')
  // 确定按钮
  const btnConfirm = document.getElementById('j-confirm')
  // 输入框 input
  const input = document.getElementById('j-input')
  // 收到主进程"新建"的消息,显示界面输入框供用户输入
  ipcRenderer.on('main-message', (event, type, data) => {
    switch(type) {
      case 'create': 
        nameDialog.style.display = 'block'
    }
  })
  // 用户输入完成后,点击确定按钮,将消息发送给主进程,并隐藏输入框
  btnConfirm.addEventListener('click', () => {
    const fileName = input.value
    if (fileName) {
      ipcRenderer.send('render-message', 'create-with-name', fileName)
      nameDialog.style.display = 'none'
    } 
  })
}
init()

现在主进程和渲染进程就已经实现了菜单定制和新建并保存文件的功能了。

经验分享:这里我们用到了 nodejs 的 fs 模块来实现文件的创建,实际上还有一个 npm 包 fs-extra 是基于 fs 扩展封装的文件操作包,其功能进行了更加精炼的封装,实际项目中我们基本会使用 fs-extra 来更高效的完成文件相关操作,使用前记得先 install。

remote 模块简化通信(最佳实践)

上面我们已经完成了本讲的整体功能了,接下来我们进行一下优化。由于菜单里面“新建”的点击事件逻辑是在主进程处理的,而让用户输入名称是在渲染进程,因此我们实现了通信处理,这里稍显麻烦,如果项目更加复杂,则通信这块将会冗余更多。还记得前面我们介绍过的 remote 模块吗?它的主要作用是让我们能快速地在渲染进程使用主进程的模块功能,那么这里我们利用 remote 来减少一些通信吧。

我们看渲染进程中,用户点击确定按钮后,发了一个消息给主进程,目的是让主进程弹窗并创建一个本地文件。通过前面的基础介绍我们知道,nodejs 的模块是可以直接在渲染进程中使用的,那么 fs 其实不受限制。另外我们通过前面介绍或官网能知道 app、BrowserWindow、dialog 等主进程的模块能通过 remote 在渲染进程中直接使用 (以下代码是以前版本的逻辑,新版本有变化参考后面内容)

因此我们可以将用户点击确定后的操作直接在渲染进程中执行完成,而不用发送消息给主进程,这样是不是简洁多了。

// render.js 渲染进程 js
import { ipcRenderer, remote } from 'electron'
const fs = require('fs')
function init() {
  // 输入名称弹窗
  const nameDialog = document.getElementById('j-name-dialog')
  // 确定按钮
  const btnConfirm = document.getElementById('j-confirm')
  // 输入框 input
  const input = document.getElementById('j-input')
  // 收到主进程"新建"的消息,显示界面输入框供用户输入
  ipcRenderer.on('main-message', (event, type, data) => {
    switch(type) {
      case 'create': 
        nameDialog.style.display = 'block'
    }
  })
  // 用户输入完成后,点击确定按钮,直接弹出对话框,最后创建本地文件
  btnConfirm.addEventListener('click', () => {
    const fileName = input.value
    if (fileName) {
      // 使用 remote 模块获取 dialog
      const dialog = remote.dialog
      dialog.showSaveDialog({
          title: '保存位置',
          defaultPath: fileName + '.txt'
        }).then((result) => {
          if (result.canceled) return // 用户取消
          fs.writeFile(result.filePath, '',
            function(err) {
              if(err) {
                  return console.log(err)
              }
              nameDialog.style.display = 'none'
              console.log('文件创建成功')
          })
         // 同步写法:fs.writeFileSync(result.filePath, '')
      })
    } 
  })
}
init()

注意,最新版本的 Electron 对 remote 的使用方式进行了调整,不能直接在渲染进程从 electron 中引入了,因为 remote 模块独立出来了。 需要先安装 remote 的模块

npm install --save @electron/remote

然后在主进程进行初始化(在创建window实例之后)

require('@electron/remote/main').initialize()

最后才能在渲染进程直接使用

const remote = require("@electron/remote")  

总结

本讲介绍了实际项目中菜单的配置,然后重点演示了主进程和渲染进程的通信实践过程,加深理解,并且运用了 dialog 和 fs 模块来实现文件的创建流程。最后借助 remote 模块来简化了通信流程。在加深 Electron 的 api 理解同时,也了解到实际项目中的常用方案。可以借助本讲内容举一反三去实践更多其他常见流程。

思考题

你觉得用 fs 实现比较麻烦,但 fs-extra 中封装得特别方便的 api 是什么?