electron笔记
背景
想把lingvist做成本地应用,方便在linux平台上的启动,
(mac上可以用alfred来快速打开)
相比移动端和云应用,桌面端的优点在于
- 启动方便
- 与系统的交互更好(比如应用切换更方便)
介绍
由Github开发,使用html,css,js来构建跨平台应用.
将Chromium和Node.js整合到一个运行时环境中,
然后打包为一个应用.
入门
文件结构
本质上是一个Node.js应用
基础的结构为
1 | . |
-
package.json
配置文件,配置项:
- name 应用名
- version 应用版本
- main 启动脚本,默认是index.js,electron中习惯手动指定为main.js
- scripts 各种命令行脚本
- start 启动命令,如果是electron,则其对应的shell命令为
electron .
- start 启动命令,如果是electron,则其对应的shell命令为
-
main.js
启动脚本,如果是一个简单应用,则可以在这里完成大部分的功能,
一个最简单的例子是1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 引入electron模块并使用其两个功能
const { app, BrowserWindow } = require('electron')
function createWindow () {
// 创建浏览器窗口
let win = new BrowserWindow({ //electron.BrowserWindow负责打开窗口
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true
}
})
// 加载index.html文件
win.loadFile('index.html')
}
app.whenReady().then(createWindow) // electron.app负责管理声明周期然而不光有启动事件,还要处理其他事件,
可以发现mac就是个要求怪异且多事的系统.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16//当所有窗口都被关闭后退出
app.on('window-all-closed', () => {
// 在 macOS 上,除非用户用 Cmd + Q 确定地退出,
// 否则绝大部分应用及其菜单栏会保持激活。
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
// 在macOS上,当单击dock图标并且没有其他窗口打开时,
// 通常在应用程序中重新创建一个窗口。
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})当然受限于启动速度,第一个窗口打开前,
window-all-closed
事件可能就已经发生,
因此该事件对应动作的定义还是放在app.whenReady
里面比较好1
2
3
4
5
6
7app.whenReady().then(() => { // 像lisp一样创建了一个匿名的函数
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
}) -
index.html
看来electron做了最基础的分割,
view层与controller层分开了.
示例代码1
2
3
4
5
6
7
8
9
10
11
12
<html>
<head>
<title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
We are using Node.js <span id="node-version"></span>,
Chromium <span id="chrome-version"></span>,
and Electron <span id="electron-version"></span>.
</body>
</html>
运行
在目录下使用 npm install && npm start
- 安装相关依赖,生成
node_modules
和package-lock.json
- 开始运行本地应用
与React项目联动
假设本地有一个React的Web应用,
想让其使用electron包装为一个本地应用.
则主要需要做两个操作.
在main.js中打开react项目的本地端口
1 | //win.loadFile('index.html') 这一行替换为: |
调整启动命令,package.json中
1 | "scripts": { |
start对应 npm start
命令,此处设置为了启动react项目
其他脚本无法直接使用 npm xxxx
而是使用 npm run xxx
于是要在本地应用中使用,需要执行
1 | npm start # 启动react |
主进程和渲染进程
当创建了一个electron应用,一个主进程就被创建了.
该主进程负责
- 打开网页(渲染进程),一个甚至是多个.
- 如果被渲染的网页是自己写在项目内部的(html, css, js)那就实现了用web知识开发本地应用的目的了.
- 负责用户与GUI的交互
- 比如发出桌面通知
- 比如为用户设置快捷键以快速开始某些功能
进程间通信
electron推荐在网页中接收捕获事件(比如按钮点击事件等等)后,
由渲染线程发送信号给主线程,由主线程执行具体的操作.
以防止直接在渲染线程中发生内存泄露问题.
electron在本质上,通信的机制叫ipc,
不过多个模块都能使用该机制,
- electron模块中有ipc-renderer和ipc-main.
- ipc模块中有ipc
- remote模块有getGlobal
注:js的模块使用require来导入
-
相互通信
可以使用ipc-renderer和ipc-main
异步操作中,
ipc-renderer
send(发送信息)
on(监听信息)
ipc-main
on(监听信息)
send(发送信息)
同步操作中
ipc-renderer
sendSync(发送信息并直接返回结果)
ipc-main
on(接收请求并立即返回信息)
异步代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22//在渲染进程中:
const {ipcRenderer} = require('electron')
// 发送
ipcRenderer.send('asynchronous-message', 'ping')
// 接收
ipcRenderer.on('asynchronous-reply', (event, arg) => {
console.log('asynchronous-reply : args:',arg)
const message = `Asynchronous message reply: ${arg}`
document.getElementById('async-reply').innerHTML = message
})
//在主进程中:
const {ipcMain} = require('electron')
// 接收
ipcMain.on('asynchronous-message', (event, arg) => {
// 发送
event.sender.send('asynchronous-reply', {'ping':'pong','num':'1'})
})同步代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18//在渲染进程中:
const {ipcRenderer} = require('electron')
const syncMsgBtn = document.getElementById('sync-msg')
syncMsgBtn.addEventListener('click', () => {
// reply表示是直接返回结果的
const reply = ipcRenderer.sendSync('synchronous-message', 'ping')
document.getElementById('sync-reply').innerHTML = reply
})
//在主进程中:
const {ipcMain} = require('electron')
ipcMain.on('synchronous-message', (event, arg) => {
event.returnValue = 'pong' // 这是定义返回值的语法?
}) -
渲染进程向主进程发消息
实质上发送信息的是ipc,
但不知道为什么,外部用remote模块中的插件包裹了一下.1
2
3
4
5
6// 在渲染进程打开提示对话框
const ipc = require('ipc');
const {dialog} = require('electron').remote
dialog.showMessageBox(options, (index) => {
// ipc.send(...)
}) -
主进程向渲染进程发消息
使用的是webContents
比如告知网页,用户按下了全局快捷键1,要求网页执行对应动作1
win.webContents.send('ping', 'whoooooooh!')
-
渲染进程之间的通信
两个渲染进程之间通信可以使用很多方法,
浏览器已经实现的有- Storage API
- localStorage
- sessionStorage
- IndexedDB
另外也可以通过主进程来中介,
这样就可以使用electron自身的ipc机制
比如一个用remote的方式1
2
3
4
5
6
7
8// 在主进程中
global.sharedObject = {
someProperty: 'default value'
}
// 在第一个页面中
require('electron').remote.getGlobal('sharedObject').someProperty = 'new value'
// 在第二个页面中
console.log(require('electron').remote.getGlobal('sharedObject').someProperty)
掘金附带开发者工具的方法
main.js中
1 | // 保存一个全局的window对象防止被GC |
知乎上的声效器项目
应用本身的实现
属于网页的一个js文件: index.js
1 | ; |
关闭窗口
在index.js中定义close按钮的事件
然而并不在事件中写 window.close()
这样的原生GUI代码,
而是在事件中通知主线程,
然后在主线程中执行 app.quit()
来退出应用.
以防止可能出现的内存泄露.
-
处理拖动问题
如果应用本身被固定了大小,
且希望被拖动,
则需要在app/css/index.css中配置1
2
3
4
5
6
7
8
9
10
11
12
13
14html,
body {
...
-webkit-app-region: drag; // 这会导致一些按钮无法点击
...
}
.close, // 关闭按钮
.settings, // 设置按钮
.button-sound { // 播放按钮
...
-webkit-app-region: no-drag;// 设置为不能拖动
} -
功能实现
index.js中接收事件并通知主线程
1
2
3
4
5
6var ipc = require('ipc');
var closeEl = document.querySelector('.close');
closeEl.addEventListener('click', function () { // 接收事件
ipc.send('close-main-window'); // 通知主线程
});主线程中接收通知并处理
1
2
3
4
5var ipc = require('ipc');
ipc.on('close-main-window', function () { // 接到通知
app.quit(); // 关闭应用
});
全局快捷键
electron包含了一个global-shortcut模块,
可以绑定快捷键,
主线程中定义全局快捷键并在触发时,通知网页
1 | var globalShortcut = require('global-shortcut'); |
网页的index.js中接收通知并执行相关动作
1 | ipc.on('global-shortcut', function (arg) { |
另一个页面
其实就是配置页面.
页面1发消息给主线程,要求主线程打开页面2
1 | var settingsEl = document.querySelector('.settings'); // 页面1上有个叫settings的按钮 |
主线程中保持一个引用防止gc,然后写上打开新页面的代码
1 | var settingsWindow = null; // 保持一个引用防止被GC |
页面2中定义关闭按钮按下时的动作
1 | var ipc = require('ipc'); |
配置保存在文件中
-
配置文件和读写
需要一个类似DAO的读写驱动(
configuration.js
)
以及一个保存配置的json文件(~/.sound-machine-config.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;
/** nconf为js中一个读写文件的模块,
* 包括打开文件,或者是打开json文件,
* 以及读写文件的功能
* 安装时可以使用npm install --save表示模块被作为依赖安装在项目中,--save-dev参数表示发布时不带这个模块
*/
var nconf = require('nconf').file({file: getUserHome() + '/sound-machine-config.json'});
/* 保存一个配置 */
function saveSettings(settingKey, settingValue) {
nconf.set(settingKey, settingValue);
nconf.save();
}
/* 读取一个配置 */
function readSettings(settingKey) {
nconf.load();
return nconf.get(settingKey);
}
/* 用于帮助获取配置文件路径 */
function getUserHome() {
return process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME'];
}
/* 要向外暴露功能,成全自身的模块化 */
module.exports = {
saveSettings: saveSettings,
readSettings: readSettings
}; -
配置文件的默认生成
配置文件可以看出是存储在用户自己目录下的,不像windows一样有应用自己的文件夹.
所以一般不在项目中直接包括一个写好的配置文件.
在main.js中做处理1
2
3
4
5
6
7
8var configuration = require('./configuration');
app.on('ready', function () {
if (!configuration.readSettings('shortcutKeys')) { // 如果读取不到配置,说明没有配置文件
configuration.saveSettings('shortcutKeys', ['ctrl', 'shift']); // 那么新建一个配置.
}
...
} -
配置文件的加载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23app.on('ready', function () {
// ...
// 这里在初始化时加载配置中的文件
setGlobalShortcuts();
});
function setGlobalShortcuts() {
// 取消原先所有的绑定
globalShortcut.unregisterAll();
// 读取配置文件
var shortcutKeysSetting = configuration.readSettings('shortcutKeys');
// 整理快捷键表达式格式
var shortcutPrefix = shortcutKeysSetting.length === 0 ? '' : shortcutKeysSetting.join('+') + '+';
// 按照配置绑定快捷键
globalShortcut.register(shortcutPrefix + '1', function () {
mainWindow.webContents.send('global-shortcut', 0);
});
globalShortcut.register(shortcutPrefix + '2', function () {
mainWindow.webContents.send('global-shortcut', 1);
});
} -
修改配置
settings页面的完整交互
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
31var configuration = require('../configuration');
for (var i = 0; i < modifierCheckboxes.length; i++) {
// 从配置文件中读出一个数组 ['ctrl', 'shift']
var shortcutKeys = configuration.readSettings('shortcutKeys');
// html上,一个复选框显示为Ctrl,有个属性值为 'ctrl' 的话
var modifierKey = modifierCheckboxes[i].attributes['data-modifier-key'].value;
// 数组里能找到'ctrl',则当前复选框被勾上
modifierCheckboxes[i].checked = shortcutKeys.indexOf(modifierKey) !== -1;
// 当复选框被改变时调用函数
modifierCheckboxes[i].addEventListener('click', function (e) {
bindModifierCheckboxes(e);
});
}
function bindModifierCheckboxes(e) {
var shortcutKeys = configuration.readSettings('shortcutKeys');
var modifierKey = e.target.attributes['data-modifier-key'].value;
if (shortcutKeys.indexOf(modifierKey) !== -1) { // 如果已经有这个控制键了
var shortcutKeyIndex = shortcutKeys.indexOf(modifierKey);
shortcutKeys.splice(shortcutKeyIndex, 1); // 那就找到并删除这个配置
}
else {
shortcutKeys.push(modifierKey); // 如果找不到控制键,则添加
}
configuration.saveSettings('shortcutKeys', shortcutKeys); // 调用configuration来更改配置文件
ipc.send('set-global-shortcuts'); // 通知主进程重新绑定全局快捷键
}主进程中的操作
1
2
3ipc.on('set-global-shortcuts', function () {
setGlobalShortcuts();
});
托盘菜单
不知道为什么,明明已经使用了ipc来发送信息,但还是在外面包裹了一层remote模块的组件.
1 | var remote = require('remote'); |
打包应用
这方面的主流工具有
electron-forge
electron-packager
electronl-builder
冷门的还有 electron-installer-debian 等等.
虽然打包命令有时候很长,但可以在 package.json
中定义快捷方式,这里暂时用electron-packager举例
1 | "scripts": { |
然后就能使用 npm run package
调用了
electron-packager
该工具特点
- 使用简单,有大量的默认配置,最简单使用使用方法就是
electron-packager .
- 打包出文件夹,里面有可执行文件以及动态库等
1 | node_modules/electron-packager/bin/electron-packager.js . electron-lingvist --platform=linux --arch=x64 --asar --icon=./assets/lingvist.icns --overwrite --out=./dist |
一些注意
- 如果没有全局安装,则需要到本地的node~modules路径下找可执行的electron~-packager.js
- 如果不指定platform和arch将使用和本机相同的参数
- asar暂时意义不明
- 图标格式(win用ico,mac用icns,linux是png),但不知道为什么图标一直不成功
- 由于包含V8引擎和Chromium内核,空应用大小有121M.体积比较大.
electron-builder
工具特点
- 有更多自动化配置,使用命令更简单
- 如果有,配置以json形式写在package.json文件里,更加清晰明了
- 默认生成路径为dist,可以不用往git仓库里手动加dist文件夹了
- 打包分成两个级别,一个是不压缩的包含可执行文件的文件夹(看起来和electron-packager效果相同),一个是安装文件
- 要求图标最小是256*256,如果是mac应用,最小是512*512,可见electron-builder追求更完美的打包
- 甚至支持pacman下的安装文件(尽管不标准但仍然能使用
pacman -U xxx.pacman
安装) - 官方文档丰富且好看
- 不知为何可执行文件本身是没有图标的,只有安装包安装后才有图标
1 | "build": { |
对应需要的的资源文件夹为
1 | build(builder默认的资源文件夹) |
包装别人的web应用
这里以包装lingvist为例
如何访问其他URL
使用loadURL即可
1 | mainWindow.loadURL('https://learn.lingvist.com/#guess') |
这个浏览器不太安全
出现这个问题的原因是userAgent不正确.
网上有一种方法是
1 | win.loadURL(authUrl, {userAgent: 'Chrome'}) |
但这只能保证目前打开的这一个窗口的userAgent,
而通常谷歌登陆,会重新打开一个窗口.
这样会导致新页面的userAgent无人设置,从而不被信任.
于是网上有一个全局的配置方法
1 | const {session} = require('electron') |
授权的死循环问题
上面设置userAgent还有一个简便方法
1 | app.whenReady().then(() => { |
经测试不会出现死循环的问题
如何从0开始一个项目
npm init
然后回答问题以生成package.json
npm install --save-dev electron
以添加开发时的依赖
缩放问题
-
固定比例
代码中可以设置
1
2
3
4
5
6
7const mainWindow = new BrowserWindow({
width: 300,
height: 500,
webPreferences: {
zoomFactor:1.5,
}
}); -
相对大小
如果使用固定大小,发现直接运行和打包以后运行的窗口大小不同.
于是转而使用相对大小.
electron的screen模块可以获取屏幕大小.1
2
3
4
5
6
7const {screen} = require("electron");
const screenSize = screen.getPrimaryDisplay().workAreaSize;
const mainWindow = new BrowserWindow({
width: parseInt(screenSize.width * 0.31),
height: parseInt(screenSize.height * 0.81),
// ...
});如果在不同设备上使用,建议使用一个配置文件保存用户自定义的大小
-
鼠标或快捷键控制
electron自带的标题栏可以使用
C-+
,C--
,C-0
来控制
i3桌面的浮动窗口问题
for-window [class=“electron-lingvist”] floating enable
隐藏标题栏,但功能键保存
1 | new BrowserWindow({ |
无法显示桌面通知
收到消息后无法显示桌面通知,命令行显示
libnotify failed to connect to proxy
卡顿了很久后又好用了
根本原因并非无法连接,也不是这个应用本身没有推送通知的权限.
而可能是整个系统的通知系统,都只有推送,没有显示,造成了通道堵塞(估计容量为1),
因此安装dunst作为显示前端之后ok了