个人看法是利用主要利用前端技术快速开发跨平台应用程序.趁现在发展还不错(至少比WinForm,WPF,Qt开发更年轻),至于移动端,除了原生之外还有Flutter和React Native等技术.
知识点
流程模型
Electron是多进程的,因为单进程意味着一个网站的崩溃或无响应会影响到整个浏览器.
Electron 应用程序的结构非常相似。 作为应用开发者,你将控制两种类型的进程:主进程 和 渲染器进程。 这类似于上文所述的 Chrome 的浏览器和渲染器进程。
主进程
每个 Electron 应用都有一个单一的主进程,作为应用程序的入口点。 主进程在 Node.js 环境中运行,这意味着它具有 require 模块和使用所有 Node.js API 的能力
窗口管理
主进程的主要目的是使用 BrowserWindow 模块创建和管理应用程序窗口。
BrowserWindow 类的每个实例创建一个应用程序窗口,且在单独的渲染器进程中加载一个网页。 从主进程用 window 的 webContent 对象与网页内容进行交互。
由于 BrowserWindow 模块是一个 EventEmitter, 所以您也可以为各种用户事件 ( 例如,最小化 或 最大化您的窗口 ) 添加处理程序。
当一个 BrowserWindow 实例被销毁时,与其相应的渲染器进程也会被终止
下面介绍一下EventEmitter类
Node.js 所有的异步 I/O 操作在完成时都会发送一个事件到事件队列。
Node.js 里面的许多对象都会分发事件:一个 net.Server 对象会在每次有新连接时触发一个事件, 一个 fs.readStream 对象会在文件被打开的时候触发一个事件。 所有这些产生事件的对象都是 events.EventEmitter 的实例。1
2
3
4
5
6
7
8
9//event.js 文件
var EventEmitter = require('events').EventEmitter;
var event = new EventEmitter();
event.on('some_event', function() {
console.log('some_event 事件触发');
});
setTimeout(function() {
event.emit('some_event');
}, 1000);
应用程序生命周期
主进程还能通过 Electron 的 app 模块来控制您应用程序的生命周期。 该模块提供了一整套的事件和方法,可以让您用来添加自定义的应用程序行为 (例如:以编程方式退出您的应用程序、修改应用程序坞,或显示一个关于面板)1
2
3
4// quitting the app when no windows are open on non-macOS platforms
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
原生 API
为了使 Electron 的功能不仅仅限于对网页内容的封装,主进程也添加了自定义的 API 来与用户的作业系统进行交互。 Electron 有着多种控制原生桌面功能的模块,例如菜单、对话框以及托盘图标。
相关文档:app | Electron
渲染器进程
每个 Electron 应用都会为每个打开的 BrowserWindow ( 与每个网页嵌入 ) 生成一个单独的渲染器进程。 洽如其名,渲染器负责 渲染 网页内容。 本质上,在渲染进程内运行的代码的行为应当遵循 Web 标准(至少在 Chromium 所遵循的范围内)。
因此,一个浏览器窗口中的所有的用户界面和应用功能,都应与在网页开发上使用相同的工具和规范来进行攥写。
- 以一个 HTML 文件作为渲染器进程的入口点。
- 使用层叠样式表 (Cascading Style Sheets, CSS) 对 UI 添加样式。
- 通过
<script>元素可添加可执行的 JavaScript 代码。
渲染器无权直接访问 require 或其他 Node.js API。 为了在渲染器中直接包含 NPM 模块,您必须使用与在 web 开发时相同的打包工具 (例如 webpack 或 parcel)
为了方便开发,可以用完整的 Node.js 环境生成渲染器进程。 在历史上,这是默认的,但由于安全原因,这一功能已被禁用。
预加载脚本
预加载脚本实践中通常用来为无法访问Node.js和Electron的渲染进程提供API,会在渲染进程之前执行程序。
预加载(preload)脚本包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码 。 这些脚本虽运行于渲染器的环境中,却因能访问 Node.js API** 而拥有了更多的权限1
2
3
4
5
6
7
8const { BrowserWindow } = require('electron')
// ...
const win = new BrowserWindow({
webPreferences: {
preload: 'path/to/preload.js'
}
})
// ...
因为预加载脚本与浏览器共享同一个全局 Window 接口,并且可以访问 Node.js API,所以它通过在全局 window 中暴露任意 API 来增强渲染器,以便你的网页内容使用。尽管预加载脚本与其所附着的渲染器共享着同一个全局 window 对象,但并不能在预加载脚本中直接将任何变量附加到 window 上,因为 contextIsolation 是默认启用的。
语境隔离(Context Isolation)意味着预加载脚本与渲染器的主要运行环境是隔离开来的,以避免泄漏任何具特权的 API 到您的网页内容代码中。作为替代,请使用 contextBridge 模块来安全地实现这一目的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
const { contextBridge, ipcRenderer } = require("electron/renderer")
contextBridge.exposeInMainWorld("myApi",{
send: (channel,data) => {
let validChannels = ["toMain"]
if(validChannels.includes(channel)){
ipcRenderer.send(channel,data)
}
},
receive: (channel,func) => {
let validChannels = ["fromMain"]
if(validChannels.includes(channel)){
ipcRenderer.on(channel,(event,...args) => func(...args))
}
},
setTitle:(title)=>{
ipcRenderer.send("set-title",title)
},
openFile:()=>ipcRenderer.invoke("dialog:openFile"),
})
上下文隔离
上下文隔离功能将确保您的 预加载脚本 和 Electron的内部逻辑 运行在所加载的 webcontent网页 之外的另一个独立的上下文环境里。 这对安全性很重要,因为它有助于阻止网站访问 Electron 的内部组件 和 预加载脚本可访问的高等级权限的API 。
实际上预加载脚本访问的 window 对象并不是网站所能访问的对象。 例如,如果您在预加载脚本中设置 window.hello = 'wave' 并且启用了上下文隔离,当网站尝试访问window.hello对象时将返回 undefined。
自 Electron 12 以来,默认情况下已启用上下文隔离
1 | // 在上下文隔离启用的情况下使用预加载 |
1 | // renderer.js |
进程间通信
由于主进程和渲染器进程在 Electron 的进程模型具有不同的职责,因此 IPC 是执行许多常见任务的唯一方法,例如从 UI 调用原生 API 或从原生菜单触发 Web 内容的更改。
IPC通道
利用 ipcMain 和 ipcRenderer 模块,进程之间可以通过开发者定义的“通道”传递消息来进行通信。 这些通道是 任意 (您可以随意命名它们)和 双向 (您可以在两个模块中使用相同的通道名称)的。
渲染器进程到主进程(单向)
单向 IPC 消息从渲染进程发送到主进程,可以使用 ipcRenderer.send API 发送消息,然后使用 ipcMain.on API 接收消息。
关键:主进程设计ipcMain.on接口订阅event,预加载脚本中暴露触发event的方法,渲染进程中通过ipcRenderer.send发布.
渲染器进程设置窗口标题1
2
3
4
5
6
7// renderer.js
const setButton = document.getElementById('btn')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
const title = titleInput.value
window.electronAPI.setTitle(title)
})1
2
3
4
5
6// preload.js
const { contextBridge, ipcRenderer } = require('electron/renderer')
contextBridge.exposeInMainWorld('electronAPI', {
setTitle: (title) => ipcRenderer.send('set-title', title)
})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//main.js
const { app, BrowserWindow, ipcMain } = require('electron/main')
const path = require('node:path')
function handleSetTitle (event, title) {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
}
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => {
ipcMain.on('set-title', handleSetTitle)
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
渲染器进程到主进程(双向)
双向 IPC 的一个常见应用是从渲染器进程代码调用主进程模块并等待结果。 这是通过搭配使用 ipcRenderer.invoke 和 ipcMain.handle 来实现的。
渲染进程打开一个原生dialog,并获取输入文本1
2
3
4
5
6
7
8// render.js
const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')
btn.addEventListener('click', async () => {
const filePath = await window.electronAPI.openFile()
filePathElement.innerText = filePath
})1
2
3
4
5
6//preload.js
const { contextBridge, ipcRenderer } = require('electron/renderer')
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile')
})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// main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron/main')
const path = require('node:path')
async function handleFileOpen () {
const { canceled, filePaths } = await dialog.showOpenDialog()
if (!canceled) {
return filePaths[0]
}
}
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => {
ipcMain.handle('dialog:openFile', handleFileOpen)
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
主进程到渲染器进程
将消息从主进程发送到渲染器进程时,需要指定是哪一个渲染器接收消息。 消息需要通过渲染进程的 WebContents 实例来发送过去。 这个 WebContents 实例包含了一个 send 方法,其使用方法与 ipcRenderer.send 相同。
通过主进程修改渲染进程的页面中文字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// main.js
const { app, BrowserWindow, Menu, ipcMain } = require('electron/main')
const path = require('node:path')
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{
click: () => mainWindow.webContents.send('update-counter', 1),
label: 'Increment'
},
{
click: () => mainWindow.webContents.send('update-counter', -1),
label: 'Decrement'
}
]
}
])
Menu.setApplicationMenu(menu)
mainWindow.loadFile('index.html')
// Open the DevTools.
mainWindow.webContents.openDevTools()
}
app.whenReady().then(() => {
ipcMain.on('counter-value', (_event, value) => {
console.log(value) // will print value to Node console
})
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})1
2
3
4
5
6
7// 预加载脚本
const { contextBridge, ipcRenderer } = require('electron/renderer')
contextBridge.exposeInMainWorld('electronAPI', {
onUpdateCounter: (callback) => ipcRenderer.on('update-counter', (_event, value) => callback(value)),
counterValue: (value) => ipcRenderer.send('counter-value', value)
})1
2
3
4
5
6
7
8
9// 渲染器进程
const counter = document.getElementById('counter')
window.electronAPI.onUpdateCounter((value) => {
const oldValue = Number(counter.innerText)
const newValue = oldValue + value
counter.innerText = newValue.toString()
window.electronAPI.counterValue(newValue)
})
进程沙盒化
Chromium中的一个关键安全特性是进程可以在沙盒中执行。沙盒通过限制对大多数系统资源的访问来限制恶意代码可能造成的危害——沙盒进程只能自由使用CPU周期和内存。为了执行需要额外特权的操作,沙盒进程使用专用通信通道将任务委派给更有特权的进程。
从 Electron 20 开始,渲染进程默认启用了沙盒,无需进一步配置。
沙盒化与 Node.js 集成紧密相关。 设置 nodeIntegration: true或者sandbox:false,为渲染进程启用 Node.js 集成会禁用该进程的沙盒。*
渲染进程
当Electron中的渲染器进程被沙盒化时,它们的行为与常规Chrome渲染器的行为相同。沙盒渲染器不会初始化Node.js环境。因此,当启用沙箱时,渲染器进程只能通过进程间通信(IPC)将这些任务委派给主进程来执行特权任务(如与文件系统交互、对系统进行更改或生成子进程)。
预加载脚本
为了允许渲染器进程与主进程通信,附加到沙盒渲染器的预加载脚本仍将有Node.js API的多填充子集可用。暴露了类似于Node的require模块的require函数,但只能导入Electron和Node内置模块的子集:
electron(following renderer process modules:contextBridge,crashReporter,ipcRenderer,nativeImage,webFrame)eventstimersurl
node: imports are supported as well:
此外,预加载脚本还将某些Node.js基元聚合填充为全局:
由于require函数是一个功能有限的polyfill,因此您将无法使用CommonJS模块将预加载脚本分离为多个文件。如果您需要拆分预加载代码,请使用打包器,如webpack或Parcel。请注意,因为提供给预加载脚本的环境比沙盒渲染器的环境具有更高的特权,所以除非启用了contextIsolation,否则仍有可能将特权API泄露给渲染器进程中运行的不受信任的代码。
单个进程禁用沙盒
1 | app.whenReady().then(() => { |
在渲染器中启用 nodeIntegration 时,沙盒也会被禁用。 可以通过往 BrowserWindow 构造函数传入 nodeIntegration: true 标志或者为 webview 提供对应的 HTML 布尔值属性来实现。1
2
3
4
5
6
7
8app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
nodeIntegration: true
}
})
win.loadURL('https://google.com')
})1
2// index.html
<webview nodeIntegration src="page.html"></webview>
全局启用沙盒
1 | app.enableSandbox() |
Electron中的消息端口
MessagePorts是一个允许在不同上下文之间传递消息的web功能。这就像window.postMessage,但在不同的channel上。
在渲染进程中,MessagePort类的行为与它在web上的行为完全相同。不过,主进程不是网页——它没有Blink集成——因此它没有MessagePort或MessageChannel类。为了在主进程中处理和交互MessagePorts,Electron添加了两个新类:MessagePortMain和MessageChannelMain。这些类的行为与渲染器中的类似类类似。
MessagePort对象可以在渲染器或主进程中创建,并使用ipcRenderer.postMessage和WebContents.postMessage方法来回传递。请注意,通常的IPC方法(如send和invoke)不能用于传输MessagePorts,只有postMessage方法可以传输MessagePort。
通过主进程传递MessagePorts,您可以连接两个可能无法通信的页面(例如,由于同源限制)。
Electron为MessagePort添加了一个web上没有的功能,以使MessagePort更有用。这就是关闭事件,当通道的另一端关闭时会发出该事件。端口也可以通过垃圾收集来隐式关闭。在渲染器中,您可以通过分配给port.onclose或调用port.addEventListener(’close’,…)来侦听关闭事件。在主进程中,您也可以通过调用port.on(’close’,……)来监听关闭事件。
打包应用
Electron Forge 是一个处理 Electron 应用程序打包与分发的一体化工具。 在工具底层,它将许多现有的 Electron 工具 (例如 @electron/packager、 @electron/osx-sign、electron-winstaller 等) 组合到一起,因此您不必费心处理不同系统的打包工作。
使用electron forge1
2npm install --save-dev @electron-forge/cli
npx electron-forge import
配置1
2
3
4
5
6
7//...
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make"
},
//...1
npm run make
Electron Forge 通过配置可以为不同的操作系统创建特定格式的可分发文件 Makers | Electron Forge
配置应用图标Custom App Icons | Electron Forge
对代码签名Code Signing | Electron Forge
发布和更新
使用 update.electronjs.org
Electron 官方在 https://update.electronjs.org 上为开源应用程序提供了免费的自动更新服务。 使用它有以下几点要求:
- 应用在 macOS 或 Windows 上运行
- 你的应用有一个公开的 GitHub 仓库
- 应用程序需要发布到 GitHub releases 中
- 应用程序已经进行了代码签名(仅限 macOS)
开发实践
通知
每个操作系统都有自己的机制向用户显示通知。 Electron的通知 API 是跨平台的,但对每个进程类型来说是不同的。
主进程通知
主进程通知使用 Electron 的通知模块显示。 使用此模块创建的通知对象不会立刻显示,除非调用他们的 show() 实例 方法。1
2
3
4
5
6
7
8
9const { Notification } = require('electron')
const NOTIFICATION_TITLE = 'Basic Notification'
const NOTIFICATION_BODY = 'Notification from the Main process'
new Notification({
title: NOTIFICATION_TITLE,
body: NOTIFICATION_BODY
}).show()
渲染进程通知
通知可以直接在渲染进程中使用 Web Notifications API 显示。
Renderer Process1
2
3
4
5
6
7const NOTIFICATION_TITLE = 'Title'
const NOTIFICATION_BODY =
'Notification from the Renderer process. Click to log to console.'
const CLICK_MESSAGE = 'Notification clicked'
new Notification(NOTIFICATION_TITLE, { body: NOTIFICATION_BODY }).onclick =
() => console.log(CLICK_MESSAGE)
虽然操作系统的代码和用户体验相似,但依然存在微妙的差异。
Windows
对于Windows上的通知 您的 Electron 应用需要有一个带有 AppUserModelID 和对应的 ToastActivatorCLSID 的开始菜单快捷方式。
Electron 尝试使AppUserModelID 和 ToastActivatorCLSID的工作自动化。 Electron在和安装和更新框架 Squirrel 一起使用的时候,Windows (例如你正在使用electron-winstaller)快捷方式将被自动正确的配置好。
在生产环境中,Electron 会自动检测 Squirrel 的存在,并且使用正确的值来自动调用app.setAppUserModelId()。 在开发环境中, 你可能需要自己调用 app.setAppUserModelld()
Windows还允许使用自定义模板、图像和其他灵活的元素进行高级通知。
要从主进程发送这些通知,您可以使用用户空间模块 electron-windows-notifications ,它使用本机Node插件发送ToastNotification 和 TileNotification 对象。
当包括按钮在内的通知使用 electron-windows-notifications 时,处理回复需要使用 electron-windows-interactive-notifications 帮助注册所需的 COM 组件并调用您的 Electron 应用程序和输入的用户数据。
查询通知状态
要检测是否允许发送通知,请使用用户空间的 windows-notification-state 模块。
该模块允许你事先确定 Windows 是否会静默丢弃通知。
macOS
在macOS上发送通知很简单,你应该参考苹果关于通知的人机界面指南。
请注意,通知的大小限制为256个字节,如果超过该限制,则会被截断。
查询通知状态
要检测是否允许发送通知,可以使用用户空间的macos-notification-state模块。
该模块允许你事先确定通知是否会被显示。
Linux
在 Linux 上,通知是使用 libnotify 发送的,它可以在遵循桌面通知规范的任何桌面环境上显示通知,包括 Cinnamon、Enlightenment、Unity、GNOME 和 KDE。
自定义窗口
窗口外框(chrome) 是指窗口中不是主网页内容的部分(如标题栏,工具栏,控件)。 虽然操作系统的窗口外框提供的默认标题栏对于简单用例足够了,但是很多应用选择去除它。 实现一个自定义的标题栏可以使您的应用更有现代感,并在多个平台中保持一致。
去除默认标题栏
要移除默认标题栏,将 BrowserWindow 构造函数中的BaseWindowContructorOptions titleBarStyle 参数设置为 'hidden'1
2
3
4
5
6
7
8
9
10
11
12
13const { app, BrowserWindow } = require('electron')
function createWindow () {
const win = new BrowserWindow({
// remove the default titlebar
titleBarStyle: 'hidden'
})
win.loadURL('https://example.com')
}
app.whenReady().then(() => {
createWindow()
})
添加原生窗口控件
在MacOS上,设置 titleBarStyle: 'hidden' 会去除标题栏,保留窗口左上角的红绿灯控件。 但是在 Windows 和 Linux 上,你需要通过设置 BrowserWindow 构造函数的 BaseWindowContructorOptions titleBarOverlay 参数来将窗口控件添加回你的 BrowserWindow。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15const { app, BrowserWindow } = require('electron')
function createWindow () {
const win = new BrowserWindow({
// remove the default titlebar
titleBarStyle: 'hidden',
// expose window controls in Windows/Linux
...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {})
})
win.loadURL('https://example.com')
}
app.whenReady().then(() => {
createWindow()
})
目前我们的应用窗口不能被移动。 已经删除了默认标题栏,应用需要告诉 Electron 哪些区域可以拖拽。 我们通过添加 样式的 app-region: drag 到自定义标题栏来做到这一点。 现在我们可以拖动自定义标题栏来重新定位我们的应用窗口了!1
2
3
4
5
6
7
8
9.titlebar {
height: 30px;
background: blue;
color: white;
display: flex;
justify-content: center;
align-items: center;
app-region: drag;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
<link href="./styles.css" rel="stylesheet">
<title>Custom Titlebar App</title>
</head>
<body>
<!-- mount your title bar at the top of you application's body tag -->
<div class="titlebar">Cool titlebar</div>
</body>
</html>
自定义窗口交互
默认情况下,使用操作系统窗口外框提供的标题栏可以拖拽窗口。 移除默认标题栏的应用需要使用 app-region CSS 属性来定义可以用于拖拽窗口的指定区域。 设置 app-region: drag 会将一块矩形区域标记为可拖拽。
可拖拽区域会忽略所有的指针事件。 例如,与可拖拽区域重叠的按钮元素将不会在重叠区域内产生鼠标点击或者鼠标进入/退出事件。 设置 app-region: no-drag 会将一块矩形区域排除出可拖拽区域,从而重新启用指针事件。
要让整个窗口可拖拽,你可以向 body 的样式里添加 app-region: drag。1
2
3body {
app-region: drag;
}
如果你只把自定义标题栏设置为可拖拽,你还需要设置标题栏里所有的按钮为不可拖拽。
创建可拖拽区域时,拖拽行为可能与文本选择相冲突。 例如,当你拖拽标题栏时,你可能会意外选中它的文本内容。 为了避免这种情况,您需要在可拖拽区域中禁用文本选择,像这样:1
2
3
4.titlebar {
user-select: none;
app-region: drag;
}
自定义窗口样式
无边框窗口移除了所有操作系统的窗口外框,包括窗口控件。1
2
3
4
5
6
7
8
9
10
11
12
13
14const { app, BrowserWindow } = require('electron')
function createWindow () {
const win = new BrowserWindow({
width: 300,
height: 200,
frame: false
})
win.loadURL('https://example.com')
}
app.whenReady().then(() => {
createWindow()
})
创建透明窗口1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const { app, BrowserWindow } = require('electron')
function createWindow () {
const win = new BrowserWindow({
width: 100,
height: 100,
resizable: false,
frame: false,
transparent: true
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
})
