前言
有句话叫做“有需求就有市场”,技术领域也同样是如此。在过往的前端领域之上,当面临需要涉及操作系统的时候,前端coder往往显得力不从心。这便是桌面应用的需求造就了 Electron 的到来。
什么是Electron?
简介
打开官网,我们便可以看到其介绍,使用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。顾名思义,我们可以完全自主控制地去构建跨平台桌面应用了,无需强依赖于桌面应用原生开发人员,有效降低沟通成本,再也不用求爷爷告奶奶去协调资源,完全可以自主访问以往受限的操作系统相关底层API。
当然,这也并不意味着百利而无一害,毕竟获得更多 power 的同时,也会承担更多 Risk。
优秀应用
- Visual Studio Code
- Atom
- Postman
- 社交通讯 WhatsApp
- MongoDB 桌面管理工具 Compass
- 接口管理工具 Apifox
- ... ...
技术选型
Electron核心组成
Electron 是基于 Chromium 和 Node 实现的,才使得我们可以无缝轻松使用其开发跨平台桌面应用,降低了学习门槛,更加轻松上手开发。
为了弥补前端访问系统API方面的不足,Electron 内部对系统API进行了封装,相关譬如系统对话框、系统托盘、系统菜单、剪切板等。而其他诸如网络访问控制、本地文件系统等则由 Node 提供底层支持。
Electron 通过各操作系统之间的消息循环打通 Node 和 Chromium 的事件循环,保证了其两者的松耦合。进而推出了主进程、渲染进程的概念。
Electron 起了一个新到安全线程去轮询, 当 Nodejs 有新的事件之后,通过 PostTask 转发到 Chromiums 的事件循环当中,完成 Electron 的事件融合
具体相关源码:https://github.com/electron/electron/blob/main/shell/common/node_bindings.cc
Electron 工作机制
啥也不说,先上个图
左侧是我们传统开发中前端人员所能控制展示的区域,而当基于 Electron 去开发桌面应用时,我们可控区域如右侧所示,全部交由前端自主开发。
而 Electron 开发中,页面不再是用户手动输入打开,而是开发着自主硬编码好的。
Electron应用程序主要分为主进程、渲染进程两个部分,即对应着右侧图中上下两个部分。
进程
一个 Electron 应用程序由一个主进程(有且只有一个) 多个渲染进程组成。
主进程
功能:桥梁作用,连接操作系统和渲染进程,负责管理所有窗口及其对应的渲染进程。
- 有且只有一个,整个应用入口
- 创建、管理渲染进程
- 控制应用生命周期
- 使用 NodeJS 特性
- 调用操作系统 API
- ...
渲染进程
功能:负责完成渲染页面、接收用户输入、相应用户交互等工作。
- 渲染页面
- 使用部分 Electron 模块 API
- 使用 NodeJS 特性
- 一个应用可存在多个渲染进程
- 控制用户交互逻辑
- 访问 Dom API
核心模块归属情况
上图为笔者整理的常用模块归属情况,详细主进程、渲染进程会在后续的实战部分进行部分讲解。
IPC 通信
大概了解完两个进程的功能之后,我们接下去该考虑一下这两者之间,是如何进行协调通信的。
Electron 中通过提供ipcMain、ipcRenderer来作为主进程、渲染进程之间的通信桥梁。
从接口定义中不难推断出其管道IPC是通过继承 EventEmitter 来实现IpcMain、IpcRenderer,并拓展了其他工具类方法。纵使翻阅 electron 源码也是如此,感兴趣的同学可以自己去研究研究,这里只做简单了解。
讲到这里,对于主进程和渲染进程的通信就变得十分容易理解了,通过管道IPC,采用熟知的发布订阅模式进行两者之间的通信。
窗口获取
- BrowserWindow.getFocusedWindow(): 获取当前激活状态窗口
- remote.getCurrentWindow(): 获取当前渲染进程关联窗口
- BrowserWindow.fromI(id): 根据id获取窗口实例
- BrowserWindow.getAllWindow(): 获取所有窗口
remote
在讲实际项目基本操作之前,先介绍一下一个比较特殊的 remote 模块
remote:这是一个 Electron 内部的模块,渲染进程可以通过此模块访问到主进程的模块、对象和方法。包括在渲染进程创建窗口、创建菜单等类似本应该由主进程完成的操作通过 remote 依然可以在渲染进程进行完成。前提是创建窗口的时候,开启了 nodeIntegration 配置,让渲染进程有能力去访问 Node.js 相关API。但是其背后的机制是一样的,通过通知主进程,主进程接收消息后再进行相关操作,然后把相关的实例以远程对象形式返回到渲染进程。
局限性
当然,remote虽然极大便利了开发者,但是也带来了一些局限性
- 性能损耗大:跨进程操作
- 制造混乱:异步导致执行顺序错乱
- 制造假象:代理对象导致数据混乱
- 安全问题:恶意代码攻击
在不久的将来,remote 模块将从 electron 内部移除,但是还很漫长,保持关注即可。
实战
从这里开始,我们将从实际的项目基本功能演练进行相关核心模块的使用演示。
进程互访
渲染进程TO主进程
其核心原理是因为暴露了 remote 模块,让开发者可以相对随心所欲的进行访问。
比如我们在主进程里想要获取应用程序的程序路径,我们可以在主进程这么获取:
代码语言:javascript复制import { app } from 'electron'
// 获取应用程序路径
const ROOT_PATH = app.getAppPath()
而在渲染进程中,有了 remote 模块,此类简单属性获取也变得更加方便:
代码语言:javascript复制const { app } = require('electron').remote
// 获取应用程序路径
const ROOT_PATH = app.getAppPath()
然鹅,其不仅可以访问主进程的属性,还可以调用相关方法,再举个栗子:
代码语言:javascript复制const { remote } = require('electron')
// 渲染进程打开开发者工具
remote.getCurrentWindow().webContents.openDevTools()
结论:通过 remote 模块,我们可以方便的访问主进程的模块、对象和方法。
主进程TO渲染进程
渲染进程是由主进程控制的,通过创建的渲染进程的窗口win.webContents对象,可以轻易地访问渲染进程相关内容。
这里官网的相关事例说明相对完善,可以自行查看。
代码语言:javascript复制const {BrowserWindow} = require('electron')
let win = new BrowserWindow({width: 800, height: 600})
win.loadURL('http://github.com')
// 获取当前网页窗口的网址
let currentURL = win.webContents.getURL()
进程通信
其核心即为管道IPC通信,上文有所说明,不再赘述。
主进程TO渲染进程
主要有两种方式进行通信:
- ipcMain 接收渲染进程消息
- webContents 发送给渲染进程
比方说呢,项目里我有一个地方需要监听用户通过 a 标签打开外链,但是我又不想它重新创建一个窗口,所以需要系统干预进行处理。
我的解决方案就是通过 进程通信 shell 模块来通过系统默认浏览器来打开目标链接。
代码语言:javascript复制<a href="www.baidu.com" target="_blank">百度</a>
代码语言:javascript复制const { ipcMain, shell } = require('electron');
ipcMain.on('open-url', (event, url) => {
// 'open-url' 为管道消息名称
// event 为消息发送相关信息
// event.sender 为渲染进程的webContents对象事例
// url 为传递参数
// 通过系统默认浏览器打开目标外链
shell.openExternal(url)
})
如果此时到这里之后,我们想告诉渲染进程我们已经成功接收并执行了,也就是回调,那么我们就可以通过渲染进程事例进行对渲染进程消息通知:
方法1: webContents 直接回传
代码语言:javascript复制const { ipcMain, shell } = require('electron');
const win = new BrowserWindow({
//... ...
})
ipcMain.on('open-url', (event, url) => {
// ... ...
// 通过系统默认浏览器打开目标外链
shell.openExternal(url)
// 向渲染进程进行消息通知
win.webContents.send('ready-open-url')
})
方法2: ipcMain.on 接收消息通知时,event.sender 为渲染进程的webContents 对象事例,我们也可以直接进行消息通知:
代码语言:javascript复制const { ipcMain, shell } = require('electron');
const win = new BrowserWindow({
//... ...
})
ipcMain.on('open-url', (event, url) => {
// ... ...
// 通过系统默认浏览器打开目标外链
shell.openExternal(url)
// 向渲染进程进行消息通知
event.sender.send('ready-open-url')
})
方法3: ipcMain.on 接收消息通知时,event 提供reply方法,相应消息给来源渲染进程,本质上与方法2逻辑一致。
代码语言:javascript复制// ... ...
ipcMain.on('open-url', (event, url) => {
// ... ...
// 通过系统默认浏览器打开目标外链
shell.openExternal(url)
// 向渲染进程进行消息通知
event.replay('ready-open-url')
})
渲染进程TO主进程
主要是通过 ipcRenderer 模块进行向主进程进行消息通知。
还是拿上面的例子来说,打开外链,那么我们就需要在渲染进程中进行向主进程通知,我需要打开某个外链。具体如下:
本事例为在 Vue 中的实践
代码语言:javascript复制const { ipcRenderer } = require('electron')
const links = document.querySelectorAll('a[href]')
links.forEach(link => {
link.addEventListener('click', e => {
const url = link.getAttribute('href')
e.preventDefault()
ipcRenderer.send('open-url', url)
})
})
当然这是一个异步的消息队列~ 可能在某些需求场景下,我们需要传递的是同步消息, 那么我们只要在主进程里直接设置 returnValue 的值即可,而渲染进程不需要再重复监听。
还是拿上面的打开外链做个演示说明:
代码语言:javascript复制// 主进程
ipcMain.on('open-url', (event, url) => {
// 通过系统默认浏览器打开目标外链
shell.openExternal(url);
// 设置返回值
event.returnValue = 'success';
})
// 渲染进程
const returnVal = ipcRenderer.sendSync('open-url', url);
console.log(returnVal) // success
当然,同步通信会阻塞渲染进程,孰轻孰重需要谨慎选择~
渲染进程TO渲染进程
当我们程序相对复杂,创建了多个渲染进程的时候,就容易出现多个渲染进程之间相互通信的场景。
解决方案其实也是显而易见的,既然是一个爹(主进程)生的,那么直接通过主进程进行一个过渡中转,就可以实现双方的一个通信了。毕竟窗口的创建往往就是在主进程里完成的,其持有所有窗口的实例,只要拿到目标窗口的id即可进行通信。
每个窗口 webContents.getProcessId() 或者 webContents.id 即可获得对应窗口的id。
伪代码如下:
代码语言:javascript复制// win1窗口发送消息
ipcRenderer.sendTo(win2.webContents.id, 'send-msg', params1, params2)
// win2窗口接收消息
ipcRenderer.on('send-msg', (event, params1, params2) => {
// ... ...
})
其中 ipcRenderer.sendTo 中,第一个参数为目标窗口id,第二个参数为管道消息名称,其余为传递参数。
当然,需要发送消息给到的目标窗口是打开的状态,否则可就接受不到了。
到此,三种场景的进程通信介绍完毕了。
有个小注意事项⚠️需要关注一下:
进程之间的通信过程中,发送的json对象都会被序列化和反序列化,所以传递的时候需要注意其方法和原型链上的数据是不会被传递的。
这一点,跟小程序 setData 进行视图层和逻辑层数据传输是十分类似的,evaluteJavascript 所实现的,最终都转化为字符串传递。
搭建开发环境
electron的安装,兴许是一个漫长的过程,这里强烈建议大家有条件的话能够科学上网,可以省掉不少破事。当然没有的话,也没关系(假的),我们也有解决方案。
包管理工具的话,大家就各自选择了,npm/yarn 都可以,这里以 yarn 进行说明。
初始化项目
代码语言:javascript复制yarn init
electron 依赖包有点大,默认从github下载,所以巨艰难。设置镜像
代码语言:javascript复制yarn config set set ELECTRON_MIRROR=http://npm.taobao.org/mirrors/electron/
全局安装 electron
代码语言:javascript复制yarn global add electron
这是正常安装成功 node_modules/electron 里应有的文件结构,如果后续运行报错了,大概率就是安装失败了。
可以选择手工操作处理此类问题,如果你上网不够科学的话~
解决方案:
- /node_modules/electron/ 目录下创建path.txt
- win输入:electron.exe
- mac输入:Electron.app/Contents/MacOS/Electron
- /node_modules/electron/ 目录下创建dist目录
- 版本包地址 下找到对应版本解压到dist目录
至此,electron 安装就算是成功了。
package.json 中配置“main” 入口文件即 electron 的启动文件,即主进程的相关代码。
下面贴一个以 Vue 框架进行开发的项目文件结构图。
引入现代框架
通过引用模板项目即可快速入手开发,一个字-香!
Angular
- 官方维护版本:https://github.com/angular/angular-electron (缺点:停更许久)
- 社区活跃版本:https://github.com/maximegris/angular-electron
React
- electron-react-boilerplate该项目模板汇集了 Electron、React、Redux、React Router、webpack、React Hot Loader等,对入手尝鲜 Electron 来说,简直是不要太香。
Vue
- Vue CLI Plugin Electron Builder:https://github.com/nklayman/vue-cli-plugin-eletron-builder
- electron-vue: https://github.com/SimulatedGREG/electron-vue (也已基本停更)
通过引用前端三剑客框架,我们就可以快速投入到 Electron 的GUI应用开发之中,当然如果你执着于 jQuery,也是可以引用开发的,只是不建议而已,这就涉及到 Electron 性能相关了,这里不再展开。
发布打包
设置图标
- 准备一张1024*1024尺寸的png图 放在public下
- 安装 electron-icon-builder 插件
yarn add electron-icon-builder --dev
容易安装失败 多装几次(科学上网)
- package.json 添加指令配置
"build-icon": "electron-icon-builder --input=./public/logo.png --output=build --flatten"
- 执行
yarn build-icon
生成应用图标到对应的build文件夹
打包安装包
代码语言:javascript复制yarn electron:build
直到 Done 出来之后也就大功告成了~
一个 electron 应用也就生成好了。
核心模块演示
设置全局变量
项目开发中,经常有个需求便是主题换肤,在尝试过程中自然就想到了 mac 下的系统主题切换。由此来演示下如何设置全局变量,并在渲染进行获取。
主进程
代码语言:javascript复制import { nativeTheme } from 'electron'
/** 添加全局属性 * */
global.selfConfigs = {
nativeTheme: () => nativeTheme.shouldUseDarkColors
}
渲染进程
代码语言:javascript复制const nativeTheme = require('electron').remote.getGlobal('selfConfigs').nativeTheme()
当然,直接通过 remote 调用 nativeTheme 也是可以的,just a 栗子。
脚本注入
- 通过 preload 配置项,进行脚本注入
let win = new BrowserWindow({
webPreferences: {
preload: jsFilePath,
nodeIntegration: true
}
})
- 通过 executeJavaScript 注入脚本
比方说,在 window 上添加自定义属性
主进程
代码语言:javascript复制let win = new BrowserWindow({
// ...
})
win.webContents.executeJavaScript(`
window.onlyConfig = {a:1,b:2}
`)
渲染进程
代码语言:javascript复制console.log(window.onlyConfig) // {a:1,b:2}
实现系统消息通知
有两种可实现方式,两种方式的使用方法区别不大。
- HTML API 发送消息通知,缺点就是需要用户授权同意之后
- 主进程直接发送系统消息
const { Notification } = this.$electron.remote
const notification = new Notification({
title: '新建通知', BVVv body: '您新建了一个md文档,请点击查看'
})
notification.show()
notification.on('click', () => {})
实现系统托盘及相关菜单
系统托盘由 Tray 模块提供,用于添加托盘图标和上下文菜单至通知栏。
啥也不说了,先上大头贴
实现原理相对简单,通过定时器刷新托盘图标,并添加相对应的上下文菜单进行逻辑操作即可,更多功能可以自行DIY。
代码语言:javascript复制/** 添加系统托盘 * */
let toggleSwitch = true; let toggleFlag = false; let timer
const icon1 = path.join(__dirname, '../public/icon.png')
const icon2 = path.join(__dirname, '../public/icon2.png')
tray = new Tray(icon1)
tray.setToolTip('Electron 系统托盘')
tray.on('click', () => {
console.log('托盘单击')
win.isVisible() ? win.hide() : win.show()
})
tray.on('right-click', () => {
const menuConfig = Menu.buildFromTemplate([
{
label: toggleSwitch ? '开启闪烁图标' : '关闭闪烁图标',
click: () => {
if (toggleSwitch) {
timer = setInterval(() => {
if (toggleFlag) {
tray.setImage(icon2)
} else {
tray.setImage(icon1)
}
toggleFlag = !toggleFlag
}, 600)
} else {
tray.setImage(icon1)
clearInterval(timer)
}
toggleSwitch = !toggleSwitch
}
},
{
label: '退出',
click: () => app.quit()
}
])
tray.popUpContextMenu(menuConfig)
})
/** 添加系统托盘 * */
实现系统右键菜单
以往,我们处理的思路是根据用户右键所在鼠标坐标生成一个右键菜单,相对麻烦并且还需要考虑边界状态。好比如编写此篇文章所用到的 mdnice ,即是用此方案使用了自定义右键菜单。
通过 electron 暴露的 screen 模块,获取到当前鼠标所在位置
代码语言:javascript复制window.oncontextmenu = () => {
const point = require('electron').screen.getCursorScreenPoint();
}
而在 electron 里,我们可以直接自定义系统右键菜单,兼容性更佳。
代码语言:javascript复制// 监听右键菜单触发
win.webContents.on('context-menu', (event, params) => {
const selectEnabled = !!params.selectionText.trim().length
const template = [
{
label: '为当前页面生成二维码',
click: () => {
console.log(`当前页面地址为:${params.pageURL}`)
}
}
]
if (selectEnabled) {
template.unshift(...[{
label: '复制',
role: 'copy',
visible: () => !selectEnabled
},
{
label: '剪切',
role: 'cut'
}])
}
const RightMenu = Menu.buildFromTemplate(template)
RightMenu.popup()
})
最终实现如下基础效果:
常见问题
npm 安装electron不成功
解决方案:通过cnpm淘宝镜像安装 避免安装失败
报错 require is not defined
原因:electron12以后默认没法在渲染进程中引入Nodejs模块
解决方案:
找到 ./background.js里的 new BrowserWindow 添加配置项 nodeIntegration 设置为 true
导入electron.remote后,提示undefined
原因: 在electron10版本之后,remote默认关闭,需要手动开启
解决方案:
找到 ./background.js里的 new BrowserWindow 添加配置项
代码语言:javascript复制const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
enableRemoteModule: true, // 解决remote为undefined问题
nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION
}
})
mac 下快捷键失效的问题
发现在mac下,本该熟练的复制、剪切、粘贴等快捷失效了。你说说,作为一名合格的 CV 工程师,这你能忍?
这时候就想起尤大的表情包,看文档!
马不停蹄一股脑加了段代码,瞬间感觉牛逼哄哄
代码语言:javascript复制 // 判断 mac 下 注册快捷键
if (process.platform === 'darwin') {
const contents = win.webContents
globalShortcut.register('CommandOrControl C', () => {
contents.copy()
})
globalShortcut.register('CommandOrControl V', () => {
contents.paste()
})
}
后面发现这个方案并不是有效的解决方案,注册完快捷键后发现 electron 占据了系统的原有快捷键,这才发现除了 electron 以外的其他应用,这些快捷键都失效了~
后面仔细研究一番之后,通过判断应用是否激活状态,来进行相关快捷键的注册/注销.
代码语言:javascript复制// 处理系统本身的快捷键 复制 全选 等
win.on('focus', () => {
// mac下快捷键失效的问题
if (process.platform === 'darwin') {
globalShortcut.register('CommandOrControl C', () => {
console.log('注册复制快捷键成功')
contents.copy()
})
globalShortcut.register('CommandOrControl V', () => {
console.log('注册粘贴快捷键成功')
contents.paste()
})
globalShortcut.register('CommandOrControl X', () => {
console.log('注册剪切快捷键成功')
contents.cut()
})
globalShortcut.register('CommandOrControl A', () => {
console.log('注册全选快捷键成功')
contents.selectAll()
})
}
})
win.on('blur', () => {
globalShortcut.unregister('CommandOrControl C') // 注销键盘事件
globalShortcut.unregister('CommandOrControl V') // 注销键盘事件
globalShortcut.unregister('CommandOrControl X') // 注销键盘事件
globalShortcut.unregister('CommandOrControl A') // 注销键盘事件
})
windows 下控制台出现中文乱码
常见的gb2312为936 utf8为65001 配置执行命令即可解决
代码语言:javascript复制"start": "chcp 65001 && electron ."
Vue 构建的 history 模式项目打包空白
history 模式匹配不到对应静态资源,需要做一层处理,或者router 的 mode 切换为 hash 即可。
总结
electron 优势
- 上手门槛低
- 开发周期短
electron 不足
- 打包后应用体积过大
- 版本发布过快
- 安全性问题
- 资源消耗较大
- 平台上架难
前端想象力
- 无浏览器兼容问题
- 支持 ES 高级语法
- 无跨域问题
- 支持 Node.js
参考
- electron官网
- 《Electron实战(入门、进阶与性能优化)》
- 掘金小册 - 《Electron React 从 0 到 1 实现简历平台实战》