使用Electron开发记事本

Electron是一个跨平台的框架,可以用网页语言来开发客户端程序,虽然说每一个应用都是一个Chrome,但毕竟也是方便了我们这些前端开发者做自己的客户端软件的梦想。

这里我为了能使用到最新版本的Electron,并没有选择用electron-vue去作为项目的基础模版,而是在github上着了一个加了webpack功能的模版,输入以下命令开始:

1
git clone git@github.com:szwacz/electron-boilerplate.git life-memory

一、测试模版是否可用

下载好之后,第一步就是将electron的版本更新到v7.1.7看看是否可以正常运行,这里为了避免因为网络问题导致下载失败就直接用cnpm进行安装了。

经过测试,这个模版的运行及打包均没有问题,可以正常执行(Mac环境),这下子可以安心地去开发了。

二、为应用添加菜单

模版中,应用的菜单并不符合记事本的要求,因此需要调整一下。菜单的定义位于src/menu目录下,我们要做两件事,一是为Mac系统的菜单腾出第一个位置,二是补充自己需要的菜单项。下面是我的菜单项定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// file_menu_template.js

import { dialog } from 'electron'
import log from 'electron-log'

export const fileMenuTemplate = {
label: '文件',
submenu: [
// {
// label: '新建',
// accelerator: 'CmdOrCtrl+N'
// },
{
label: '打开',
accelerator: 'CmdOrCtrl+O',
click: openFile
},
{
type: 'separator'
},
{
label: '保存',
accelerator: 'CmdOrCtrl+S',
click: saveFile
},
{
label: '另存为',
accelerator: 'CmdOrCtrl+Shift+S',
click: saveAsFile
}
]
}

export const macAppMenuTemplate = {
label: '生活记',
submenu: [
{
label: '退出',
role: 'quit'
}
]
}

/**
* 打开文件
* @param {MenuItem} menuItem 菜单项
* @param {BrowserWindow} browserWindow 渲染进程窗口
* @param {Event} event 事件
*/
function openFile(menuItem, browserWindow, event) {
dialog.showOpenDialog(browserWindow, {
title: '打开文件',
filters: [
{ name: 'Markdown 文件', extensions: ['md', 'markdown'] },
{ name: '文本文件', extensions: ['txt'] }
],
properties: ['openFile']
}).then(dialogRes => {
if (!dialogRes.canceled) {
// 向当前获取焦点的窗口发送事件
if (browserWindow) {
browserWindow.webContents.send('lm-open-file', dialogRes.filePaths)
}
}
}).catch(e => {
log.error(e)
})
}

/**
* 保存文件
* @param {MenuItem} menuItem 菜单项
* @param {BrowserWindow} browserWindow 渲染进程窗口
* @param {Event} event 事件
*/
function saveFile(menuItem, browserWindow, event) {
if (browserWindow) {
browserWindow.webContents.send('lm-save-file')
}
}

/**
* 另存为
* @param {MenuItem} menuItem 菜单项
* @param {BrowserWindow} browserWindow 渲染进程窗口
* @param {Event} event 事件
*/
function saveAsFile(menuItem, browserWindow, event) {
if (browserWindow) {
browserWindow.webContents.send('lm-save-as-file')
}
}

因为第一个位置暂时也不需要添加其他内容,所以我就没有将其拆分出去,而是和文件菜单放在一个文件里了。

写这篇文档时项目已经完成,所以这个文档的代码中会包含一些现在用不到的代码,见谅见谅~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// edit_menu_template.js

export const editMenuTemplate = {
label: "编辑",
submenu: [
{ label: "撤销", accelerator: "CmdOrCtrl+Z", click: undo },
{ label: "重做", accelerator: "Shift+CmdOrCtrl+Z", click: redo },
{ type: "separator" },
{ label: "剪切", accelerator: "CmdOrCtrl+X", selector: "cut:" },
{ label: "复制", accelerator: "CmdOrCtrl+C", selector: "copy:" },
{ label: "粘贴", accelerator: "CmdOrCtrl+V", selector: "paste:" },
{ label: "全选", accelerator: "CmdOrCtrl+A", click: selectAll }
]
};

/**
* 选择全部
* @param {MenuItem} menuItem 菜单项
* @param {BrowserWindow} browserWindow 渲染进程窗口
* @param {Event} event 事件
*/
function selectAll(menuItem, browserWindow, event) {
if (browserWindow) {
browserWindow.webContents.send('lm-select-all')
}
}

/**
* 撤销
* @param {MenuItem} menuItem 菜单项
* @param {BrowserWindow} browserWindow 渲染进程窗口
* @param {Event} event 事件
*/
function undo(menuItem, browserWindow, event) {
if (browserWindow) {
browserWindow.webContents.send('lm-undo')
}
}

/**
* 重做
* @param {MenuItem} menuItem 菜单项
* @param {BrowserWindow} browserWindow 渲染进程窗口
* @param {Event} event 事件
*/
function redo(menuItem, browserWindow, event) {
if (browserWindow) {
browserWindow.webContents.send('lm-redo')
}
}

编辑菜单里面基本上都是对文档内容的快捷操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// help_menu_template.js

import { app, shell } from "electron";
import jetpack from "fs-jetpack";

const appDir = jetpack.cwd(app.getAppPath());
const manifest = appDir.read("package.json", "json");

export const helpMenuTemplate = {
label: '帮助',
submenu: [
{
label: '学习Markdown语法',
click: function (item, focusedWindow) {
// 打开外部文档
shell.openExternal('https://www.runoob.com/markdown/md-tutorial.html')
}
},
// {
// label: '帮助'
// },
{
label: '关于',
submenu: [
{
label: '版本 v' + manifest.version,
enabled: false
}
// {
// label: '更新记录'
// }
]
}
]
}

帮助菜单中则是将应用的版本号显示出来,另外还有一个开发时显示的菜单,那个菜单只需要去掉退出应用的菜单项即可。

菜单定义之后,在background.js中,我们需要将新增的菜单定义加入,并稍微修改一下逻辑,让Mac系统下的菜单列表前面增加一个占位的菜单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { devMenuTemplate } from "./menu/dev_menu_template";
import { editMenuTemplate } from "./menu/edit_menu_template";
import {
macAppMenuTemplate,
fileMenuTemplate
} from "./menu/file_menu_template";
import { helpMenuTemplate } from "./menu/help_menu_template";

const setApplicationMenu = () => {
const menus = [fileMenuTemplate, editMenuTemplate, helpMenuTemplate];
if (process.platform === "darwin") {
menus.unshift(macAppMenuTemplate);
}
if (env.name === "development") {
menus.push(devMenuTemplate);
}
Menu.setApplicationMenu(Menu.buildFromTemplate(menus));
};

此时运行程序,我们定义的菜单就会如期显示出来,接下来要让程序对用户点击菜单项做出响应,则需要在菜单定义中定义click函数,普通的菜单点击,我们只需要将事件发送到当前聚焦的窗口,让它去处理这个事件即可。

不过这里就涉及到主线程主动向渲染进程发送消息的知识了,在上面的代码中我们也可以看到,我们需要拿到browserWindow的实例,然后获取到它的webContents对象,然后就可以向其发送消息了。而渲染进程要接受消息,则是通过ipcRenderer去获取,这一点官方文档已经讲得很详细了,我就不再细说了。

还有一种更为复杂的情况,以打开文件为例,当用户点击打开时,程序应该弹出窗口询问用户要打开哪个文件。而对话框只能由主线程来操作,当前菜单点击的处理线程正是主线程,你不可能说把事件传给渲染进程,再让渲染进程把打开对话框的事件传给你。所以最好还是直接就在这里弹出对话框,将用户选择的文件交给渲染进程处理就好了。这段代码在上面的菜单定义中也有提及。

三、为应用添加日志记录功能

软件开发过程中,不可避免会遇到bug,而当bug到达用户那里时,身为开发者的你是不好去调试的。所以日志记录就显得尤为关键,还好electron生态中有比较好用的electron-log可以使用。我的用法比较简单,就在主线程中修改了日志记录的格式,后面因为全局共享一个实例,所以其他地方就不用去修改配置了,直接引入这个包即可。

1
2
3
4
5
6
7
8
import log from "electron-log";

// 修改日志记录的格式
log.transports.console.format =
"[{h}:{i}:{s}.{ms}] [{level} {processType}] › {text}";
log.transports.file.format =
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}";
log.debug("path of user data: ", app.getPath("userData"));

在启动时我还去打印了一下用户数据的存放位置,方便以后排查问题。

四、其他关键点记录

其实我的最终目标不是开发一个简单的记事本,所以我在项目中引入了CodeMirror这个插件,引入的时候还是遇到了一些问题的,下面是我的解决方案:

首先在js文件中实例化CodeMirror:

1
2
3
4
5
6
7
import CodeMirror from "codemirror/lib/codemirror";
import "codemirror/mode/markdown/markdown";

this.editor = CodeMirror.fromTextArea(
document.getElementById(textareaId),
editorOptions
);

到这一步还是正常的,可当我要引入它的CSS文件时,它就报错了,我也不知道为什么。但最后想出了一个解决办法,就是把css文件在html中引入:

1
2
3
<!-- app/app.html -->

<link rel="stylesheet" href="../node_modules/codemirror/lib/codemirror.css">

这样做之后基本上就没什么问题了,关于记事本的代码其实很简单,逻辑也不复杂,就不贴出来献丑了。

另外就是涉及到文件读写时,node的文件系统读写结果是通过回调函数来获取的,我觉得用起来很不爽,就写了一个工具类把它包装了一下,让它返回Promise对象。然后我用的时候就可以愉快的用async/await了~

最后一点就是我比较喜欢用scss去写样式文件,所以需要自己配置一下scss的编译方式,首先需要安装sass:

1
cnpm i sass node-sass sass-loader --save-dev

安装之后,找到build/webpack.base.config.js,在rules中添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
test: /\.scss$/,
use: [
{
loader: 'style-loader'
},
{
loader: 'css-loader'
},
{
loader: 'sass-loader'
}
]
}

这样就可以解析scss文件了。

五、打包相关

1、图标

应用做好之后,我们需要为其准备一个精美的图标,而在不同的系统上使用的图标类型是不同的,因此我们在得到一张1024x1024.png的图标之后,还需要为windows平台生成.ico格式的图标,此时我们可以在https://www.easyicon.net/language.en/covert/这个网站去转换。

若要为Mac平台生成.icns的图标则没有那么简单,因为icns格式并不是一个图标,而是包含不同分辨率图标的集合,我们需要一个一个的生成然后再去转换。

我就填了这个坑,我在刚刚的网站把转换好的一张icns格式的图片放到项目中打包,结果打包后的应用是没有图标的!

转换icns并没有网站可以帮我们做,我们只能在mac电脑中敲命令来做。具体可以参考这个博客,需要注意的是刚开始创建的目录,后面的.iconset不能省,前面的名字可以随便起。

当图标准备完毕之后,把它们重命名替换掉项目中resources目录下的图标文件即可。

2、为程序关联文件格式

我希望我的程序在安装过后可以在用户想要打开文本文件时可以用我的程序来打开,可在Mac系统上,你会发现大部分应用处于灰色状态(如果你的应用不做处理,也会是这个样子的)

为了这个小功能我谷歌了好久好久都没找到解决方案,不过最后偶然间看到了Electron-bilder的配置文档,里面描述了如何配置文件关联,我只能说,,真NM简单。。

Electron-builder的配置一般会放在packages.json中,恰好我用的这个模版里面的打包工具就是它,我们只需要在build下面加上下面的配置即可关联自己定义的文件格式了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"fileAssociations": [
{
"ext": "md",
"name": "Markdown 文件",
"role": "Editor"
},
{
"ext": "markdown",
"name": "Markdown 文件",
"role": "Editor"
},
{
"ext": "txt",
"name": "文本文件",
"role": "Editor"
}
],

关联之后,程序只是虚有其表,因为我们并没有真正去处理传来的文件,所以下一步就是接收文件路径。在这一步自己也爬了一个又一个坑,都是血和泪的教训啊。。。

在程序启动时接收文件路径参数,乍一想这个问题,就应该是通过进程对象就可以取到了。可当时我在Mac电脑上开发,不论怎么搞都没办法获取到路径参数,谷歌也找不到答案。又是在万念俱灰之时,我去看了看官方文档,,,我只想说MMP

原来,Mac系统是要监听app的open-file事件的,而Windows则是通过进程对象来获取文件路径。

这样可以获取到文件路径了,但最终这个路径是要交给渲染进程去处理的,而在程序刚启动时渲染进程甚至还没有创建出来!此时,就需要在主进程中先定义一个变量保存一下接收的这个路径了,等待渲染进程加载完成后再把这个路径传给它,所以,我的整个处理逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// background.js

// 外部文件路径
let preFilePath = ''

app.on('will-finish-launching', () => {
log.debug('will-finish-launching')

// 打开文件事件(MacOS有效)
app.on("open-file", (e, filePath) => {
log.debug("open-file: ", filePath);

const fw = BrowserWindow.getFocusedWindow();
if (fw) {
fw.webContents.send("lm-open-file", [filePath]);
} else {
preFilePath = filePath
}
});

// 检查进程是否含有参数(Windows有效)
if (process.platform ==='win32' && process.argv.length >= 2) {
log.debug('process argv:', process.argv)

// windows系统当没有路径参数时这个位置默认有个.,需要加以判断
preFilePath = process.argv[1] === '.' ? '' : process.argv[1]
}
})

mainWindow.once('ready-to-show', () => {
log.debug('ready-to-show')
mainWindow.show()

// 检查是否存在需要直接打开的文件,有的话就直接打开
if (preFilePath) {
mainWindow.webContents.send('lm-open-file', [preFilePath])
}
})

其中,在app的will-finish-launching事件中才开始监听文件打开事件,也是官方文档上面建议的:

这样做了之后,整个记事本应用才显得完整起来。

其实,上面的很多做法不仅限于记事本中使用,希望我写的文章能对大家有所帮助!

下面放上我写的记事本的截图,来证明我做到过!

Godbobo wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!