使用
Electron
开发客户端程序已经有一段时间了,整体感觉还是非常不错的,其中也遇到了一些坑点,本文是从【运行原理】到【实际应用】对
Electron
进行一次系统性的总结。【多图,长文预警~】
本文所有实例代码均在我的
github electron-react
上,结合代码阅读文章效果更佳。另外
electron-react
还可作为使用
Electron + React + Mobx + Webpack
技术栈的脚手架工程。
桌面应用程序,又称为 GUI 程序(Graphical User Interface),但是和 GUI 程序也有一些区别。桌面应用程序 将 GUI 程序从GUI 具体为“桌面”,使冷冰冰的像块木头一样的电脑概念更具有 人性化,更生动和富有活力。
我们电脑上使用的各种客户端程序都属于桌面应用程序,近年来
WEB
和移动端的兴起让桌面程序渐渐暗淡,但是在某些日常功能或者行业应用中桌面应用程序仍然是必不可少的。
传统的桌面应用开发方式,一般是下面两种:
直接将语言编译成可执行文件,直接调用系统
API
,完成UI绘制等。这类开发技术,有着较高的运行效率,但一般来说,开发速度较慢,技术要求较高,例如:
C++ / MFC
开发
Windows
应用
Objective-C
开发
MAC
应用
C# / .NET Framework
(只能开发
Windows应用
)
Java / Swing
不过,上面两种对前端开发人员太不友好了,基本是前端人员不会涉及的领域,但是在这个【大前端😅】的时代,前端开发者正在想方设法涉足各个领域,使用
WEB
技术开发客户端的方式横空出世。
使用
WEB
技术进行开发,利用浏览器引擎完成
UI
渲染,利用
Node.js
实现服务器端
JS
编程并可以调用系统
API
,可以把它想像成一个套了一个客户端外壳的
WEB
应用。
在界面上,
WEB
的强大生态为
UI
带来了无限可能,并且开发、维护成本相对较低,有
WEB
开发经验的前端开发者很容易上手进行开发。
本文就来着重介绍使用
WEB
技术开发客户端程序的技术之一【
electron
】
Electron
是由
Github
开发,用
HTML,CSS
和
JavaScript
来构建跨平台桌面应用程序的一个开源库。
Electron
通过将
Chromium
和
Node.js
合并到同一个运行时环境中,并将其打包为
Mac,Windows
和
Linux
系统下的应用来实现这一目的。
Web
技术进行开发,开发成本低,可扩展性强,更炫酷的
UI
Windows、Linux、Mac
三套软件,且编译快速
Web
应用上进行扩展,提供浏览器不具备的能力
当然,我们也要认清它的缺点:性能比原生桌面应用要低,最终打包后的应用比原生应用大很多。
虽然你还在用
WEB
技术进行开发,但是你不用再考虑兼容性问题了,你只需要关心你当前使用
Electron
的版本对应
Chrome
的版本,一般情况下它已经足够新来让你使用最新的
API
和语法了,你还可以手动升级
Chrome
版本。同样的,你也不用考虑不同浏览器带的样式和代码兼容问题。
Node环境
这可能是很多前端开发者曾经梦想过的功能,在
WEB
界面中使用
Node.js
提供的强大
API
,这意味着你在
WEB
页面直接可以操作文件,调用系统
API
,甚至操作数据库。当然,除了完整的
Node API
,你还可以使用额外的几十万个
npm
模块。
你可以直接使用
Node
提供的
request
模块进行网络请求,这意味着你无需再被跨域所困扰。
强大的扩展性
借助
node-ffi
,为应用程序提供强大的扩展性(后面的章节会详细介绍)。
现在市面上已经有非常多的应用在使用
Electron
进行开发了,包括我们熟悉的
VS Code
客户端、
GitHub
客户端、
Atom
客户端等等。印象很深的,去年迅雷在发布迅雷X
10.1
时的文案:
从迅雷X 10.1版本开始,我们采用Electron软件框架完全重写了迅雷主界面。使用新框架的迅雷X可以完美支持2K、4K等高清显示屏,界面中的文字渲染也更加清晰锐利。从技术层面来说,新框架的界面绘制、事件处理等方面比老框架更加灵活高效,因此界面的流畅度也显著优于老框架的迅雷。至于具体提升有多大?您一试便知。
你可以打开
VS Code
,点击【帮助】【切换开发人员工具】来调试
VS Code
客户端的界面。
Electron
结合了
Chromium
、
Node.js
和用于调用操作系统本地功能的
API
。
Chromium
为
Electron
提供强大的
UI
能力,可以在不考虑兼容性的情况下开发界面。
Node.js
是一个让
JavaScript
运行在服务端的开发平台,
Node
使用事件驱动,非阻塞
I/O
模型而得以轻量和高效。
单单靠
Chromium
是不能具备直接操作原生
GUI
能力的,
Electron
内集成了
Nodejs
,这让其在开发界面的同时也有了操作系统底层
API
的能力,
Nodejs
中常用的
Path、fs、Crypto
等模块在
Electron
可以直接使用。
为了提供原生系统的
GUI
支持,
Electron
内置了原生应用程序接口,对调用一些系统功能,如调用系统通知、打开系统文件夹提供支持。
在开发模式上,
Electron
在调用系统
API
和绘制界面上是分离开发的,下面我们来看看
Electron
关于进程如何划分。
Electron
区分了两种进程:主进程和渲染进程,两者各自负责自己的职能。
Electron
运行
package.json
的
main
脚本的进程被称为主进程。一个
Electron
应用总是有且只有一个主进程。
APP
以及对
APP
做一些事件监听)
可调用的API:
Node.js API
Electron
提供的主进程
API
(包括一些系统功能和
Electron
附加功能)
由于
Electron
使用了
Chromium
来展示
web
页面,所以
Chromium
的多进程架构也被使用到。 每个
Electron
中的
web
页面运行在它自己的渲染进程中。
主进程使用 BrowserWindow 实例创建页面。 每个 BrowserWindow 实例都在自己的渲染进程里运行页面。 当一个 BrowserWindow 实例被销毁后,相应的渲染进程也会被终止。
你可以把渲染进程想像成一个浏览器窗口,它能存在多个并且相互独立,不过和浏览器不同的是,它能调用
Node API
。
HTML
和
CSS
渲染界面
JavaScript
做一些界面交互
可调用的API:
DOM API
Node.js API
Electron
提供的渲染进程
API
你可以通过如下方式获取
Electron API
1 |
const { BrowserWindow, ... } = require('electron') |
下面是一些常用的
Electron API
:
在后面的章节我们会选择其中常用的模块进行详细介绍。
你可以同时在
Electron
的主进程和渲染进程使用
Node.js API
,)所有在
Node.js
可以使用的
API
,在
Electron
中同样可以使用。
1 |
import {shell} from 'electron'; |
有一个非常重要的提示: 原生Node.js模块 (即指,需要编译源码过后才能被使用的模块) 需要在编译后才能和Electron一起使用。
主进程和渲染进程虽然拥有不同的职责,然是他们也需要相互协作,互相通讯。
例如:在
web
页面管理原生
GUI
资源是很危险的,会很容易泄露资源。所以在
web
页面,不允许直接调用原生
GUI
相关的
API
。渲染进程如果想要进行原生的
GUI
操作,就必须和主进程通讯,请求主进程来完成这些操作。
ipcRenderer
是一个
EventEmitter
的实例。 你可以使用它提供的一些方法,从渲染进程发送同步或异步的消息到主进程。 也可以接收主进程回复的消息。
在渲染进程引入
ipcRenderer
:
1 |
import { ipcRenderer } from 'electron'; |
异步发送:
通过
channel
发送同步消息到主进程,可以携带任意参数。
在内部,参数会被序列化为
JSON
,因此参数对象上的函数和原型链不会被发送。
1 |
ipcRenderer.send('async-render', '我是来自渲染进程的异步消息'); |
同步发送:
1 |
const msg = ipcRenderer.sendSync('sync-render', '我是来自渲染进程的同步消息'); |
注意: 发送同步消息将会阻塞整个渲染进程,直到收到主进程的响应。
主进程监听消息:
ipcMain
模块是
EventEmitter
类的一个实例。 当在主进程中使用时,它处理从渲染器进程(网页)发送出来的异步和同步信息。 从渲染器进程发送的消息将被发送到该模块。
ipcMain.on
:监听
channel
,当接收到新的消息时
listener
会以
listener(event, args...)
的形式被调用。
1 |
ipcMain.on('sync-render', (event, data) => { |
在主进程中可以通过
BrowserWindow
的
webContents
向渲染进程发送消息,所以,在发送消息前你必须先找到对应渲染进程的
BrowserWindow
对象。:
1 |
const mainWindow = BrowserWindow.fromId(global.mainId); |
根据消息来源发送:
在
ipcMain
接受消息的回调函数中,通过第一个参数
event
的属性
sender
可以拿到消息来源渲染进程的
webContents
对象,我们可以直接用此对象回应消息。
1 |
ipcMain.on('sync-render', (event, data) => { |
渲染进程监听:
ipcRenderer.on
:监听
channel
, 当新消息到达,将通过
listener(event, args...)
调用
listener
。
1 |
ipcRenderer.on('main-msg', (event, msg) => { |
ipcMain
和
ipcRenderer
都是
EventEmitter
类的一个实例。
EventEmitter
类是
NodeJS
事件的基础,它由
NodeJS
中的
events
模块导出。
EventEmitter
的核心就是事件触发与事件监听器功能的封装。它实现了事件模型需要的接口, 包括
addListener,removeListener
,
emit
及其它工具方法. 同原生
JavaScript
事件类似, 采用了发布/订阅(观察者)的方式, 使用内部
_events
列表来记录注册的事件处理器。
我们通过
ipcMain
和
ipcRenderer
的
on、send
进行监听和发送消息都是
EventEmitter
定义的相关接口。
1 |
import { remote } from 'electron'; |
但实际上,我们在调用远程对象的方法、函数或者通过远程构造函数创建一个新的对象,实际上都是在发送一个同步的进程间消息。
在上面通过
remote
模块调用
dialog
的例子里。我们在渲染进程中创建的
dialog
对象其实并不在我们的渲染进程中,它只是让主进程创建了一个
dialog
对象,并返回了这个相对应的远程对象给了渲染进程。
Electron
并没有提供渲染进程之间相互通信的方式,我们可以在主进程中建立一个消息中转站。
渲染进程之间通信首先发送消息到主进程,主进程的中转站接收到消息后根据条件进行分发。
就像在浏览器中使用一样,这种存储相当于在应用程序中永久存储了一部分数据。有时你并不需要这样的存储,只需要在当前应用程序的生命周期内进行一些数据的共享。这时你可以用
Electron
内的
IPC
机制实现。
将数据存在主进程的某个全局变量中,然后在多个渲染进程中使用
remote
模块来访问它。
在主进程中初始化全局变量:
1 |
global.mainId = ...; |
在渲染进程中读取:
1 |
import { ipcRenderer, remote } from 'electron'; |
在渲染进程中改变:
1 |
getGlobal('myField').name = 'code秘密花园'; |
多个渲染进程共享同一个主进程的全局变量,这样即可达到渲染进程数据共享和传递的效果。
主进程模块
BrowserWindow
用于创建和控制浏览器窗口。
1 |
mainWindow = new BrowserWindow({ |
无框窗口是没有镶边的窗口,窗口的部分(如工具栏)不属于网页的一部分。
在
BrowserWindow
的构造参数中,将frame
设置为false
可以指定窗口为无边框窗口,将工具栏隐藏后,就会产生两个问题:- 1.窗口控制按钮(最小化、全屏、关闭按钮)会被隐藏
- 2.无法拖拽移动窗口
可以通过指定
titleBarStyle
选项来再将工具栏按钮显示出来,将其设置为hidden
表示返回一个隐藏标题栏的全尺寸内容窗口,在左上角仍然有标准的窗口控制按钮。
1
2
3
4
5
6 new BrowserWindow({
width: 200,
height: 200,
titleBarStyle: 'hidden',
frame: false
});5.3 窗口拖拽
默认情况下, 无边框窗口是不可拖拽的。我们可以在界面中通过
CSS
属性-webkit-app-region: drag
手动制定拖拽区域。在无框窗口中, 拖动行为可能与选择文本冲突,可以通过设定
-webkit-user-select: none;
禁用文本选择:
1
2
3
4 .header {
-webkit-user-select: none;
-webkit-app-region: drag;
}相反的,在可拖拽区域内部设置
-webkit-app-region: no-drag
则可以指定特定不可拖拽区域。5.4 透明窗口
通过将
transparent
选项设置为true
, 还可以使无框窗口透明:
1
2
3
4 new BrowserWindow({
transparent: true,
frame: false
});5.5 Webview
使用
webview
标签在Electron
应用中嵌入 “外来” 内容。外来内容包含在webview
容器中。 应用中的嵌入页面可以控制外来内容的布局和重绘。与
iframe
不同,webview
在与应用程序不同的进程中运行。它与您的网页没有相同的权限, 应用程序和嵌入内容之间的所有交互都将是异步的。六、对话框
dialog
模块提供了api
来展示原生的系统对话框,例如打开文件框,alert
框,所以web
应用可以给用户带来跟系统应用相同的体验。注意:dialog是主进程模块,想要在渲染进程调用可以使用remote
6.1 错误提示
dialog.showErrorBox
用于显示一个显示错误消息的模态对话框。
1 remote.dialog.showErrorBox('错误', '这是一个错误弹框!')6.2 对话框
在 Windows 上, “question” 与”info”显示相同的图标, 除非你使用了 “icon” 选项设置图标。 在 macOS 上, “warning” 和 “error” 显示相同的警告图标
1
2
3
4
5
6
7
8 remote.dialog.showMessageBox({
type: 'info',
title: '提示信息',
message: '这是一个对话弹框!',
buttons: ['确定', '取消']
}, (index) => {
this.setState({ dialogMessage: `【你点击了${index ? '取消' : '确定'}!!】` })
})6.3 文件框
dialog.showOpenDialog
用于打开或选择系统目录。
1
2
3
4
5 remote.dialog.showOpenDialog({
properties: ['openDirectory', 'openFile']
}, (data) => {
this.setState({ filePath: `【选择路径:${data[0]}】 ` })
})6.4 信息框
这里推荐直接使用
HTML5 API
,它只能在渲染器进程中使用。
1
2
3
4
5
6
7
8 let options = {
title: '信息框标题',
body: '我是一条信息~~~',
}
let myNotification = new window.Notification(options.title, options)
myNotification.onclick = () => {
this.setState({ message: '【你点击了信息框!!】' })
}七、系统
7.1 获取系统信息
通过
remote
获取到主进程的process
对象,可以获取到当前应用的各个版本信息:process.versions.electron
:electron
版本信息process.versions.chrome
:chrome
版本信息process.versions.node
:node
版本信息process.versions.v8
:v8
版本信息获取当前应用根目录:
1 remote.app.getAppPath()使用
node
的os
模块获取当前系统根目录:
1 os.homedir();7.2 复制粘贴
Electron
提供的clipboard
在渲染进程和主进程都可使用,用于在系统剪贴板上执行复制和粘贴操作。以纯文本的形式写入剪贴板:
1 clipboard.writeText(text[, type])以纯文本的形式获取剪贴板的内容:
1 clipboard.readText([type])7.3 截图
desktopCapturer
用于从桌面捕获音频和视频的媒体源的信息。它只能在渲染进程中被调用。下面的代码是一个获取屏幕截图并保存的实例:
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 getImg = () => {
this.setState({ imgMsg: '正在截取屏幕...' })
const thumbSize = this.determineScreenShotSize()
let options = { types: ['screen'], thumbnailSize: thumbSize }
desktopCapturer.getSources(options, (error, sources) => {
if (error) return console.log(error)
sources.forEach((source) => {
if (source.name === 'Entire screen' || source.name === 'Screen 1') {
const screenshotPath = path.join(os.tmpdir(), 'screenshot.png')
fs.writeFile(screenshotPath, source.thumbnail.toPNG(), (error) => {
if (error) return console.log(error)
shell.openExternal(`file://${screenshotPath}`)
this.setState({ imgMsg: `截图保存到: ${screenshotPath}` })
})
}
})
})
}
determineScreenShotSize = () => {
const screenSize = screen.getPrimaryDisplay ().workAreaSize
const maxDimension = Math.max(screenSize.width, screenSize.height)
return {
width: maxDimension * window.devicePixelRatio,
height: maxDimension * window.devicePixelRatio
}
}八、菜单
应用程序的菜单可以帮助我们快捷的到达某一功能,而不借助客户端的界面资源,一般菜单分为两种:
- 应用程序菜单:位于应用程序顶部,在全局范围内都能使用
- 上下文菜单:可自定义任意页面显示,自定义调用,如右键菜单
Electron
为我们提供了Menu
模块用于创建本机应用程序菜单和上下文菜单,它是一个主进程模块。你可以通过
Menu
的静态方法buildFromTemplate(template)
,使用自定义菜单模版来构造一个菜单对象。
template
是一个MenuItem
的数组,我们来看看MenuItem
的几个重要参数:label
:菜单显示的文字click
:点击菜单后的事件处理函数role
:系统预定义的菜单,例如copy
(复制)、paste
(粘贴)、minimize
(最小化)…enabled
:指示是否启用该项目,此属性可以动态更改submenu
:子菜单,也是一个MenuItem
的数组推荐:最好指定role与标准角色相匹配的任何菜单项,而不是尝试手动实现click函数中的行为。内置role行为将提供最佳的本地体验。
下面的实例是一个简单的额菜单
template
。
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 const template = [
{
label: '文件',
submenu: [
{
label: '新建文件',
click: function () {
dialog.showMessageBox({
type: 'info',
message: '嘿!',
detail: '你点击了新建文件!',
})
}
}
]
},
{
label: '编辑',
submenu: [{
label: '剪切',
role: 'cut'
}, {
label: '复制',
role: 'copy'
}, {
label: '粘贴',
role: 'paste'
}]
},
{
label: '最小化',
role: 'minimize'
}
]8.1 应用程序菜单
使用
Menu
的静态方法setApplicationMenu
,可创建一个应用程序菜单,在Windows
和Linux
上,menu
将被设置为每个窗口的顶层菜单。注意:必须在模块ready事件后调用此 API app。
我们可以根据应用程序不同的的生命周期,不同的系统对菜单做不同的处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19app.on('ready', function () {
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
})
app.on('browser-window-created', function () {
let reopenMenuItem = findReopenMenuItem()
if (reopenMenuItem) reopenMenuItem.enabled = false
})
app.on('window-all-closed', function () {
let reopenMenuItem = findReopenMenuItem()
if (reopenMenuItem) reopenMenuItem.enabled = true
})
if (process.platform === 'win32') {
const helpMenu = template[template.length - 1].submenu
addUpdateMenuItems(helpMenu, 0)
}8.2 上下文菜单
使用
Menu
的实例方法menu.popup
可自定义弹出上下文菜单。
1
2
3
4
5 let m = Menu.buildFromTemplate(template)
document.getElementById('menuDemoContainer').addEventListener('contextmenu', (e) => {
e.preventDefault()
m.popup({ window: remote.getCurrentWindow() })
})8.3 快捷键
在菜单选项中,我们可以指定一个
accelerator
属性来指定操作的快捷键:
1
2
3
4
5 {
label: '最小化',
accelerator: 'CmdOrCtrl+M',
role: 'minimize'
}另外,我们还可以使用
globalShortcut
来注册全局快捷键。
1
2
3
4
5
6
7 globalShortcut.register('CommandOrControl+N', () => {
dialog.showMessageBox({
type: 'info',
message: '嘿!',
detail: '你触发了手动注册的快捷键.',
})
})CommandOrControl代表在macOS上为Command键,以及在Linux和Windows上为Control键。
九、打印
Electron
提供的打印api可以非常灵活的控制打印设置的显示,并且可以通过html来书写打印内容。Electron
提供了两种方式进行打印,一种是直接调用打印机打印,一种是打印到并且有两种对象可以调用打印:
- 通过
window
的webcontent
对象,使用此种方式需要单独开出一个打印的窗口,可以将该窗口隐藏,但是通信调用相对复杂。- 使用页面的
webview
元素调用打印,可以将webview
隐藏在调用的页面中,通信方式比较简单。上面两种方式同时拥有
printToPdf
方法。9.1 调用系统打印
1 contents.print([options], [callback]);打印配置(options)中只有简单的三个配置:
silent
:打印时是否不展示打印配置(是否静默打印)printBackground
:是否打印背景deviceName
:打印机设备名称首先要将我们使用的打印机名称配置好,并且要在调用打印前首先要判断打印机是否可用。
使用
webContents
的getPrinters
方法可获取当前设备已经配置的打印机列表,注意配置过不是可用,只是在此设备上安装过驱动。通过
getPrinters
获取到的打印机对象: https://electronjs.org/docs/api/structures/printer-info我们这里只管关心两个,
name
和status
,status
为0
时表示打印机可用。
callback
是用于判断打印任务是否发出的回调,而不是打印任务完成后的回调。所以一般打印任务发出,回调函数即会调用并返回参数true
。这个回调并不能判断打印是否真的成功了。
1
2
3
4
5
6
7 if (this.state.curretnPrinter) {
mainWindow.webContents.print({
silent: silent, printBackground : true, deviceName: this.state.curretnPrinter
}, () => { })
} else {
remote.dialog.showErrorBox('错误', '请先选择一个打印机!')
}9.2 打印到PDF
1 contents.printToPDF(options, callback)
callback
函数在打印失败或打印成功后调用,可获取打印失败信息或包含
1
2
3
4
5
6
7
8
9
10
11
12 const pdfPath = path.join(os.tmpdir(), 'webviewPrint.pdf');
const webview = document.getElementById('printWebview');
const renderHtml = '我是被临时插入webview的内容...';
webview.executeJavaScript('document.documentElement.innerHTML =`' + renderHtml + '`;');
webview.printToPDF({}, (err, data) => {
console.log(err, data);
fs.writeFile(pdfPath, data, (error) => {
if (error) throw error
shell.openExternal(`file://${pdfPath}`)
this.setState({ webviewPdfPath: pdfPath })
});
});这个例子中的打印是使用
webview
完成的,通过调用executeJavaScript
方法可动态向webview
插入打印内容。9.3 两种打印方案的选择
此过程需和调用打印的进行做好通信,大致过程如下:
可见通信非常繁琐,使用
webview
进行打印可实现同样的效果但是通信方式会变得简单,因为渲染进程和webview
通信不需要经过主进程,通过如下方式即可:
1
2
3
4
5
6
7
8
9
10 const webview = document.querySelector('webview')
webview.addEventListener('ipc-message', (event) => {
console.log(event.channel)
})
webview.send('ping');
const {ipcRenderer} = require('electron')
ipcRenderer.on('ping', () => {
ipcRenderer.sendToHost('pong')
})之前专门为
ELectron
打印写过一个DEMO
: electron-print-demo 有兴趣可以clone
下来看一下。9.4 打印功能封装
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 /**
* 获取系统打印机列表
*/
export function getPrinters() {
let printers = [];
try {
const contents = remote.getCurrentWindow().webContents;
printers = contents.getPrinters();
} catch (e) {
console.error('getPrintersError', e);
}
return printers;
}
/**
* 获取系统默认打印机
*/
export function getDefaultPrinter() {
return getPrinters().find(element => element.isDefault);
}
/**
* 检测是否安装了某个打印驱动
*/
export function checkDriver(driverMame) {
return getPrinters().find(element => (element.options["printer-make-and-model"] || '').includes(driverMame));
}
/**
* 根据打印机名称获取打印机对象
*/
export function getPrinterByName(name) {
return getPrinters().find(element => element.name === name);
}十、程序保护
10.1 崩溃
崩溃监控是每个客户端程序必备的保护功能,当程序崩溃时我们一般期望做到两件事:
- 1.上传崩溃日志,及时报警
- 2.监控程序崩溃,提示用户重启程序
electron
为我们提供给了crashReporter
来帮助我们记录崩溃日志,我们可以通过crashReporter.start
来创建一个崩溃报告器:
1
2
3
4
5
6
7 const { crashReporter } = require('electron')
crashReporter.start({
productName: 'YourName',
companyName: 'YourCompany',
submitURL: 'https://your-domain.com/url-to-submit',
uploadToServer: true
})当程序发生崩溃时,崩溃报日志将被储存在临时文件夹中名为
YourName Crashes
的文件文件夹中。submitURL
用于指定你的崩溃日志上传服务器。 在启动崩溃报告器之前,您可以通过调用app.setPath('temp', 'my/custom/temp')
API来自定义这些临时文件的保存路径。你还可以通过crashReporter.getLastCrashReport()
来获取上次崩溃报告的日期和ID
。我们可以通过
webContents
的crashed
来监听渲染进程的崩溃,另外经测试有些主进程的崩溃也会触发该事件。所以我们可以根据主window
是否被销毁来判断进行不同的重启逻辑,下面是整个崩溃监控的逻辑:
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 import { BrowserWindow, crashReporter, dialog } from 'electron';
// 开启进程崩溃记录
crashReporter.start({
productName: 'electron-react',
companyName: 'ConardLi',
submitURL: 'http://xxx.com', // 上传崩溃日志的接口
uploadToServer: false
});
function reloadWindow(mainWin) {
if (mainWin.isDestroyed()) {
app.relaunch();
app.exit(0);
} else {
// 销毁其他窗口
BrowserWindow.getAllWindows ().forEach((w) => {
if (w.id !== mainWin.id) w.destroy();
});
const options = {
type: 'info',
title: '渲染器进程崩溃',
message: '这个进程已经崩溃.',
buttons: ['重载', '关闭']
}
dialog.showMessageBox(options, (index) => {
if (index === 0) mainWin.reload();
else mainWin.close();
})
}
}
export default function () {
const mainWindow = BrowserWindow.fromId(global.mainId);
mainWindow.webContents.on('crashed', () => {
const errorMessage = crashReporter.getLastCrashReport();
console.error('程序崩溃了!', errorMessage); // 可单独上传日志
reloadWindow(mainWindow);
});
}10.2 最小化到托盘
有的时候我们并不想让用户通过点关闭按钮的时候就关闭程序,而是把程序最小化到托盘,在托盘上做真正的退出操作。
首先要监听窗口的关闭事件,阻止用户关闭操作的默认行为,将窗口隐藏。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 function checkQuit(mainWindow, event) {
const options = {
type: 'info',
title: '关闭确认',
message: '确认要最小化程序到托盘吗?',
buttons: ['确认', '关闭程序']
};
dialog.showMessageBox(options, index => {
if (index === 0) {
event.preventDefault();
mainWindow.hide();
} else {
mainWindow = null;
app.exit(0);
}
});
}
function handleQuit() {
const mainWindow = BrowserWindow.fromId(global.mainId);
mainWindow.on('close', event => {
event.preventDefault();
checkQuit(mainWindow, event);
});
}这时程序就再也找不到了,任务托盘中也没有我们的程序,所以我们要先创建好任务托盘,并做好事件监听。
windows平台使用
ico
文件可以达到更好的效果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21export default function createTray() {
const mainWindow = BrowserWindow.fromId(global.mainId);
const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png'
tray = new Tray(path.join(global.__dirname, iconName));
const contextMenu = Menu.buildFromTemplate([
{
label: '显示主界面', click: () => {
mainWindow.show();
mainWindow.setSkipTaskbar(false);
}
},
{
label: '退出', click: () => {
mainWindow.destroy();
app.quit();
}
},
])
tray.setToolTip('electron-react');
tray.setContextMenu(contextMenu);
}十一、扩展能力
在很多情况下,你的应用程序要和外部设备进行交互,一般情况下厂商会为你提供硬件设备的开发包,这些开发包基本上都是通过
C++
编写,在使用electron
开发的情况下,我们并不具备直接调用C++
代码的能力,我们可以利用node-ffi
来实现这一功能。
node-ffi
提供了一组强大的工具,用于在Node.js
环境中使用纯JavaScript
调用动态链接库接口。它可以用来为库构建接口绑定,而不需要使用任何C++
代码。注意
node-ffi
并不能直接调用C++
代码,你需要将C++
代码编译为动态链接库:在Windows
下是Dll
,在Mac OS
下是dylib
,Linux
是so
。
node-ffi
加载Library
是有限制的,只能处理C
风格的Library
。下面是一个简单的实例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 const ffi = require('ffi');
const ref = require('ref');
const SHORT_CODE = ref.refType('short');
const DLL = new ffi.Library('test.dll', {
Test_CPP_Method: ['int', ['string',SHORT_CODE]],
})
testCppMethod(str: String, num: number): void {
try {
const result: any = DLL.Test_CPP_Method(str, num);
return result;
} catch (error) {
console.log('调用失败~',error);
}
}
this.testCppMethod('ConardLi',123);上面的代码中,我们用
ffi
包装C++
接口生成的动态链接库test.dll
,并使用ref
进行一些类型映射。使用
JavaScript
调用这些映射方法时,推荐使用TypeScript
来约定参数类型,因为弱类型的JavaScript
在调用强类型语言的接口时可能会带来意想不到的风险。借助这一能力,前端开发工程师也可以在
IOT
领域一展身手了😎~十二、环境选择
具体策略如下:
- 在开发环境中,我们直接进入环境选择页面,读取到选择的环境后进行响应的重定向操作
- 在菜单保留环境选择入口,以便在开发过程中切换
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 const envList = ["moke", "beta", "development", "production"];
exports.envList = envList;
const urlBeta = 'https://wwww.xxx-beta.com';
const urlDev = 'https://wwww.xxx-dev.com';
const urlProp = 'https://wwww.xxx-prop.com';
const urlMoke = 'https://wwww.xxx-moke.com';
const path = require('path' );
const pkg = require(path.resolve(global.__dirname, 'package.json'));
const build = pkg['build-config'];
exports.handleEnv = {
build,
currentEnv: 'moke',
setEnv: function (env) {
this.currentEnv = env
},
getUrl: function () {
console.log('env:', build.env);
if (build.env === 'production' || this.currentEnv === 'production') {
return urlProp;
} else if (this.currentEnv === 'moke') {
return urlMoke;
} else if (this.currentEnv === 'development') {
return urlDev;
} else if (this.currentEnv === "beta") {
return urlBeta;
}
},
isDebugger: function () {
return build.env === 'development'
}
}十三、打包
最后也是最重要的一步,将写好的代码打包成可运行的
.app
或.exe
可执行文件。这里我把打包氛围两部分来做,渲染进程打包和主进程打包。
13.1 渲染进程打包和升级
这里我说说渲染进程单独打包的好处:
打包完成的
html
和js
文件,我们一般要上传到我们的前端静态资源服务器下,然后告知服务端我们的渲染进程有代码更新,这里可以说成渲染进程单独的升级。注意,和壳的升级不同,渲染进程的升级仅仅是静态资源服务器上
html
和js
文件的更新,而不需要重新下载更新客户端,这样我们每次启动程序的时候检测到离线包有更新,即可直接刷新读取最新版本的静态资源文件,即使在程序运行过程中要强制更新,我们的程序只需要强制刷新页面读取最新的静态资源即可,这样的升级对用户是非常友好的。这里注意,一旦我们这样配置,就意味着渲染进程和主进程打包升级的完全分离,我们在启动主窗口时读取的文件就不应该再是本地文件,而是打包完成后放在静态资源服务器的文件。
为了方便开发,这里我们可以区分本地和线上加载不同的文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 function getVersion (mac,current){
// 根据设备mac和当前版本获取最新版本
}
export default function () {
if (build.env === 'production') {
const version = getVersion (mac,current);
return 'https://www.xxxserver.html/electron-react/index_'+version+'.html';
}
return url.format({
protocol: 'file:',
pathname: path.join(__dirname, 'env/environment.html'),
slashes: true,
query: { debugger: build.env === "development" }
});
}具体的
webpack
配置这里就不再贴出,可以到我的github
electron-react
的/scripts
目录下查看。这里需要注意,在开发环境下我们可以结合
webpack
的devServer
和electron
命令来启动app
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 devServer: {
contentBase: './assets/',
historyApiFallback: true,
hot: true,
port: PORT,
noInfo: false,
stats: {
colors: true,
},
setup() {
spawn(
'electron',
['.'],
{
shell: true,
stdio: 'inherit',
}
)
.on('close', () => process.exit(0))
.on('error', e => console.error(e));
},
},//...13.2 主进程打包
主进程,即将整个程序打包成可运行的客户端程序,常用的打包方案一般有两种,
electron-packager
和electron-builder
。
electron-packager
在打包配置上我觉得有些繁琐,而且它只能将应用直接打包为可执行程序。这里我推荐使用
electron-builder
,它不仅拥有方便的配置protocol
的功能、内置的Auto Update
、简单的配置package.json
便能完成整个打包工作,用户体验非常不错。而且electron-builder
不仅能直接将应用打包成exe app
等可执行程序,还能打包成msi dmg
等安装包格式。你可以在
package.json
方便的进行各种配置:
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 "build": {
"productName": "electron-react", // app中文名称
"appId": "electron-react",// app标识
"directories": { // 打包后输出的文件夹
"buildResources": "resources",
"output": "dist/"
}
"files": [ // 打包后依然保留的源文件
"main_process/",
"render_process/",
],
"mac": { // mac打包配置
"target": "dmg",
"icon": "icon.ico"
},
"win": { // windows打包配置
"target": "nsis",
"icon": "icon.ico"
},
"dmg": { // dmg文件打包配置
"artifactName": "electron_react.dmg",
"contents": [
{
"type": "link",
"path": "/Applications",
"x": 410,
"y": 150
},
{
"type": "file",
"x": 130,
"y": 150
}
]
},
"nsis": { // nsis文件打包配置
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"shortcutName": "electron-react"
},
}执行
electron-builder
打包命令时,可指定参数进行打包。
1
2
3
4
5
6 --mac, -m, -o, --macos macOS打包
--linux, -l Linux打包
--win, -w, --windows Windows打包
--mwl 同时为macOS,Windows和Linux打包
--x64 x64 (64位安装包)
--ia32 ia32(32位安装包)关于主进程的更新你可以使用
electron-builder
自带的Auto Update
模块,在electron-react
也实现了手动更新的模块,由于篇幅原因这里就不再赘述,如果有兴趣可以到我的github
查看main
下的update
模块。13.3 打包优化
electron-builder
打包出来的App
要比相同功能的原生客户端应用体积大很多,即使是空的应用,体积也要在100mb
以上。原因有很多:第一点;为了达到跨平台的效果,每个
Electron
应用都包含了整个V8
引擎和Chromium
内核。第二点:打包时会将整个
node_modules
打包进去,大家都知道一个应用的node_module
体积是非常庞大的,这也是使得Electron
应用打包后的体积较大的原因。第一点我们无法改变,我们可以从第二点对应用体积进行优化:
Electron
在打包时只会将denpendencies
的依赖打包进去,而不会将devDependencies
中的依赖进行打包。所以我们应尽可能的减少denpendencies
中的依赖。在上面的进程中,我们使用webpack
对渲染进程进行打包,所以渲染进程的依赖全部都可以移入devDependencies
。另外,我们还可以使用双
packajson.json
的方式来进行优化,把只在开发环境中使用到的依赖放在整个项目的根目录的package.json
下,将与平台相关的或者运行时需要的依赖装在app
目录下。具体详见 two-package-structure 。- https://electronjs.org/docs
- http://jlord.us/essential-electron/
- https://imweb.io/topic/5b9f500cc2ec8e6772f34d79
- https://www.jianshu.com/p/1ece6fd7a80c
- https://zhuanlan.zhihu.com/p/52991793
本项目源码地址: https://github.com/ConardLi/electron-react
小结
- 了解
Electron
的基本运行原理- 掌握
Electron
开发的核心基础知识- 了解
Electron
关于弹框、打印、保护、打包等功能的基本使用