发布于 

electron

Electron介绍

electron核心技术

Electron的三个核心组成:

  • Chromium:浏览器渲染
  • Node.js:文件读取
  • Native apis:提供统一原生界面能力(操作系统交互)

用Electron做的比较出名的软件大概就是VSCode了,

electron主要的特点就是跨平台兼容性好,
但是在内存占用方面也经常被人诟病,
因为electron应用就算什么内容都没有,
还有整整200MB的Chromium引擎包在里面

electron工作流程

桌面应用和web应用不同的是涉及到对操作系统的操作,
但这一部分都由Native apis实现,
前端工程师只需要关心渲染层面的东西就可以。

electron中的两种进程:

  • 主进程 Main Process
    • 一个应用主进程唯一
    • 启动入口一般在main.js中(package.json中main配置入口)
    • 首先启动,启动后调用Native UI创建一个或多个BrowsersWindow界面
  • 渲染进程 Renderer Process
    • 一个应用可以有多个渲染进程
    • 在BrowersWindow上运行的进程
    • 各个渲染进程互不干扰,在自己的沙箱环境中运行

各个进程之间使用IPC进行通信

step1

启动APP

step2

主进程创建window

step3

window加载界面

step4

界面交互涉及到操作系统,
渲染进程通过IPC和主进程通信,
主进程再调用native apis

Electron开发

框架结构

代码结构

官方提供的框架案例:

入口文件main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const {app, BrowserWindow} = require("electron")

app.whenReady().then(()=>{
let mainWin = new BrowserWindow({
width:800,
height:600
})

mainWin.loadFile('index.html')

mainWin.on("close",()=>{
console.log("close")
mainWin = null
})
})

app.on("window-all-closed",()=>{
console.log("window-all-closed")
app.quit()
})

使用nodemon辅助开发

nodemon

使用node命令启动server时,
每当代码修改,都需要重启server:

1
node server.js

electron开发也是这样,每当主进程代码(main.js)发生变化时,
都需要重启项目:

1
electron . 

使用nodemon,可以监听指定源码的变化,自动执行命令:

1
2
3
4
5
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon --watch main.js --exec npm run dev",
"dev": "electron ."
}

生命周期

生命周期

ready

app初始化完成

dom-ready

webContents监听,窗口文本加载完成

did-finish-load

webContents监听,导航完成时触发

window-all-closed

所有窗口都被关闭时触发

before quit

关闭窗口前触发

will-quit

窗口关闭应用程序退出时触发

quit

所有窗口被关闭时触发

closed

窗口被关闭时触发,此时应删除窗口引用
可以在closed中将窗口对象置null进行内存回收

窗体

窗体显示

由于窗体先创建后显示,因此在dom渲染之前会出现白屏的情况,
可以先将窗口show设置为false,
loadFile后监听ready-to-show事件,
再用win.show将窗口调出:

1
2
3
4
5
6
7
8
9
10
let mainWin = new BrowserWindow({
width:800,
height:600,
show:false
})

mainWin.loadFile('index.html')
mainWin.on('ready-to-show',()=>{
mainWin.show()
})

调出控制台快捷键:Ctrl+Shift+i

多个窗体

创建多个窗体

如果MainWindow里渲染的界面有一个按钮,
点击按钮,创建一个新界面,
这意味着需要给按钮绑定一个事件,并在对应事件中进行窗口调用操作。

在窗口对应的html文件中,引入js脚本,
脚本内引入electron提供的用于创建窗口的对象:
(这部分参考Electron最新版remote问题

注意:需要打开MainWindow中的WebPreferences.nodeIntegration后才能在渲染进程中使用node。
调用Electron的API还需要打开WebPreferences中的两个属性:

1
2
contextIsolation:false; // 上下文隔离
enableRemoteModule:true; // 远程调用

新版本的electron远程调用需要安装@electron/remote库,

注意,安装@electron/remote库时报错可以用cnpm安装:

1
cnpm install @electron/remote

在渲染进程中,通过引入@electron/remote远程调用api

在主进程中需要对remote进行初始化:

1
2
3
4
5
app.whenReady().then(()=>{
let mainWin = new BrowserWindow({...})
require('@electron/remote/main').initialize()
require('@electron/remote/main').enable(mainWin.webContents)
})

index.html引入的index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const {BrowserWindow} = require('@electron/remote')

window.addEventListener('DOMContentLoaded',()=>{
const oBtn = document.getElementById('open-btn')
oBtn.addEventListener('click',()=>{
let win = new BrowserWindow({
width:200,
height:200
})
win.loadFile('remoteIndex.html')
win.on('close',()=>{
win = null
})
})
})

自定义窗体

自定义窗口

和在渲染进程中创建一个新窗口一样,
需要使用从remote中引用api进行窗口操作:

getCurrentWindow 获取当前窗口

  • win.close() 关闭当前窗口,会触发window.onbeforeunload事件
  • win.isMaximized() 查询当前窗口是否已经最大化
  • win.maximize() 窗口最大化显示
  • win.restore() 窗口回归原始状态
  • win.isMinimized() 窗口是否已经最小化显示
  • win.minimize() 窗口最小化显示
  • win.destroy() 摧毁当前窗口,不会触发onbeforeunload事件
代码实现
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
const {getCurrentWindow} = require("@electron/remote")

window.addEventListener('DOMContentLoaded',()=>{

let mainWindow = getCurrentWindow() // 获取当前Window

const closeBtn = document.getElementById('close-btn')
const maxBtn = document.getElementById('maximize-btn')
const miniBtn = document.getElementById('minimize-btn')

closeBtn.addEventListener('click',()=>{
mainWindow.close()
})

maxBtn.addEventListener('click',()=>{
if(!mainWindow.isMaximized()){
mainWindow.maximize() // 最大化
}else{
mainWindow.restore() // 回到原始状态
}
})

miniBtn.addEventListener('click',()=>{
if(!mainWindow.isMinimized()){
mainWindow.minimize() // 最小化
}
})

window.onbeforeunload = ()=>{
let mainWindow = getCurrentWindow()
const dialogDom = document.getElementsByClassName('close-dialog')[0]
dialogDom.style.display = 'flex'
const yesBtn = document.getElementsByClassName('close-yes')[0]
const noBtn = document.getElementsByClassName('close-no')[0]
yesBtn.addEventListener('click',()=>{
mainWindow.destroy() // 需要销毁,使用close还会触发unbeforeLoad事件
})
noBtn.addEventListener('click',()=>{
dialogDom.style.display = 'none'
})
return false
}
})

父子和模态窗体

父子和模态窗口

有些应用程序点击按钮会出现一个弹窗,
并且不能跨越弹窗点击到后面的窗口,
这就是父子和模态窗口,

需要在子窗口上配置parent和modal属性:

1
2
3
4
5
6
7
let win = new BrowserWindow({
parent:getCurrentWindow(),
width:300,
height:300,
show:false,
modal:true
})

渲染进程中创建子窗体,需要通过getCurrentWindow()设置parent父窗体,
但是在主进程中创建子窗体时,需要给主进程提供一个父窗体标识,
然后由BrowserWindow.fromId获取到目标窗体:

保存父窗体的id:

1
2
3
4
5
let mainWindowId = null //主窗口id
app.whenReady().then(()=>{
let mainWindow = new BrowserWindow({...})
mainWindowId = mainWindow.id // 记录id
})

子窗口创建时,parent指向id代表的窗体:

1
2
3
4
5
6
7
8
9
let subWin1 = new BrowserWindow({
width:400,
height:300,
parent:BrowserWindow.fromId(mainWindowId),
webPreferences:{
nodeIntegration:true,
contextIsolation:false,
}
})

父子窗体还有一个特点:父窗体在关闭后,子窗体同时也会被关闭

菜单

自定义菜单

Menu介绍

自定义菜单需要一个数组变量作为菜单配置:

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
50
51
52
53
54
55
56
let menuTemp = [
{
label:"文件",
submenu:[ // 子菜单
{
label:"打开",
click(){ // 菜单点击事件
console.log('你按下打开按钮')
}
},
{type:"separator"}, // 分隔符
{
label:"关于",
role:'about' // electron内置的默认行为
},
]
},
{
label:"角色",
submenu:[
{label:"复制",role:"copy"},
{label:"剪切",role:"cut"},
{label:"黏贴",role:"paste"},
{label:"最小化",role:"minimize"},
]
},
{
label:'类型',
submenu:[
// 多选
{label:"选项1", type:"checkbox"},
{label:"选项2", type:"checkbox"},
{label:"选项3", type:"checkbox"},
{type:"separator"},
// 单选
{label:"item1", type:"radio"},
{label:"item2", type:"radio"},
{label:"item3", type:"radio"},
{type:"separator"},
{label:"windows", type:"submenu", role:"windowMenu"}
]
},
{
label:"其他",
submenu: [
{
label:"crazy",
icon:"./assets/crazy.png", // 自定义图标
accelerator: 'ctrl + p', // 自定义快捷键
click(){
console.log('going crazy')
}
}
]
}
]

用配置生成菜单项,然后将生成的菜单项设置到应用的菜单中:

1
2
let menu = Menu.buildFromTemplate(menuTemp)
Menu.setApplicationMenu(menu)
动态创建菜单

动态创建菜单需要使用到MenuItem方法,
现有一个菜单项的子菜单指向一个Menu类型的数据

1
2
3
4
5
let menuItem = new Menu()
let customMenu = new MenuItem({
label:'自定义',
submenu:menuItem // 子菜单为一个Menu
})

要为子菜单动态添加菜单项,需要用到Menu.append方法为菜单添加子项:

1
2
3
4
5
6
menuItem.append(
new MenuItem({ // 新菜单项
label:"new menu item",
type:"normal"
})
)
右键菜单

右键菜单的创建和导航栏菜单创建一样,都是由Menu.buildFromTemplate完成,
不同在于不会使用Menu.setApplicationMenu,
而是Menu.popup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const {Menu,getCurrentWindow} = require("@electron/remote")

let contextTemp = [
{label:"测试"},
{type:"separator"},
{label:"其他",click:()=>console.log('click click')},
]
let menu = Menu.buildFromTemplate(contextTemp)

window.addEventListener('DOMContentLoaded',()=>{
window.addEventListener('contextmenu',ev=>{
ev.preventDefault()
menu.popup({
window:getCurrentWindow()
})
},false)
})

弹窗

dialog

Dialog Doc

弹窗方法api:electron.dialog

异步获取文件弹窗配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
btn.addEventListener('click',()=>{
// 返回Promise
dialog.showOpenDialog({
defaultPath: __dirname, // 默认路径:当前目录
buttonLabel: '选好了',
title:"自定义标题",
// openFile 打开文件
// openDirectory 打开目录
// multiSelections 多选
properties:['openFile', 'multiSelections'],
filters:[ // 配置获取文件的类型
{name:"code", extensions:["js",'json','html']},
{name:"img", extensions:["ico",'jpeg','png']},
{name:"media", extensions:["avi",'mp4','mp3']},
]
}).then((ret)=>{
console.log(ret.canceled)
console.log(ret.filePaths)
})
})

错误弹窗:

1
2
3
4
5
6
errBtn.addEventListener('click',()=>{
dialog.showErrorBox(
'Errorrrrrrrr',
"错误内容"
)
})

shell

shell

Shell可以用来:

  • 打开文件管理器
  • 将通过浏览器打开url

shell doc

使用shell在资源管理器中打开文件目录:

1
2
3
const {shell} = require('electron')
const path = require('path')
shell.showItemInFolder(path.resolve(__filename))

shell在浏览器中打开链接:

1
shell.openExternal(urlPath) // 外部浏览器打开链接

消息提示

Notification

Notifications Doc

在electron应用中触发window.Notification,
能够触发操作系统的消息提示,
同时,还能监听到消息提示的点击事件

1
2
3
4
5
6
7
8
9
10
11
let option = {
title:'title123',
body:"something",
icon:'./app.ico'
}
// 创建消息
let my_noti = new window.Notification(option.title, option)
// 监听消息的点击
my_noti.onclick = ()=>{
console.log('消息被点击')
}

进程通信

主进程与渲染进程通信

主进程与渲染进程

主进程与渲染进程之间的通信有几类:

  • 渲染进程发起 - ipcRenderer
    • 异步
    • 同步
  • 主进程发起的通信 - ipcMain
    • 异步
    • 同步(不支持)
异步通信 send

异步通信是没有返回值的通信

渲染进程 → 主进程

渲染进程发送:

1
2
3
4
asyncBtn.addEventListener('click',()=>{
ipcRenderer.send('msg1','Render 发送异步请求到 Main')
})

主进程回复:

1
2
3
4
ipcMain.on('msg1', (ev, data)=>{
// main 向 render 发送异步消息
ev.sender.send('msg1Re', 'Main 异步回复到 Render')
})
渲染进程消息的发送&&主进程消息的回复
渲染进程消息的发送&&主进程消息的回复
控制台打印中文乱码bug

使用chcp 65001命令改变当前活动代码页格式为utf-8

主进程 → 渲染进程

主进程需要通过根据当前focus的窗口的WebContent,来实现向指定渲染进程的通信:

1
2
3
4
5
6
7
8
9
let menuTemp = [
{
label:"send",
click(){
// 实现一个向渲染进程通信的菜单按钮
BrowserWindow.getFocusedWindow().webContents.send('mtp','来自于自进程的消息')
}
}
]

子进程开启事件的on监听:

1
2
3
4
    // 自进程发送的消息
ipcRenderer.on('mtp',(ev,data)=>{
console.log(data)
})
点击send菜单按钮,主进程发送消息,子进程打印消息
点击send菜单按钮,主进程发送消息,子进程打印消息
同步通信 sendSync

同步通信只支持渲染进程向主进程发送,

渲染进程可以用一个值接收sendSync的返回值:

1
2
3
4
5
    // 发送同步请求
syncBtn.addEventListener('click',()=>{
let res = ipcRenderer.sendSync('msg2','Render 发送同步请求到 Main')
console.log(res)
})

主进程通过设置returnValue作为返回值:

1
2
3
4
5
ipcMain.on("msg2",(ev,data)=>{
console.log(data)
// 同步回复
ev.returnValue = 'Main 同步回复到 Render'
})
渲染进程请求,主进程返回值
渲染进程请求,主进程返回值

渲染进程之间传值

方法1:localStorage传值

使用localStorage能够跨窗口传值,
之前在一个vue项目中就遇到了跨窗口传值的需求,
结果发现vuex无法跨窗口传值,所以用的localStorage

localStorage的跨页面同步
localStorage的跨页面同步

localStorage适合具有父子关系的窗口之间传值

方法2:通过主进程通信

A进程和Main建立通道
B进程和Main建立通道
AB进程就能通过Main进行通信:

A进程和Main建立的通道:

1
ipcRenderer.send('stm', text) // A → Main

Main将A进程数据转发到B进程

1
2
3
4
ipcMain.on('stm',(ev,data)=>{ // Main ← A
let targetWindow = BrowserWindow.fromId(id) // 获取到B窗口
targetWindow.webContents.send('msgAtoB',data) // Main → B
})

B进程接收数据

1
2
3
ipcRenderer.on('msgAtoB',(ev,data)=>{ // B ← Main
// do something
})
通过主进程实现子窗口创建时传值

通过主进程通信方式创建窗口的同时传值,
父窗口渲染需要在告知主进程开启新窗口的同时传递值,
然后在主窗口将子窗口进程创建好后,将值通过自通信的方式传递过去:

父窗口通知主进程创建子窗口进程(监听did-finish-load事件):

1
ipcRenderer.send('openWin',123)

主进程创建新窗口并传值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ipcMain.on('openWin',(ev,data)=>{
let subWin1 = new BrowserWindow({
width:400,
height:300,
parent:BrowserWindow.fromId(mainWindowId),
webPreferences:{
nodeIntegration:true,
contextIsolation:false,
}
})
require('@electron/remote/main').enable(subWin1.webContents)
subWin1.loadFile('subIndex.html')
subWin1.on('close',()=>{
subWin1 = null
})
// 在创建窗口时传值
subWin1.webContents.on('did-finish-load',()=>{
subWin1.webContents.send('its',data)
})

})

子窗口监听:

1
2
3
ipcRenderer.on('its',(ev,data)=>{
alert(`父窗口传来的值:${data}`)
})

快捷键

globalShortcut

globalShortcut doc

globalShortcut可以在操作系统中注册/销毁全局快捷键。
这种绑定是全局,即便是应用失去焦点,也能持续监听。

在ready中注册快捷键

1
2
3
4
5
6
7
8
9
10
app.on('ready',()=>{
let res = globalShortcut.register('ctrl + q',()=>{
console.log('something be active')
})
if(!res){console.log('register fail')}
// 判断快捷键是否已经被注册
console.log(`[ctrl + q] 被注册情况:${globalShortcut.isRegistered('ctrl + q')}`)
console.log(`[tab] 被注册情况:${globalShortcut.isRegistered('tab')}`)
})

快捷键的销毁:

1
2
3
4
5
6
app.on('will-quit',()=>{
console.log('快捷键解绑')
globalShortcut.unregister('ctrl + q')
globalShortcut.unregisterAll()
})

剪切板

clipboard

clipboard doc
nativeImage doc

剪切板文本的读写:

1
2
3
4
5
6
7
let res = null // 复制内容
copyBtn.onclick = ()=>{
res = clipboard.writeText(copyInput.value) // 写入
}
pasteBtn.onclick = ()=>{
pasteInput.value = clipboard.readText(res) // 读取
}

剪切板图片的复制:

1
2
3
4
5
6
7
8
const {clipboard, nativeImage} = require('electron')
// 读入图片
let imgData = nativeImage.createFromPath('assets/app.ico')
clipboard.writeImage(imgData) // 剪切板写入图片
let imgFromCb = clipboard.readImage() // 剪切板读出图片
let imgDom = new Image()
imgDom.src = imgFromCb.toDataURL() // nativeImage进行图片格式转换
document.body.appendChild(imgDom)
note

nativeImage是electron为多种缩放大小准备的图标操作类,
读取的图标具有一定格式要求(png、jpg、ico),
还有一定尺寸要求(见官网介绍)
如果尺寸和格式不满足要求,读入进来的图片就默认为空