electron笔记

背景

想把lingvist做成本地应用,方便在linux平台上的启动,
(mac上可以用alfred来快速打开)

相比移动端和云应用,桌面端的优点在于

  • 启动方便
  • 与系统的交互更好(比如应用切换更方便)

介绍

由Github开发,使用html,css,js来构建跨平台应用.
将Chromium和Node.js整合到一个运行时环境中,
然后打包为一个应用.

入门

文件结构

本质上是一个Node.js应用
基础的结构为

1
2
3
4
.
├── package.json
├── main.js
└── index.html
  1. package.json

    配置文件,配置项:

    • name 应用名
    • version 应用版本
    • main 启动脚本,默认是index.js,electron中习惯手动指定为main.js
    • scripts 各种命令行脚本
      • start 启动命令,如果是electron,则其对应的shell命令为 electron .
  2. 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
    7
    app.whenReady().then(() => {  // 像lisp一样创建了一个匿名的函数
    createWindow()

    app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
    })
    })
  3. index.html

    看来electron做了最基础的分割,
    view层与controller层分开了.
    示例代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!DOCTYPE html>
    <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_modulespackage-lock.json
  • 开始运行本地应用

与React项目联动

假设本地有一个React的Web应用,
想让其使用electron包装为一个本地应用.
则主要需要做两个操作.
在main.js中打开react项目的本地端口

1
2
//win.loadFile('index.html') 这一行替换为:
win.loadFile('http://localhost:3000/')

调整启动命令,package.json中

1
2
3
4
5
6
7
"scripts": {
"start": "react-scripts start",
"build": "react-scriptsbuild",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject",
"electron": "electron ."
}

start对应 npm start 命令,此处设置为了启动react项目
其他脚本无法直接使用 npm xxxx 而是使用 npm run xxx
于是要在本地应用中使用,需要执行

1
2
npm start        # 启动react
npm run electron # 启动本地应用

主进程和渲染进程

当创建了一个electron应用,一个主进程就被创建了.
该主进程负责

  • 打开网页(渲染进程),一个甚至是多个.
    • 如果被渲染的网页是自己写在项目内部的(html, css, js)那就实现了用web知识开发本地应用的目的了.
  • 负责用户与GUI的交互
    • 比如发出桌面通知
    • 比如为用户设置快捷键以快速开始某些功能

进程间通信

electron推荐在网页中接收捕获事件(比如按钮点击事件等等)后,
由渲染线程发送信号给主线程,由主线程执行具体的操作.
以防止直接在渲染线程中发生内存泄露问题.

electron在本质上,通信的机制叫ipc,
不过多个模块都能使用该机制,

  • electron模块中有ipc-renderer和ipc-main.
  • ipc模块中有ipc
  • remote模块有getGlobal

注:js的模块使用require来导入

  1. 相互通信

    可以使用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' // 这是定义返回值的语法?
    })
  2. 渲染进程向主进程发消息

    实质上发送信息的是ipc,
    但不知道为什么,外部用remote模块中的插件包裹了一下.

    1
    2
    3
    4
    5
    6
    // 在渲染进程打开提示对话框
    const ipc = require('ipc');
    const {dialog} = require('electron').remote
    dialog.showMessageBox(options, (index) => {
    // ipc.send(...)
    })
  3. 主进程向渲染进程发消息

    使用的是webContents
    比如告知网页,用户按下了全局快捷键1,要求网页执行对应动作

    1
    win.webContents.send('ping', 'whoooooooh!')
  4. 渲染进程之间的通信

    两个渲染进程之间通信可以使用很多方法,
    浏览器已经实现的有

    • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 保存一个全局的window对象防止被GC
let win

function createWindow () {
win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')

// 打开开发者工具
win.webContents.openDevTools()

// window被关闭时也清空win的应用.
win.on('closed', () => {
win = null
})
}

知乎上的声效器项目

应用本身的实现

属于网页的一个js文件: index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
'use strict';

var soundButtons = document.querySelectorAll('.button-sound');

for (var i = 0; i < soundButtons.length; i++) {
var soundButton = soundButtons[i];
var soundName = soundButton.attributes['data-sound'].value;
// html中的每个按钮都这样设置
prepareButton(soundButton, soundName);
}

function prepareButton(buttonEl, soundName) {
// 设置按钮背景图
buttonEl.querySelector('span').style.backgroundImage = 'url("img/icons/' + soundName + '.png")';

var audio = new Audio(__dirname + '/wav/' + soundName + '.wav');
buttonEl.addEventListener('click', function () {
audio.currentTime = 0;
// 按钮被点击后播放音乐
audio.play();
});
}

关闭窗口

在index.js中定义close按钮的事件
然而并不在事件中写 window.close() 这样的原生GUI代码,
而是在事件中通知主线程,
然后在主线程中执行 app.quit() 来退出应用.
以防止可能出现的内存泄露.

  1. 处理拖动问题

    如果应用本身被固定了大小,
    且希望被拖动,
    则需要在app/css/index.css中配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    html,
    body {
    ...
    -webkit-app-region: drag; // 这会导致一些按钮无法点击
    ...
    }

    .close, // 关闭按钮
    .settings, // 设置按钮
    .button-sound { // 播放按钮
    ...
    -webkit-app-region: no-drag;// 设置为不能拖动
    }

  2. 功能实现

    index.js中接收事件并通知主线程

    1
    2
    3
    4
    5
    6
    var ipc = require('ipc');

    var closeEl = document.querySelector('.close');
    closeEl.addEventListener('click', function () { // 接收事件
    ipc.send('close-main-window'); // 通知主线程
    });

    主线程中接收通知并处理

    1
    2
    3
    4
    5
    var ipc = require('ipc');

    ipc.on('close-main-window', function () { // 接到通知
    app.quit(); // 关闭应用
    });

全局快捷键

electron包含了一个global-shortcut模块,
可以绑定快捷键,
主线程中定义全局快捷键并在触发时,通知网页

1
2
3
4
5
6
7
8
9
10
11
12
var globalShortcut = require('global-shortcut');

app.on('ready', function() {
// ...

globalShortcut.register('ctrl+shift+1', function () { // 将一个函数绑定在 ctrl+shift+1键上
mainWindow.webContents.send('global-shortcut', 0); // 发出的信息中带参数
});
globalShortcut.register('ctrl+shift+2', function () {
mainWindow.webContents.send('global-shortcut', 1);
});
});

网页的index.js中接收通知并执行相关动作

1
2
3
4
ipc.on('global-shortcut', function (arg) {
var event = new MouseEvent('click');
soundButtons[arg].dispatchEvent(event); // 参数作为了单选按钮的序号.
});

另一个页面

其实就是配置页面.
页面1发消息给主线程,要求主线程打开页面2

1
2
3
4
var settingsEl = document.querySelector('.settings');  // 页面1上有个叫settings的按钮
settingsEl.addEventListener('click', function () {
ipc.send('open-settings-window'); // 点击后发消息给主线程
});

主线程中保持一个引用防止gc,然后写上打开新页面的代码

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
var settingsWindow = null;   // 保持一个引用防止被GC

ipc.on('open-settings-window', function () {
if (settingsWindow) { // 如果窗口已经被打开了则什么也不做直接返回
return;
}

settingsWindow = new BrowserWindow({
frame: false,
height: 200,
resizable: false,
width: 200
}); // 定义窗口大小

settingsWindow.loadURL('file://' + __dirname + '/app/settings.html'); // 定义窗口对应页面

// 可能是electron.BrowserWindow.close()方法在执行完后发出的一个信息
settingsWindow.on('closed', function () {
settingsWindow = null;
});
});

ipc.on('close-settings-window', function () { // 新窗口也是需要监听关闭事件的
if (settingsWindow) {
settingsWindow.close();
}
});

页面2中定义关闭按钮按下时的动作

1
2
3
4
5
6
var ipc = require('ipc');

var closeEl = document.querySelector('.close');
closeEl.addEventListener('click', function (e) {
ipc.send('close-settings-window');
});

配置保存在文件中

  1. 配置文件和读写

    需要一个类似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
    'use strict';

    /** 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
    };
  2. 配置文件的默认生成

    配置文件可以看出是存储在用户自己目录下的,不像windows一样有应用自己的文件夹.
    所以一般不在项目中直接包括一个写好的配置文件.
    在main.js中做处理

    1
    2
    3
    4
    5
    6
    7
    8
    var configuration = require('./configuration');

    app.on('ready', function () {
    if (!configuration.readSettings('shortcutKeys')) { // 如果读取不到配置,说明没有配置文件
    configuration.saveSettings('shortcutKeys', ['ctrl', 'shift']); // 那么新建一个配置.
    }
    ...
    }
  3. 配置文件的加载

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    app.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);
    });
    }
  4. 修改配置

    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
    31
    var 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
    3
    ipc.on('set-global-shortcuts', function () {
    setGlobalShortcuts();
    });

托盘菜单

不知道为什么,明明已经使用了ipc来发送信息,但还是在外面包裹了一层remote模块的组件.

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
var remote = require('remote');
var Tray = remote.require('tray'); // 托盘图标是用remote的组件
var Menu = remote.require('menu'); // 托盘菜单也是用remote的组件
var path = require('path'); // 要加载图片所以要处理文件路径,为了跨平台,搞了个path包

var trayIcon = null;

// 不知道为什么,不同平台区别对待了托盘图标
if (process.platform === 'darwin') {
trayIcon = new Tray(path.join(__dirname, 'img/tray-iconTemplate.png'));
}
else {
trayIcon = new Tray(path.join(__dirname, 'img/tray-icon-alt.png'));
}

var trayMenuTemplate = [
// 定义各种菜单的行为
{
label: 'Sound machine',
enabled: false
},
{
label: 'Settings',
click: function () {
ipc.send('open-settings-window'); // 这些代码写在页面1的js中,所以要发送信息.
}
},
{
label: 'Quit',
click: function () {
ipc.send('close-main-window');
}
}
];
var trayMenu = Menu.buildFromTemplate(trayMenuTemplate);
trayIcon.setContextMenu(trayMenu);

打包应用

这方面的主流工具有

  1. electron-forge
  2. electron-packager
  3. electronl-builder

冷门的还有 electron-installer-debian 等等.

虽然打包命令有时候很长,但可以在 package.json 中定义快捷方式,这里暂时用electron-packager举例

1
2
3
4
"scripts": {
"start": "electron .",
"package": "electron-packager ./ SoundMachine --all --out ~/Desktop/SoundMachine --version 0.30.2 --overwrite --icon=./app/img/app-icon.icns"
}

然后就能使用 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
2
3
4
5
6
7
8
"build": {
"linux": {
"icon": "build/icons",
"target": [
"pacman"
]
}
}

对应需要的的资源文件夹为

1
2
3
4
5
6
build(builder默认的资源文件夹)
└── icons
├── 225x225.png
├── 256x256.png
├── icon.icns
└── icon.png

包装别人的web应用

这里以包装lingvist为例

如何访问其他URL

使用loadURL即可

1
mainWindow.loadURL('https://learn.lingvist.com/#guess')

这个浏览器不太安全

出现这个问题的原因是userAgent不正确.
网上有一种方法是

1
win.loadURL(authUrl, {userAgent: 'Chrome'})

但这只能保证目前打开的这一个窗口的userAgent,
而通常谷歌登陆,会重新打开一个窗口.
这样会导致新页面的userAgent无人设置,从而不被信任.

于是网上有一个全局的配置方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const {session} = require('electron')

app.whenReady().then(() => {
// session只能在应用启动后设置,而不能在app启动前,也就是在app.whenReady的外部
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
details.requestHeaders['User-Agent'] = 'Chrome';
callback({
cancel: false,
requestHeaders: details.requestHeaders
});
});

// ...
}

授权的死循环问题

上面设置userAgent还有一个简便方法

1
2
3
4
app.whenReady().then(() => {
session.defaultSession.setUserAgent("Chrome");
// ...
});

经测试不会出现死循环的问题

如何从0开始一个项目

npm init 然后回答问题以生成package.json
npm install --save-dev electron 以添加开发时的依赖

缩放问题

  1. 固定比例

    代码中可以设置

    1
    2
    3
    4
    5
    6
    7
    const mainWindow = new BrowserWindow({
    width: 300,
    height: 500,
    webPreferences: {
    zoomFactor:1.5,
    }
    });
  2. 相对大小

    如果使用固定大小,发现直接运行和打包以后运行的窗口大小不同.
    于是转而使用相对大小.
    electron的screen模块可以获取屏幕大小.

    1
    2
    3
    4
    5
    6
    7
    const {screen} = require("electron");
    const screenSize = screen.getPrimaryDisplay().workAreaSize;
    const mainWindow = new BrowserWindow({
    width: parseInt(screenSize.width * 0.31),
    height: parseInt(screenSize.height * 0.81),
    // ...
    });

    如果在不同设备上使用,建议使用一个配置文件保存用户自定义的大小

  3. 鼠标或快捷键控制

    electron自带的标题栏可以使用 C-+, C--, C-0 来控制

i3桌面的浮动窗口问题

for-window [class=“electron-lingvist”] floating enable

隐藏标题栏,但功能键保存

1
2
3
4
new BrowserWindow({
// ...
autoHideMenuBar: true,
})

无法显示桌面通知

收到消息后无法显示桌面通知,命令行显示
libnotify failed to connect to proxy
卡顿了很久后又好用了

根本原因并非无法连接,也不是这个应用本身没有推送通知的权限.
而可能是整个系统的通知系统,都只有推送,没有显示,造成了通道堵塞(估计容量为1),
因此安装dunst作为显示前端之后ok了

参考

  1. 官方入门示例
  2. 掘金的入门教程
  3. 知乎上的解释
  4. 一个很慢但比较好看的博客
  5. 解决userAgent不被Google Oauth信任的问题
  6. 官方9.2.1中文文档