使用 Electron 打造跨平台桌面应用
早期桌面应用的开发主要借助原生 C/C++ API 进行,由于需要反复经历编译过程,且无法分离界面 UI 与业务代码,开发调试极为不便。后期出现的 QT 和 WPF 在一定程度上解决了界面代码分离和跨平台的问题,却依然无法避免较长时间的编译过程。近几年伴随互联网行业的迅猛发展,尤其是 NodeJS、Chromium 这类基于 W3C 标准开源应用的不断涌现,原生代码与 Web 浏览器开发逐步走向融合,Electron 正是在这种背景下诞生的。
Electron 是由 Github 开发,通过将Chromium和NodeJS整合为一个运行时环境,实现使用 HTML、CSS、JavaScript 构建跨平台的桌面应用程序的目的。Electron 源于 2013 年 Github 社区提供的开源编辑器 Atom,后于 2014 年在社区开源,并在 2016 年的 5 月和 8 月,通过了 Mac App Store 和 Windows Store 的上架许可,VSCode、Skype 等著名开源或商业应用程序,都是基于 Electron 打造。为了方便编写测试用例,笔者在 Github 搭建了一个简单的 Electron 种子项目Octopus,读者可以基于此来运行本文涉及的示例代码。
Getting Start
首先,让我们通过npm init
和git init
新建一个项目,然后通过如下npm
语句安装最新的
Electron 稳定版。
1 | ➜ npm i -D electron@latest |
然后向项目目录下的package.json
文件添加一条scripts
语句,便于后面通过npm start
命令启动
Electron 应用。
1 | { |
然后在项目根目录下新建resource
文件夹,里面分别再建立index.html
和main.js
两个文件,最终形成如下的项目结构:
1 | electron-demo |
main.js
是 Electron
应用程序的主入口点,当在命令行运行这段程序的时候,就会启动一个 Electron
的主进程,主进程当中可以通过代码打开指定的 Web
页面去展示 UI。
1 | /** main.js */ |
Web
页面index.html
运行在自己的渲染进程当中,但是能够通过
NodeJS 提供的 API
去访问操作系统的原生资源(例如下面代码中的process.versions
语句),这正是
Electron 能够跨平台执行的原因所在。
1 |
|
使用命令行工具执行npm start
命令之后,上述 HTML
代码在笔者 Linux
操作系统内被渲染为如下界面。应用当中,可以通过CTRL+R
重新加载页面,或者使用CTRL+SHIFT+I
打开浏览器控制台。
一个 Electron 应用的主进程只会有一个,渲染进程则会有多个。
主进程与渲染进程
- 主进程(main process)管理所有的 web
页面以及相应的渲染进程,它通过
BrowserWindow
来创建视图页面。 - 渲染进程(renderer
processes)用来运行页面,每个渲染进程都对应自己的
BrowserWindow
实例,如果实例被销毁那么渲染进程就会被终止。
Electron
分别在主进程和渲染进程提供了大量
API,可以通过require
语句方便的将这些 API
包含在当前模块使用。但是 Electron 提供的 API
只能用于指定进程类型,即某些 API
只能用于渲染进程,而某些只能用于主进程,例如上面提到的BrowserWindow
就只能用于主进程。
1 | const { BrowserWindow } = require("electron"); |
Electron 通过remote
模块暴露一些主进程的
API,如果需要在渲染进程中创建一个BrowserWindow
实例,那么就可以借助这个
remote
模块:
1 | const { remote } = require("electron"); // 获取remote模块 |
Electron 可以使用所有 NodeJS 上提供的
API,同样只需要简单的require
一下。
1 | const fs = require("fs"); |
当然,NodeJS 上数以万计的 npm 包也同样在 Electron 可用,当然,如果是涉及到底层 C/C++的模块还需要单独进行编译,虽然这样的模块在 npm 仓库里并不多。
1 | const S3 = require("aws-sdk/clients/s3"); |
既然 Electron
本质是一个浏览器 + 跨平台中间件
的组合,因此常用的前端调试技术也适用于
Electron,这里可以通过CTRL+SHIFT+I
手动开启 Chromium
的调试控制台,或者通过下面代码在开发模式下自动打开:
1 | mainWindow.webContents.openDevTools(); // 开启调试模式 |
核心模块
本节将对require("electron")
所获取的模块进行概述,便于后期进行分类查找。
app 模块
Electron
提供的app模块即提供了可用于区分开发和生产环境的app.isPackaged
属性,也提供了关闭窗口的app.quit()
和用于退出程序的app.exit()
方法,以及window-all-closed
和ready
等
Electron 程序事件。
1 | const { app } = require("electron"); |
可以使用
app.getLocale()
获取当前操作系统的国际化信息。
BrowserWindow 模块
工作在主进程,用于创建和控制浏览器窗口。
1 | // 主进程中使用如下方式获取。 |
例如需要创建一个无边框窗口的 Electron
应用程序,只需将BrowserWindow
配置对象中的frame
属性设置为false
即可:
1 | const { BrowserWindow } = require("electron"); |
例如加载页面时,渲染进程第一次完成绘制时BrowserWindow
会发出ready-to-show
事件。
1 | const { BrowserWindow } = require("electron"); |
对于较为复杂的应用程序,ready-to-show
事件的发出可能较晚,会让应用程序的打开显得缓慢。
这种情况下,建议通过backgroundColor
属性设置接近应用程序背景色的方式显示窗口,从而获取更佳的用户体验。
1 | const { BrowserWindow } = require("electron"); |
如果想要创建子窗口,那么可以使用parent
选项,此时子窗口将总是显示在父窗口的顶部。
1 | const { BrowserWindow } = require("electron"); |
创建子窗口时,如果需要禁用父窗口,那么可以同时设置modal
选项。
1 | const { BrowserWindow } = require("electron"); |
globalShortcut 模块
使用globalShortcut
模块中的register()
方法注册快捷键。
1 | const { app, globalShortcut } = require("electron"); |
Linux 和 Windows 上【Command】键会失效, 所以要使用 CommandOrControl(既 MacOS 上是【Command】键 ,Linux 和 Windows 上是【Control】键)。
clipboard 模块
用于在系统剪贴板上执行复制和粘贴操作,包含有readText()
、writeText()
、readHTML()
、writeHTML()
、readImage()
、writeImage()
等方法。
1 | const { clipboard } = require("electron"); |
globalShortcut 模块
用于在 Electron 应用程序失去键盘焦点时监听全局键盘事件,即在操作系统中注册或注销全局快捷键。
1 | const { app, globalShortcut } = require("electron"); |
ipcMain 与 ipcRenderer 模块
用于主进程到渲染进程的异步通信,下面是一个主进程与渲染进程之间发送和处理消息的例子:
1 | // 主进程 |
1 | //渲染器进程,即网页 |
如果需要完成渲染器进程到主进程的异步通信,可以选择使用
ipcRenderer
对象。
Menu 与 MenuItem 模块
用于主进程,用于创建原生应用菜单和上下文菜单。
1 | const { app, BrowserWindow, Menu } = require("electron"); |
使用
MenuItem
类可以添加菜单项至 Electron 应用程序菜单和上下文菜单当中。
netLog 模块
用于记录网络日志。
1 | const { netLog } = require("electron"); |
powerMonitor 模块
通过 Electron
提供的powerMonitor
模块监视当前电脑电源状态的改变,值得注意的是,在app
模块的ready
事件被触发之前,
不能引用或使用该模块。
1 | const electron = require("electron"); |
powerSaveBlocker 模块
阻止操作系统进入低功耗 (休眠) 模式。
1 | const { powerSaveBlocker } = require("electron"); |
protocol 模块
注册自定义协议并拦截基于现有协议的请求,例如下面代码实现了一个与[file://]
协议等效的示例:
1 | const { app, protocol } = require("electron"); |
net 模块
net
模块是一个发送 HTTP(S) 请求的客户端 API,类似于
NodeJS 的 HTTP 和 HTTPS 模块 ,但底层使用的是 Chromium 原生网络库。
1 | const { app } = require("electron"); |
Electron 中提供的
ClientRequest
类用来发起 HTTP/HTTPS 请求,IncomingMessage
类则用于响应 HTTP/HTTPS 请求。
remote 模块
remote
模块返回的每个对象都表示主进程中的一个对象,调用这个对象实质是在发送同步进程消息。因为
Electron 当中 GUI 相关的模块 (如 dialog
、menu
等) 仅在主进程中可用,
在渲染进程中不可用,所以remote
模块提供了一种渲染进程(Web
页面)与主进程(IPC)通信的简单方法。remote
模块包含了一个remote.require(module)
remote.process
:主进程中的process
对象,与remote.getGlobal("process")
作用相同, 但结果已经被缓存。remote.getCurrentWindow()
:返回BrowserWindow
,即该网页所属的窗口。remote.getCurrentWebContents()
:返回WebContents
,即该网页的 Web 内容remote.getGlobal(name)
:该方法返回主进程中名为name
的全局变量。remote.require(module)
:返回主进程内执行require(module)
时返回的对象,参数module
指定的模块相对路径将会相对于主进程入口点进行解析。
1 | project/ |
1 | // 主进程: main/index.js |
remote
模块提供的主进程与渲染进程通信方法比ipcMain
/ipcRenderer
更加易于使用。
screen 模块
检索有关屏幕大小、显示器、光标位置等信息,应用的ready
事件触发之前,不能使用该模块。下面的示例代码,创建了一个可以自动全屏窗口的应用:
1 | const electron = require("electron"); |
shell 模块
提供与桌面集成相关的功能,例如可以通过调用操作系统默认的应用程序管理文件或Url
。
1 | const { shell } = require("electron"); |
systemPreferences 模块
获取操作系统特定的偏好信息,例如在 Mac 下可以通过下面代码获取当前是否开启系统 Dark 模式的信息。
1 | const { systemPreferences } = require("electron"); |
Tray 模块
用于主进程,添加图标和上下文菜单至操作系统通知区域。
1 | const { app, Menu, Tray } = require("electron"); |
webFrame 模块
定义当前网页渲染的一些属性,比如缩放比例、缩放等级、设置拼写检查、执行 JavaScript 脚本等等。
1 | const { webFrame } = require("electron"); |
session 模块
Electron
的session
模块可以创建新的session
对象,主要用来管理浏览器会话、cookie、缓存、代理设置等等。
如果需要访问现有页面的session
,那么可以通过BrowserWindow
对象的webContents
的session
属性来获取。
1 | const { BrowserWindow } = require("electron"); |
Electron
里也可以通过session
模块的cookies
属性来访问浏览器的
Cookie 实例。
1 | const { session } = require("electron"); |
使用Session
的WebRequest
属性可以访问WebRequest
类的实例,WebRequest
类可以在
HTTP 请求生命周期的不同阶段修改相关内容,例如下面代码为 HTTP
请求添加了一个User-Agent
协议头:
1 | const { session } = require("electron"); |
desktopCapturer 模块
用于捕获桌面窗口里的内容,该模块只拥有一个方法:desktopCapturer.getSources(options, callback)
。
options
对象types
:字符串数组,列出需要捕获的桌面类型是screen
还是window
。thumbnailSize
:媒体源缩略图的大小,默认为150x150
。
callback
回调函数,拥有如下 2 个参数:error
:错误信息。sources
:捕获的资源数组。
如下代码工作在渲染进程当中,作用是将桌面窗口捕获为视频:
1 | const { desktopCapturer } = require("electron"); |
dialog 模块
调用操作系统原生的对话框,工作在主线程,下面示例展示了一个用于选择多个文件和目录的对话框:
1 | const { dialog } = require("electron"); |
由于对话框工作在 Electron 的主线程上,如果需要在渲染器进程中使用,
那么可以通过remote
来获得:
1 | const { dialog } = require("electron").remote; |
contentTracing 模块
从 Chromium
收集跟踪数据,从而查找性能瓶颈。使用后需要在浏览器打开chrome://tracing/
页面,然后加载生成的文件查看结果。
1 | const { app, contentTracing } = require("electron"); |
webview 标签
Electron 的<webview>
标签基于
Chromium,由于开发变动较大官方并不建议使用,而应考虑<iframe>
或者
Electron
的BrowserView
等选择,或者完全避免在页面进行内容嵌入。
<webview>
与<iframe>
最大不同是运行于不同的进程当中,Electron
应用程序与嵌入内容之间的所有交互都是异步进行的,这样可以保证应用程序与嵌入内容双方的安全。
1 | <webview |
webContents 属性
webContents
是BrowserWindow
对象的一个属性,负责渲染和控制
Web 页面。
1 | const { BrowserWindow } = require("electron"); |
window.open() 函数
该函数用于打开一个新窗口并加载指定url
,调用后将会为该url
创建一个BrowserWindow
实例,并返回一个BrowserWindowProxy
对象,但是该对象只能对打开的url
页面进行有限的控制。正常情况下,如果希望完全控制新窗口,可以直接创建一个新的BrowserWindow
。
1 | // window.open(url[, frameName][, features]) |
BrowserWindowProxy
对象拥有如下属性和方法:
win.closed
:子窗口关闭后设置为true
的布尔属性。win.blur()
:将焦点从子窗口中移除。win.close()
:强制关闭子窗口, 而不调用其卸载事件。win.eval(code)
:code
字符串,需要在子窗口 Eval 的代码。win.focus()
:聚焦子窗口(即将子窗口置顶)。win.print()
:调用子窗口的打印对话框。win.postMessage(message, targetOrigin)
:向子窗口发送信息。
Electron 进程
Electron 的process
对象继承自 NodeJS
的process
对象,但是新增了一些有用的事件、属性、方法。
1 | const { app, BrowserWindow } = require("electron"); |
沙箱机制
Chromium 通过将 Web 前端代码放置在一个与操作系统隔离的沙箱中运行,从而保证恶意代码不会侵犯到操作系统本身。但是 Electron 中渲染进程可以调用 NodeJS,而 NodeJS 又需要涉及大量操作系统调用,因而沙箱机制默认是禁用的。
某些应用场景下,需要运行一些不确定安全性的外部前端代码,为了保证操作系统安全,可能需要开启沙箱机制。此时首先在创建BrowserWindow
时传入sandbox
属性,然后在命令行添加--enable-sandbox
参数传递给
Electron 即可完成开启。
1 | let win; |
使用
sandbox
选项之后,将会阻止 Electron 在渲染器中创建一个 NodeJS 运行时环境,此时新窗口中的window.open()
将按照浏览器原生的方式工作。
MacBook TouchBar 支持
针对 Mac 笔记本电脑上配置的 TouchBar 硬件,Electron
提供了一系列相关的类与操作接口:TouchBar
、TouchBarButton
、TouchBarColorPicker
、TouchBarGroup
、TouchBarLabel
、TouchBarPopover
、TouchBarScrubber
、TouchBarSegmentedControl
、TouchBarSlider
、TouchBarSpacer
。
创建应用图标
用于将 PNG 或 JPG 图片设置为托盘、Dock 和应用程序的图标。
1 | const { BrowserWindow, Tray } = require("electron"); |
安全原则
由于 Electron 的发布通常落后最新版本 Chromium 几周甚至几个月,因此特别需要注意如下这些安全性问题:
使用安全的协议加载外部内容
外部资源尽量使用更安全的协议加载,比如HTTP
换成HTTPS
、WS
换成WSS
、FTP
换成FTPS
等。
1 | <!-- 错误 --> |
加载外部内容时禁用 NodeJS 集成
使用BrowserWindow
、BrowserView
、<webview>
加载远程内容时,都需要通过禁用
NodeJS 集成去限制远程代码的执行权限,避免恶意代码跨站攻击。
1 | <!-- 错误 --> |
对于需要与远程代码共享的变量或函数,可以通过将其挂载至当前页面的
window
全局对象来实现。
渲染进程中启用上下文隔离
上下文隔离是 Electron 提供的试验特性,通过为远程加载的代码创造一个全新上下文环境,避免与主进程中的代码出现冲突或者相互污染。
1 | // 主进程 |
处理远程内容中的会话许可
当页面尝试使用某个特性时,会弹出通知让用户手动进行确认;而默认情况下,Electron 会自动批准所有的许可请求。
1 | const { session } = require("electron"); |
不要禁用 webSecurity
在渲染进程禁用webSecurity
将导致许多重要的安全性功能被关闭,因此
Electron 默认开启。
1 | const mainWindow = new BrowserWindow({ |
定义 CSP 安全策略
内容安全策略 CSP 允许 Electron 通过webRequest
对指定 URL
的访问进行约束,例如允许加载https://uinika.github.io/
这个源,那么https://hack.attacker.com
将不会被允许加载,CSP
是处理跨站脚本攻击、数据注入攻击的另外一层保护措施。
1 | const { session } = require("electron"); |
使用file://
协议打开本地文件时,可以通过元数据标签<meta>
的属性来添加
CSP 约束。
1 | <meta http-equiv="Content-Security-Policy" content="default-src 'none'" /> |
别设置 allowRunningInsecureContent 为 true
Electron 默认不允许在 HTTPS 页面中加载 HTTP
来源的代码,如果将allowRunningInsecureContent
属性设置为true
会禁用这种保护。
1 | const mainWindow = new BrowserWindow({ |
不要开启实验性功能
开发人员可以通过experimentalFeatures
属性启用未经严格测试的
Chromium 实验性功能,不过 Electron
官方出于稳定性和安全性考虑并不建议这样做。
1 | const mainWindow = new BrowserWindow({ |
不要使用 enableBlinkFeatures
Blink 是 Chromium 内置的 HTML/CSS
渲染引擎,开发者可以通过enableBlinkFeatures
启用其某些默认是禁用的特性。
1 | const mainWindow = new BrowserWindow({ |
禁用 webview 的 allowpopups
开启allowpopups
属性将使window.open()
创建一个新的窗口和BrowserWindows
,若非必要状况,尽量不要使用此属性。
1 | <!-- 错误 --> |
验证 webview 选项与参数
通过渲染进程创建的<WebView>
默认不集成
NodeJS,但是它可以通过webPreferences
属性创建出一个独立的渲染进程。在<WebView>
标签开始渲染之前,Electron
将会触发一个will-attach-webview
事件,可以通过该事件防止创建具有潜在不安全选项的
Web 视图。
1 | app.on("web-contents-created", (event, contents) => { |
禁用或限制网页跳转
如果 Electron
应用程序不需要导航或只需导航至特定页面,最佳实践是将导航限制在已知范围,并禁止其它类型的导航。可以通过在will- navigation
事件处理函数中调用event.preventDefault()
并添加额外的判断来实现这一点。
1 | const URL = require("url").URL; |
禁用或限制新窗口创建
限制在 Electron
应用程序中创建额外窗口,并避免因此带来额外的安全隐患。webContents
创建新窗口时会触发一个web-contents-created
事件,该事件包含了将要打开的
URL
以及相关选项,可以在这个事件中检查窗口的创建,从而对其进行相应的限制。
1 | const { shell } = require("electron"); |
Electron 2.0 版本开始,会在可执行文件名为 Electron 时会为开发者在控制台显示安全相关的警告和建议,开发人员也可以在
process.env
或window
对象上配置ELECTRON_ENABLE_SECURITY_WARNINGS
或ELECTRON_DISABLE_SECURITY_WARNINGS
手动开启或关闭这些警告。
应用发布
Electron 的发布有别于传统桌面应用程序编译打包的发部过程,需要首先下载已经预编译完成的二进制包,Linux 下二进制包结构如下:
1 | ➜ electron-v3.0.7-linux-x64 tree -L 2 |
接下来,就可以部署前面编写的源代码,Electron 里主要有如下两种部署方式:
- 直接将代码放置到
resources
下的子目录,比如app
目录:
1 | ├── app |
- 将应用打包加密为
asar
文件以后放置到resources
目录,比如app.asar
文件。
1 | ├── app.asar |
asar 打包源码
asar
是一种简单的文件扩展格式,可以将文件如同tar
格式一样前后连接在一起,支持随机读写,并使用
JSON 来保存文件信息,可以方便的读取与解析。Electron 通过它可以解决
Windows
文件路径长度的限制,提高require
语句的加载速度,并且避免源代码泄漏。
首先,需要安装asar
这个 npm
包,然后可以选择全局安装然通过命令行使用。
1 | ➜ npm install asar |
当然,更加工程化的方式是通过代码来执行打包操作,就像下面这样:
1 | let asar = require("asar"); |
Electron 在 Web 页面可以通过file:
协议读取 asar
包中的文件,即将 asar 文件视为一个虚拟的文件夹来进行操作。
1 | const { BrowserWindow } = require("electron"); |
如果需要对 asar 文件进行 MD5 或者 SHA 完整性校验,可以对 asar 档案文件本身进行操作。
1 | asar list app.asar |
rcedit 编辑可执行文件
RcEdit是一款通过编辑窗口管理器的
rc 文件来对其进行配置的工具,Nodejs 社区提供了node-rcedit工具对
Windows
操作系统的.exe
文件进行配置,首先通过npm i rcedit --save-dev
为项目安装该依赖项。
1 | var rcedit = require("rcedit"); |
rcedit()
函数包含有如下属性:
exePath
:需要进行修改的 Windows 可执行文件所在路径。options
:一个拥有如下属性的配置对象。version-string
:版本字符串;file-version
:文件版本;product-version
:产品版本;icon
:图标文件.ico
的路径;requested-execution-level
:需要修改的执行级别(asInvoker
、highestAvailable
、requireAdministrator
)。application-manifest
:本地清单文件的路径。
callback
:函数执行完毕之后回调,完整的函数签名为function(error)
。
yarn 包管理器
Yarn 是一款由 Facebook 推出的 JavaScript 包管理器,与 NodeJS 提供的
Npm 一样使用package.json
作为包信息文件。
测试当前 Yarn 的安装版本:
1 | yarn --version |
初始化新项目:
1 | yarn init |
添加依赖包:
1 | yarn add [package] |
将依赖项添加到不同的依赖项类别:devDependencies、peerDependencies 和 optionalDependencies。
1 | yarn add [package] --dev |
升级依赖包:
1 | yarn upgrade [package] |
移除依赖包:
1 | yarn remove [package] |
可以直接使用yarn
命令安装项目的全部依赖:
1 | yarn install |
windows-installer
windows-installer是一个用于为 Electron 应用程序构建 Windows 安装程序的 Npn 包,底层基于Squirrel(一组用于管理C#或C++开发的 Windows 应用程序的安装、更新的工具库)进行实现。
1 | npm install --save-dev electron-winstaller |
1 | var electronInstaller = require("electron-winstaller"); |
electron-build
除了像上面这样通过预编译包手动打包应用程序,也可以采用electron-forge、electron-packager等第三方包来完成这项工作,在这里笔者选择electron-builder来进行自动化打包任务。
Electron Userland 是一个维护 Electron
模块的第三方社区,electron-builder 是由其维护的一款能够同时处理
Windows、MacOS、Linux 多平台的打包编译工具。由于 electron-builder
工具包的文件体积较大,其社区强烈推荐使用更快速的yarn
来代替npm
作为包管理方案。
1 | yarn add electron-builder --dev |
electron-builder 能够以命令行或者JavaScript API的方式进行使用:
(1)如果安装在项目目录下的node_modules
目录,可以直接通过
NodeJS 提供的npx
以命令行方式使用:
1 | npx electron-builder |
(2)也可以像使用其它 Npm 包那样直接调用 electron-builder 提供的 API。
1 | ; |
官方推荐使用electron-webpack-quick-start作为 Electron 应用的项目模板。
1 | { |
如果项目中存在原生依赖,还需要设置nodeGypRebuild为
true
。
如果需要调试electron-builder
的行为,那么需要设置DEBUG=electron-builder
环境变量。
1 | set DEBUG=electron-builder // Cmder |
electron-forge
electron-forge同样是由 Electron Userland 维护的一款命令行工具,用于快速建立、打包、发布一个 Electron 应用程序。
1 | λ npm install -g electron-forge |
目前 Github 上 electron-builder 的 Star 远远超过 electron-forge,由于笔者项目需要使用 React,因而也就选用带有支持 React 项目模板的 electron-builder,需要尝试 electron-forge 的同学可以移步官网查看更多信息。
使用 Electron 打造跨平台桌面应用