Electron基础
基础概念
Electron流程模型
Electron 采用多进程模型,包括主进程
/渲染器进程
其中主进程包括所有可用的功能,即可以访问所有node api,而用于向用户展示的渲染器则只能使用浏览器提供的接口,但渲染器中仍然可以通过预加载文件的方式来获得更高的权限
主进程一般指的都是main.js
及其附属的js,而渲染器进程(有时此文档中也会称为子进程)则是由html和js渲染的前端页面。
类比于互联网开发中常见的前后端开发,前台指的就是渲染器进程,而后台可看作主进程
Electron上下文隔离
上下文隔离基础原理
一种隔离手段,目的是防止在网页环境下(html/js)中直接访问到Electron
/Node
的api接口,防止出现安全性问题,在新版本的Electron
中,上下文隔离默认启用
在很多情况下进程间仍然是需要进行通信的,比如渲染器中要将用户的输入保存为硬盘上的文件,此时就涉及到了访问操作系统文件接口的操作,此时我们就可以通过预加载文件的方式向渲染器提供一个保存文件的方法,以实现功能的扩展
关于安全性
对于安全性,我们在向渲染器提供新功能时,一定注意的是要将api封装为一种方法或者一种业务,而不是直接将api导出;类比于传统前后端的关系,前台当然可以获取到后台的数据,但绝对不能直接将操作数据库的api开放给前台,而是将这些api以一种业务接口的形式向前端暴露,最大限度的保障系统安全
上下文隔离案例(渲染器端写文件到当前目录)
1. 主进程中创建事件监听
main.js
中,添加一个ipcMain
监听
// 从node中引入文件模块
const fs = require('fs')
// 创建ipcMain事件监听
ipcMain.on('save-title', (event, title) => {
// 同步写文件
fs.writeFileSync("./title.txt",title)
})
2. 创建预加载文件,开放指定方法
在根目录创建文件preload.js
,其内容为:
// 预加载文件
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
setTitle: (title) => ipcRenderer.send('set-title', title),
saveTitle: (title) => ipcRenderer.send('save-title', title)
})
3. 修改index文件,增加输入框和按钮
修改index.html文件,如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Electron Test Demo</title>
</head>
<body>
<form>
窗口标题: <input id="title" />
<button id="btn" type="button">修改</button>
<button id="btn-save" type="button">保存</button>
<script src="./renderer.js"></script>
</form>
</body>
</html>
4.增加渲染器JS文件,并增加相应的操作
在index.html
同级目录下创建文件renderer.js
,其内容为:
const setButton = document.getElementById('btn')
const saveButton = document.getElementById('btn-save')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
const title = titleInput.value
window.electronAPI.setTitle(title)
});
saveButton.addEventListener('click', () => {
const title = titleInput.value
window.electronAPI.saveTitle(title)
});
5. 启动测试与预期
执行npm run start
即可
正常情况下,输入内容后点击修改
,可以修改当前窗口的标题,如果点击保存
,可以在根目录创建一个title.txt
文件
Electron进程间通信
进程间通信(IPC)是Electron
中极为主要的部分,Electron
支持各个进程互相通信,上文在流程模型中提到过,Electron应用是基于多进程模型的,因此无论是主进程还是一个或多个渲染器进程间都有可能进行数据交换,常见的情况有:
渲染器与主进程通信
渲染器进程和主进程通信一般有两种方式,一种是单向的传递数据,另一种则可以实现双向的通信
send
/on
案例同上下文隔离案例
此方法关键点在于主进程会监听一个事件,之后子进程(渲染器进程)会触发这个事件,因此这种方式是单项信息传递的
handle
/invoke
此案例中,系统启动后主界面存在一个输入框,用户可以手动输入或点击选择按钮选择地址,点击保存会将选择或输入的路径放到运行目录的title.txt中
需要注意的是点击选择的操作,要求用户选择文件的窗口由主函数弹出(Node方式),同时需要主函数将用户选择的路径返回给渲染器使用,因此通过hanle/invoke方式进行双向传递
在main.js中增加处理对话框的方法和handle事件
const { app, BrowserWindow, ipcMain, Menu, dialog } = require('electron')
const path = require('path')
const fs = require('fs')
async function handleFileOpen() {
const { canceled, filePaths } = await dialog.showOpenDialog()
if (canceled) {
return
} else {
return filePaths[0]
}
}
function createWindow() {
const mainWindow = new BrowserWindow({
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
Menu.setApplicationMenu(null);
mainWindow.loadFile('html/index.html')
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
}
app.whenReady().then(() => {
ipcMain.handle('dialog:openFile', handleFileOpen)
ipcMain.on('set-title', (event, title) => {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
})
ipcMain.on('save-title', (event, title) => {
fs.writeFileSync("./title.txt", title)
})
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
})
在preload.js中增加开放的接口
// 预加载文件
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
setTitle: (title) => ipcRenderer.send('set-title', title),
saveTitle: (title) => ipcRenderer.send('save-title', title),
openFile: () => ipcRenderer.invoke('dialog:openFile')
})
修改index.html,为其增加部分功能
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Electron Test Demo</title>
</head>
<body>
<form>
数据路径: <input id="title" />
<button id="btn" type="button">选择</button>
<button id="btn-save" type="button">
保存
</button>
<script src="./renderer.js"></script>
</form>
</body>
</html>
修改renderer.js,为html中的元素增加操作
const setButton = document.getElementById('btn')
const saveButton = document.getElementById('btn-save')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', async () => {
const filePath = await window.electronAPI.openFile()
titleInput.value = filePath;
window.electronAPI.setTitle(filePath)
});
saveButton.addEventListener('click', () => {
const title = titleInput.value
window.electronAPI.saveTitle(title)
});
主进程与渲染器进程通信
主进程到渲染器的通信一般通过webContents
方式
此示例中,用户选择文件的操作改为了菜单选择的方式,因此实际上是主进程
调用选择文件窗口
,并将获得的结果传递给渲染器
进行显示
修改main.js
const { app, BrowserWindow, Menu, ipcMain, dialog } = require('electron')
const path = require('path')
async function handleFileOpen() {
const { canceled, filePaths } = await dialog.showOpenDialog()
if (canceled) {
return
} else {
return filePaths[0]
}
}
function createWindow() {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{
click: async () => mainWindow.webContents.send('select-file', await handleFileOpen()),
label: '打开文件',
}
]
}
])
Menu.setApplicationMenu(menu)
mainWindow.loadFile('html/index.html')
// 如果放开下方的注释,会在启动时打开开发者工具(渲染器)
// mainWindow.webContents.openDevTools()
}
app.whenReady().then(() => {
// 监听改变后的路径
ipcMain.on('value', (_event, value) => {
console.log("Select File:", value)
})
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
修改preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
handleCounter: (callback) => ipcRenderer.on('select-file', callback)
})
修改index.html
<!DOCTYPE html>
<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'">
<title>Menu Counter</title>
</head>
<body>
当前选择的路径: <span id="counter"></span>
<script src="./renderer.js"></script>
</body>
</html>
修改renderer.js
const counter = document.getElementById('counter')
window.electronAPI.handleCounter((event, value) => {
counter.innerText = value
event.sender.send('value', value)
})
渲染器进程与渲染器进程通信
渲染器进程之间无法直接进行通信,但可以将主进程作为中间人的形式进行通信,或者通过消息端口(MessagePort)
的方式进行直接的通信
具体代码请参照下文的Electron消息端口
部分的示例,在该部分中,通过Electron消息端口
的方式实现了两个渲染器进程间的通信
Electron进程沙盒化
Electron和Chromium不同,Electron中执行的js大部分情况下是可以看作可信代码的,因为这部分代码一般情况下都是用户自行控制的,但仍然存在一些危险性,所以Electron使用了一种混合沙盒化
的机制,在这种机制下,前端的js可以获得更多的权限和功能,但同样的也会引起更深层和更危险的漏洞
为渲染器启用沙盒化
// main.js
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
sandbox: true
}
})
win.loadURL('https://google.com')
})
全局范围内启用沙盒化
这种操作会使所有的渲染器强制
// main.js
app.enableSandbox()
app.whenReady().then(() => {
// no need to pass `sandbox: true` since `app.enableSandbox()` was called.
const win = new BrowserWindow()
win.loadURL('https://google.com')
})
Electron消息端口
消息端口区别于其他的通信方式是它可以持续传递消息,并且一般情况下渲染器进程间通信都需要借助此功能
在此案例下,所有html文件及其附属js都存放在根目录的html
文件夹中
主进程(main.js)
const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')
app.whenReady().then(async () => {
// 用户信息窗口
const user = new BrowserWindow({
show: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
})
// 加载网页
await user.loadFile('html/user.html')
// 主窗口
const mainWindow = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
})
mainWindow.loadFile('html/app.html')
// 主窗口开启开发者工具
mainWindow.webContents.openDevTools()
// 用户窗口开启开发者工具
user.webContents.openDevTools()
// 监听app窗口事件
ipcMain.on('request-user-channel', (event) => {
if (event.senderFrame === mainWindow.webContents.mainFrame) {
// 创建通道
const { port1, port2 } = new MessageChannelMain()
// 将创建的通道送到user窗口
user.webContents.postMessage('user-client', null, [port1])
// 将另一端送到app窗口
event.senderFrame.postMessage('provide-user-channel', null, [port2])
}
})
})
渲染器进程(app.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="main">
<p>
用户名为:
<span id="name">默认</span>
</p>
<p>
<button id="sendMessage">
发送消息
</button>
</p>
</div>
<script src="./app.js"></script>
</body>
</html>
渲染器进程(app.js)
const { ipcRenderer } = require('electron')
const nameSpan = document.getElementById('name')
const sendMessage = document.getElementById('sendMessage')
// 与user窗口的通信管道
var userPort = undefined;
// 从主进程获取消息对象
ipcRenderer.send('request-user-channel')
// 主进程返回
ipcRenderer.on('provide-user-channel', (event) => {
userPort = event.ports[0];
userPort.onmessage = (event) => {
nameSpan.innerText = event.data;
}
userPort.postMessage("系统初始化消息")
})
// 向user发送消息
sendMessage.addEventListener('click', () => {
// 发送消息
userPort.postMessage(nameSpan.innerText)
});
渲染器进程(user.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<p>
系统消息:
<span id="sysInfo">
暂无
</span>
</p>
<p>
用户名:
<input id="name" aria-label="name" />
<button id="btn-save" type="button">设置</button>
</p>
<script src="./user.js"></script>
</body>
</html>
渲染器进程(user.js)
const { ipcRenderer } = require('electron')
const sysInfo = document.getElementById('sysInfo')
const nameInput = document.getElementById('name')
const saveButton = document.getElementById('btn-save')
// app窗口通信管道
var appPort = undefined;
// 监听主进程发送的通信管道
ipcRenderer.on('user-client', (event) => {
appPort = event.ports[0];
// 当接收app窗口的消息
appPort.onmessage = (event) => {
sysInfo.innerText = event.data;
}
})
// 监听按钮点击事件
saveButton.addEventListener('click', () => {
// 向app窗口发送消息
appPort.postMessage(nameInput.value)
});
常用功能
Electron代码调试
为vscode增加项目启动配置
在项目根目录的.vscode
文件夹中,新建launch.json
文件,其内容如下:
{
"version": "0.2.0",
"compounds": [
{
"name": "Main + renderer",
"configurations": ["Main", "Renderer"],
"stopAll": true
}
],
"configurations": [
{
"name": "Renderer",
"port": 9222,
"request": "attach",
"type": "pwa-chrome",
"webRoot": "${workspaceFolder}"
},
{
"name": "Main",
"type": "pwa-node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
"args": [".", "--remote-debugging-port=9222"],
"outputCapture": "std",
"console": "integratedTerminal"
}
]
}
创建完成后,即可直接通过快捷键F5
或运行面板
直接运行/调试
为指定窗口打开开发者工具
// win为渲染器实例对象
win.webContents.openDevTools()
Electron原生菜单
main.js
const { BrowserWindow, app, Menu, MessageChannelMain } = require('electron')
app.whenReady().then(async () => {
// 主窗口
const mainWindow = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
})
// 菜单模板
const menuTemplate = [
{
// 菜单项文本
label: '系统',
// 子菜单
submenu: [
{
label: '关于',
accelerator: 'CmdOrCtrl+Shift+A'
},
// 分割栏
{
type: 'separator'
},
{
label: '退出',
// 快捷键
accelerator: 'CmdOrCtrl+Q',
click: () => {
mainWindow.close();
}
}
]
}
];
// 通过模板创建菜单
const menu = Menu.buildFromTemplate(menuTemplate);
// 设置菜单
Menu.setApplicationMenu(menu);
})
Electron托盘图标
main.js
const { BrowserWindow, app, Tray,Menu, MessageChannelMain } = require('electron')
app.whenReady().then(async () => {
// 主窗口
const mainWindow = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
})
const menuTemplate = [
{
// 设置菜单项文本
label: '系统',
// 设置子菜单
submenu: [
{
label: '关于',
// 设置菜单角色
role: 'about'
},
// 分割栏
{
type: 'separator'
},
{
label: '退出',
// 设置菜单的热键
accelerator: 'Command+Q',
click: () => {
mainWindow.close();
}
}
]
}
];
// 通过模板创建菜单
const menu = Menu.buildFromTemplate(menuTemplate);
// 设置托盘图标
// 请预先在此目录下准备图标文件
tray = new Tray('images/icon.png');
// 设置托盘图标描述
tray.setToolTip('Electron Demo Beta 2.0')
// 设置托盘菜单
tray.setContextMenu(menu)
// 在窗口关闭时销毁tray
mainWindow.on('closed', () => {
tray.destroy();
});
})
Electron快捷键
在Electron中,快捷键分为三种,即菜单快捷键
/全局快捷键
/窗体快捷键
菜单快捷键
详见 Electron原生菜单
部分,此处不再赘述
全局快捷键
全局快捷键可以在任意区域触发,与窗口是否显示无关
全局快捷键需在主进程中注册
const { BrowserWindow, app, globalShortcut } = require('electron')
app.whenReady().then(async () => {
// 主窗口
const mainWindow = new BrowserWindow({
// 禁止调整大小
resizable: false,
// 窗口框架(边框)
frame: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
})
// 加载页面(界面过于简单且无特殊操作,因此不在代码)
mainWindow.loadFile('html/app.html')
globalShortcut.register('CmdOrCtrl+Shift+M', function(){
mainWindow.close()
})
})
窗体快捷键
窗体快捷键,实际上可以看作渲染器快捷键,仅在某一个渲染器窗口中生效,这种情况下既可以通过浏览器监听事件的方式实现,也可以通过菜单的方式模拟配置,因为使用较少,后续可能再补充此部分内容
Electron通知消息
const { BrowserWindow, Notification, app, globalShortcut } = require('electron')
app.whenReady().then(async () => {
// 主窗口
const mainWindow = new BrowserWindow({
// 禁止调整大小
resizable: false,
// 窗口框架(边框)
frame: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
})
// 加载页面
mainWindow.loadFile('html/app.html')
// 注册一个全局快捷键 (Ctrl + Shift + M)
globalShortcut.register('CmdOrCtrl+Shift+M', function () {
// 显示通知框
notification.show()
})
// 新建一个通知
var notification = new Notification({
// 主标题
title: '晚上好',
// 副标题,Windows 11 不显示
subtitle: '温馨提示',
// 主要内容
body: 'Electron 应用程序向您问好,点击此通知将关闭窗口',
// 是否静音
silent:false,
// 通知超时,可为: default|never
// 如果为never且在windows中,会出现关闭按钮
// timeoutType:'never',
// 通知中显示的图标
icon:"images/icon.png"
})
// 当用户点击通知时
notification.once('click', () => {
// 关闭通知
notification.close();
// 关闭窗口
mainWindow.close();
})
// 当通知显示时
notification.once('show', () => {
// 打开开发者工具
mainWindow.webContents.openDevTools()
})
// 当通知被关闭时
notification.once('close', () => {
console.log('close notification');
})
})
Electron网络调用
Electron Vue
项目创建
准备Vue项目
注意,此处项目中选用的是 Vue 3.x
+ Typescript
,当然也支持Javascript
或Vue 2.x
,一切由你决定
安装Vue-cli
需要通过vue-cli创建项目,如果你习惯vue-ui或已有项目也可以跳过
npm i @vue/cli -g
创建项目
vue create vte-demo
安装Electron依赖
和原生安装一样,也有几率出现无法安装Electron的情况,参照(#N001
)
# 进入创建的目录
cd vte-demo
# 添加Electron依赖
vue add electron-builder
修改依赖版本(可选)
存在一定可能,vue和ts版本冲突,参考(#P001
)
npm i -D ts-loader@~8.2.0
测试运行
npm run electron:serve
Electron React
Electron进阶
界面效果
const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')
app.whenReady().then(async () => {
// 主窗口
const mainWindow = new BrowserWindow({
// 全屏显示
fullscreen:true,
// 窗口是否透明,在Windows中必须禁用窗口框架(frame: false)
transparent: true,
// 是否允许调整窗口大小
resizable: false,
// 窗口框架(边框)
frame: false,
webPreferences: {
// 开发工具,开启可能影响透明效果
devTools: false,
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
})
// 加载页面
mainWindow.loadFile('html/app.html')
// 忽略用户鼠标操作(相当于鼠标穿透)
mainWindow.setIgnoreMouseEvents(true)
// 窗口始终在最前面显示
mainWindow.setAlwaysOnTop(true)
})
案例:ServeRoot
案例描述
允许监听或发送Http/Https/TCP的测试工具,支持自动化测试脚本,不引入vue或React
功能点
可以建立Http/Https服务器
可以建立TCP服务器
可以发送TCP请求
可以发送Http请求
可以执行简单的js脚本
页面
Http服务页面
Tcp服务页面
Http请求页面
案例:VisualStyle
案例描述
图表样式可视化工具,允许通过图形化的形式对Echart/F2的样式进行配置,通过vue3/ts为基础
其他
文档
快捷模板
为了实现快速开发,因此针对不同情况制作了多个Electron模板,链接均为Github,列表如下:
Electron 原生
Electron API示例
Electron + Bootstrap
Electron + Vue2.x + ElementUI
Electron + Vue3.x + Typescript + ElementUI Plus
Electron + React
注释
文档版本
2022-07-14 07:20 初始版本
2022-07-14 09:55 补全
Electron基础
中部分内容2022-07-15 12:00 继续补全
基础概念
中的内容,新增案例2022-07-18 12:23 继续补全
扩展部分
中的内容
勘误
安装包失败,项目无法启动(#N001)
描述
执行npm run start
(即electron .
)时报错,大致是Electron安装错误,需要重新安装
Electron failed to install correctly, please delete node_modules/electron and try installing again
原因
一般是网络原因造成的包下载不全
解决
修改npm配置
`npm config edit
在打开的编辑器中找到以`registry=`开头的一行,在下方新行中增加:
electron_mirror=https://cdn.npm.taobao.org/dist/electron/
删除之前下载的node_modules包,重新执行 npm i electron
即可
窗口启动前短暂白屏(#S001)
描述
在启动项目时,打开窗口不会立刻显示内容,会短暂展示白屏然后再显示页面
原因
系统启动即立刻显示窗口容器,可以先暂时隐藏窗口,等待页面加载完成后再重新显示
解决
创建时
const mainWindow = new BrowserWindow({
// 启动时显示,此处为false是为了跳过加载完成前的白屏
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
页面加载完成
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
Electron + Vue3 + Typescript报错:TS2571(#P002)
描述
TS2571: Object is of type 'unknown'.
原因
新项目中使用了Typescript,因此对数据类型有要求,将报错的代码改为符合Ts要求即可
解决
// 如下修改
console.error('Vue Devtools failed to install')
Electron + Vue3 + Typescript 执行npm run electron:serve 报错(#P001)
描述
ERROR Failed to compile with 1 errors 13:54:57
error in ./src/background.ts
Module build failed (from ./node_modules/ts-loader/index.js):
TypeError: loaderContext.getOptions is not a function
at getLoaderOptions (E:\Source\VisualStyle\node_modules\ts-loader\dist\index.js:91:41)
at Object.loader (E:\Source\VisualStyle\node_modules\ts-loader\dist\index.js:14:21)
@ multi ./src/background.ts
原因
ts-loader和vue-cli版本不兼容,将任意一个修改即可
解决
// 修改ts-loader的版本
npm i -D ts-loader@~8.2.0
参与讨论
(Participate in the discussion)
参与讨论