在当前的 Web 时代,用户通过使用浏览器就可以满足大部分的诉求,例如看新闻、上网课、购物甚至是玩游戏,那前端还有必要学习开发桌面应用吗?
在回答这个问题之前,我们来看一下有哪些技术可以开发桌面应用:
编程语言/技术框架 | 可提供的能力 | 代表作 |
---|---|---|
C#/WPF | 开发 Windows 应用 | Visual Studio |
Swift | 开发 macOS 应用 | CodeEdit |
C++/QT | 跨平台应用 | YY 语音 |
C++/CEF | 跨平台应用 | 钉钉 |
Java/Swing | 跨平台应用 | JetBrains 系列软件 |
JavaScript/Electron | 跨平台应用 | Visual Studio Code |
Rust/Tauri | 跨平台应用 | xplorer |
Dart/Flutter | 跨平台应用 | rustdesk |
作为一名开发者,学习某项技术需要看投资回报率高不高,像 Windows 平台上的 C# 和 macOS 平台上的 Swift,个人觉得技术路线会比较窄,未来更多的场景是采用跨平台的技术来实现,所以如果你有 C++ 的功底,QT 和 CEF 是不错的选择,而对于前端同学来说,最好的选择无外乎下面三个:
可以看到,上述三个选择的优势都非常明显,个人认为选择其中任何一种的投资收益率都不低,接下来我会带领大家一起探索桌面端应用的开发,把上面技术都体验一番,而第一期先讲 Electron。
for app in /Applications/*; do;[ -d $app/Contents/Frameworks/Electron\ Framework.framework ] && echo $app; done
经过这个命令你会发现市面上挺多应用是用 Electron 开发的
$ mkdir electron-desktop
$ cd electron-desktop
$ yarn init -y
$ yarn add electron --dev
上面的命令会创建一个空白的项目,包含一个 package.json 文件和 node_modules 目录下安装的依赖,接下来我们创建一个 src 目录,里面再分别创建两个子目录 main 和 renderer,此时目录结构如下 :
在 Electron 中有主进程和渲染进程两个重要的概念,我们在主进程里面用 Node.js 代码调用 Electron 封装好的 API 来创建窗口,管理应用整个生命周期,而在渲染进程里面加载传统的 Web 界面。因此:
首先在 renderer 目录中创建 index.html 文件,内容为:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Electron Desktop</title>
<style>
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #ABE3F1;
-webkit-app-region: drag;
}
img {
width: 200px;
}
h1 {
margin: 50px;
}
</style>
</head>
<body>
<img src="https://img.zlib.cn/electron.webp" />
<h1>Electron hello</h1>
</body>
</html>
开发一款企业级应用之前,从产品侧要定义好该应用是否支持用户打开多个实例。默认情况下,Electron 应用就是多实例的,例如在 Windows 上,用户每双击一次 exe 就会开启一次应用,而在 Mac 系统上面,如果应用已经启动,再次双击并不会重新启动应用,而是唤起当前正在运行的实例,不过用户可以通过下面的方式开启多实例:
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
requestSingleInstanceLock 方法用于抢占实例运行锁,只有第一个启动的实例(第一实例)才返回 true,而一旦锁被强占之后,后续启动的其他实例(第二实例)再调用这个方法就会返回 false,从而进入到 if 语句,将其强制退出,从而确保只有一个实例运行。
当第二实例被强制退出的时候,一般需要把第一实例的窗口显示到前台,这样给用户的感觉就像唤起了应用程序,否则用户可能并没有意识到之前已经启动过该程序了,只会纳闷为啥当前的程序启动不了,体验上会非常不友好。因此 Electron 也提供了second-instance
事件用于在「第一实例」里面监听「第二实例」启动的行为,来驱动「第一实例」对该行为做出合适的反馈,例如:如果窗口在后台就显示出来,如果窗口最小化就恢复窗口等。最终的代码如下:
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', (event, argv, workingDirectory) => {
mainWindow.restore() // 从最小化窗口恢复
mainWindow.show() // 从后台显示
})
}
Electron 提供了三个与自定义协议相关的方法:
setAsDefaultProtocolClient
:设置协议isDefaultProtocolClient
:查询状态removeAsDefaultProtocolClient
:删除协议这是怎么做到的呢?就是通过自定义协议。所谓自定义协议,其实就是给应用起个独一无二的名称,然后注册到操作系统里面,凡是通过这个协议名就能唤起这个软件了,在 Electron 中注册协议只需要一行代码:
app.setAsDefaultProtocolClient('electron-desktop')
注册之后,当在浏览器中输入 electron-desktop://
之后,会发现弹出跳转提示,点击同意就能启动并跳转到桌面应用了,通过这种协议唤起应用被称为 scheme 唤起,而且在唤起的时候还可以带上一些参数,例如:
electron-desktop://width=800&height=600
scheme 唤起的行为是操作系统默认支持的,操作系统也提供了 API 来监听唤起事件并拿到唤起参数。
关于自定义协议相关的资料:
在 Mac 上面通过监听 open-url 事件,可以拿到唤起的 scheme 参数:
app.on('open-url', (event, url) => {
console.log(url) // 打印 electron-desktop://width=800&height=600
})
url 里面就是 scheme 唤起的完整地址字符串,除了开头的 electron-desktop://
前缀之外,后面的内容是完全交给用户自定义的,例如:
electron-desktop://hello-juejin
electron-desktop://1+1=2
这些都可以唤起,上面之所以用 width=800&height=600
完全是因为模仿 http 地址栏的 query 参数的格式,有现成的 API 方便解析参数而已。下面给出完整的示例,把 open-url 的回调获取到的 scheme 参数解析出来放到全局变量 urlParams 里面:
const { app, BrowserWindow } = require('electron')
const protocol = 'electron-desktop'
app.setAsDefaultProtocolClient(protocol)
let urlParams = {}
app.on('open-url', (event, url) => {
const scheme = `${protocol}://`
const urlParams = new URLSearchParams(url.slice(scheme.length))
urlParams = Object.fromEntries(urlParams.entries())
})
app.whenReady().then(() => {
createWindow()
})
function createWindow() {
const mainWindow = new BrowserWindow({ width: 800, height: 600 })
mainWindow.loadURL('https://www.juejin.cn')
}
协议唤起在 Mac 平台上有两点需要注意:
Windows 平台上没有提供 open-url 事件,而是会把 scheme 作为启动参数传递给应用程序,在代码里面可以用 process.argv 拿到所有参数,它是一个数组,格式如
["electron-desktop.exe", "--allow-file-access-from-files", "electron-desktop://width=400&height=300"]
// 第一个参数是应用程序的路径,后面的就是其他的启动参数,如果是 scheme 唤起的,也会在里面
可以用下面的代码进行判断:
const url = process.argv.find(v => v.startsWith(scheme))
if (url) { // 如果发现 electron-desktop:// 前缀,说明是通过 scheme 唤起
console.log(url)
}
如果程序支持多示例,每次都会启动新的程序,上面的代码就够用了。但如果是单实例的场景,情况就稍稍不同了,因为本质上还是会打开新的程序,只不过程序里判断单实例锁被占用,从而则立即退出,所以必须要有办法在 scheme 唤起的时候,能够通知到当前正在运行的那个实例。这里用到的仍然是 second-instance 事件:
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', (event, argv, workingDirectory) => {
// Mac 平台只需要展示窗口即可
mainWindow.restore()
mainWindow.show()
// Windows 平台上需要判断新的实例是否被 scheme 唤起
const url = argv.find(v => v.startsWith(scheme))
if (url) { // 如果发现 electron-desktop:// 前缀,说明是通过 scheme 唤起
console.log(url)
}
})
}
关键在于第二个参数 argv,如果是通过 scheme 唤起的话,argv 里面会包含 scheme 协议,与 process.argv 类似,格式是一个数组,第一项就是 electron-desktop.exe 的位置,后面是一些参数,例如:
["electron-desktop.exe", "--allow-file-access-from-files", "electron-desktop://width=400&height=300", "C:\\Windows\\system32"]
Electron 目前有两种打包工具:electron-userland/electron-builder 和 electron-userland/electron-packager。
这里使用 electron-userland/electron-packager
"scripts": {
"start": "electron .",
"pack": "electron-builder --win" //新增
}
Windows 上项目要配置.npmrc 源,否则构建的时候会有可能出现装依赖卡住的情况
registry=https://registry.npm.taobao.org
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
一切配置好后,执行 npm run pack 就可以构建应用了。
企业级桌面应用的资源都是本地化的,离线也能使用,所以需要把 html、js、css 这些资源都打包进去,接下来我们就在 src/renderer 目录下创建 index.html 和 index.js 两个文件:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Electron Desktop</title>
</head>
<body>
<p id="platform">操作系统:</p>
<p id="release">版本号:</p>
<script src="./index.js"></script>
</body>
</html>
然后在创建窗口函数里面把用 loadURL 加载网页的代码换成 loadFile 加载本地文件:
function createWindow() {
mainWindow = new BrowserWindow({ width: 800, height: 600 })
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
}
这样就可以加载本地 HTML 文件了,接下来要实现在纯 web 沙箱环境中无法完成的需求:
因为传统的 Web 网页运行在浏览器沙箱环境里面,没有能力调用操作系统 API,但是 Electron 就不一样了,它支持在 Web 中执行 Node.js 代码。不过这个能力默认是不开启的,要想使用这个能力,必须在创建窗口的时候指定两个参数:
nodeIntegration: true
:开启 node.js 环境集成contextIsolation: false
:关闭上下文隔离function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
})
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
}
然后就可以在 src/renderer/index.js 中调用 node.js 的方法:
const os = require('os')
const platform = os.platform()
const release = os.release()
document.getElementById('platform').append(platform)
document.getElementById('release').append(release)
直接在网页上调用 node.js 的 API 存在极大风险,比如加载一个第三方的 Web 页面的时候,可能会被植入恶意脚本(例如调用 fs 模块删除文件等)。因此,Electron 官方不推荐开启 nodeIntegration,而是建议使用加载 preload 脚本的方式:
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false, // 不开启 node 集成
preload: path.join(__dirname, '../preload/index.js'), // 在 preload 脚本中访问 node 的 API
},
})
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
}
preload 脚本是特殊的 JS 脚本,由 Electron 注入到 index.html 当中,会早于 index.html 文件中引入的其他脚本,而且它有权限访问 node.js 的 API,无论用户是否开启了 nodeIntegration。我们把 src/renderer/index.js 的内容删除,然后在 src 目录下新增 preload/index.js 文件,代码为:
console.log('preload index.js')
console.log('platform', require('os').platform())
如果你在运行的时候报错了,提示 module not found,是因为从 Electron 20 版本开始,渲染进程默认开启沙箱模式,需要指定 sandbox: false
才行,在 webPreferences 配置项里加入 sandbox:false
有一点需要特别注意的是:preload.js 脚本注入的时机非常之早,执行该脚本的时候,index.html 还没有开始解析,所以不能立即操作 DOM,需要在 DOMContentLoaded 事件之后再操作:
const os = require('os')
const platform = os.platform()
const release = os.release()
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('platform').append(platform)
document.getElementById('release').append(release)
})
preload.js 脚本中可以访问 node.js 的 全部 API 和 Electron 提供的渲染进程 API,这个脚本最终也是会注入到 index.html 页面里面的,在 webPreferences 的选项当中有个 contextIsolation 配置,表示是否开启上下文隔离(默认开启),它的具体含义为:
preload.js 脚本和 index.html 是否共享相同的 document 和 window 对象
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
contextIsolation: true, // 默认就是 true
preload: path.join(__dirname, '../preload/index.js'),
sandbox: false,
},
})
win.loadFile(path.join(__dirname, '../renderer/index.html'))
在 preload/index.js 中,为 window 属性增加一个变量:
window.fromPreload = 'something fromPreload'
然后在 renderer/index.html 当中,是不可以通过脚本获取这个变量的:
<script>
console.log(window.fromPreload) // undefined
</script>
// 打印出来的结果是 undefined
如果把 webPreferences 中的 contextIsolation 改成 false,那么在 index.html 中就可以拿到 preload.js 中挂在 window 上的对象!
默认情况下,上下文隔离是默认开启的,防止污染全局对象,不单单是 Windows 对象,JavaScript 中的所有全局可访问对象都是共享的,例如 Date 对象,假如在 preload.js 中设置了下面的代码:
Date.now = () => 1
如果没开启上下文隔离,如果在 index.html 中用到了,那么将永远输出 1
这很有可能对线上业务造成严重的影响,出于安全考虑,Electron 默认开启上下文隔离,如果想在 preload.js 和 index.html 共享变量,可以通过 contextBridge 的方式:
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('myAPI', {
doSomething: () => { console.log('doSomething')
}
})
那么可以在 index.html 可以直接使用 myAPI 全局变量,如果暴露变量名称和已有的全局变量发生冲突,例如:
contextBridge.exposeInMainWorld('Date', {
now: () => 1,
})
则程序启动后会报错,挂载失败。
进程名 | 中文名 | 作用 |
---|---|---|
Electron | 主进程 | 负责界面显示、用户交互、子进程管理,控制应用程序的地址栏、书签,前进/后退按钮等,同时提供存储等功能 |
Electron Helper | 网络进程 | 负责页面的网络资源加载 |
Electron Helper (Renderer) | 渲染进程 | 负责网页排版和交互(排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中) |
Electron Helper (GPU) | GPU 进程 | 负责 GPU 渲染 |
Electron Helper (Plugin) 插件进程 | 渲染进程 | 插件进程 负责插件的运行 |
它们之间彼此相互协作,构成了完整的桌面应用:
一个 Electron 开发的应用,包含一个主进程、多个渲染进程和若干工具类辅助进程。其中,主进程用于管理应用窗口,渲染进程用于加载网页,它们之间会频繁通信。
在 Electron 中,启动项目时运行的 main.js 脚本就是我们说的主进程。在主进程运行的脚本可以以创建 web 页面的形式展示 GUI。
一个 Electron 应用有且只有一个主进程。并且创建窗口等所有系统事件都要在主进程中进行。
由于 Electron 使用 Chromium 来展示页面,所以 Chromium 的多进程结构也被充分利用。每个 Electron 的页面都在运行着自己的进程,这样的进程我们称之为渲染进程。
也就是说每创建一个 web 页面都会创建一个渲染进程。每个 web 页面都运行在它自己的渲染进程中。每个渲染进程是独立的,它只关心它所运行的页面。
在 Electron 应用程序中,主进程和渲染进程是两种不同的进程类型。主进程负责管理应用程序的生命周期,并创建和管理应用程序窗口,而渲染进程负责呈现 Web 内容和控制用户界面。每个 Electron 页面都运行在其自己的渲染进程中,确保如果一个页面崩溃,它不会影响应用程序的其余部分。主进程具有对系统资源的高级访问权限,而渲染进程对资源的访问权限受到限制,没有直接访问 Node.js API 的权限。两个进程之间的通信是通过 ipc 方法完成的,允许它们相互交互并构建丰富的桌面应用程序。
在 Electron 应用程序中,主进程和渲染进程之间的通信是通过 ipc 方法实现的。主进程可以通过 ipcMain 模块发送事件和数据给渲染进程,而渲染进程可以通过 ipcRenderer 模块接收和发送事件和数据给主进程。使用 ipc 方法可以让主进程和渲染进程之间进行双向通信,这样它们就可以相互协作,构建丰富的桌面应用程序。需要注意的是,由于渲染进程的安全限制,它们不能直接访问 Node.js API,必须通过主进程进行间接访问。
渲染器进程到主进程(单向)
要将单向 IPC 消息从渲染器进程发送到主进程,您可以使用 ipcRenderer.send API 发送消息,然后使用 ipcMain.on API 接收。通常使用此模式从 Web 内容调用主进程 API。 我们将通过创建一个简单的应用来演示此模式,可以通过编程方式更改它的窗口标题。对于此演示,您需要将代码添加到主进程、渲染器进程和预加载脚本。 完整代码如下,我们将在后续章节中对每个文件进行单独解释。
渲染器进程:
脚本:preload.js
const { contextBridge, ipcRenderer } = require('electron')
// 注册事件
contextBridge.exposeInMainWorld('electronAPI', {
setTitle: (title) => ipcRenderer.send('set-title', title)
})
渲染:renderer.js
// 获取DOM节点
const asyncMsgBtn = document.getElementById('async-msg')
// 触发事件
asyncMsgBtn.addEventListener('click', function () {
const title = titleInput.value
window.electronAPI.setTitle(title)
})
主进程:
const { ipcMain } = require('electron')
ipcMain.on('set-title', (event, title) => {
// event等同于ipcMain
// title渲染进程发送来的消息
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
})
渲染器进程到主进程(双向)
双向 IPC 的一个常见应用是从渲染器进程代码调用主进程模块并等待结果。 这可以通过将 ipcRenderer.invoke 与 ipcMain.handle 搭配使用来完成。
渲染器进程:
脚本: preload.js
const { contextBridge, ipcRenderer } = require('electron')
// 注册事件
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile')
})
渲染: renderer.js
// 获取DOM节点
const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')
btn.addEventListener('click', async () => {
// 等待主进程返回结果
const filePath = await window.electronAPI.openFile()
filePathElement.innerText = filePath
})
主进程:
const { app, BrowserWindow, ipcMain, dialog } = require('electron')
async function handleFileOpen () {
const { canceled, filePaths } = await dialog.showOpenDialog()
// return 返回渲染进程结果
if (canceled) {
return ...
} else {
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()
})
主进程到渲染器
将消息从主进程发送到渲染器进程时,需要指定是哪一个渲染器接收消息。 消息需要通过其 WebContents
实例发送到渲染器进程。 WebContents 实例包含一个 send
方法,其使用方式与 ipcRenderer.send
相同。
为了演示需要用原生操作系统菜单进程演示,因为系统菜单栏是基于主进程的
主进程:
const { app, BrowserWindow, Menu, ipcMain } = require('electron')
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'
},
]
}
])
Menu.setApplicationMenu(menu)
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
})
渲染进程:
脚本:preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
handleCounter: (callback) => ipcRenderer.on('update-counter', callback)
})
渲染: renderer.js
const counter = document.getElementById('counter')
window.electronAPI.handleCounter((event, value) => {
// value主进程传来的消息
const oldValue = Number(counter.innerText)
const newValue = oldValue + value
counter.innerText = newValue
})
通过上面学习的部分内容与概念,我们可以用以上的知识进行基本的实战扩展学习,更容易理解和上手
在这里用一个时间应用作案例,先附上完整代码,在逐一讲解
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></title>
<link rel="stylesheet" href="./clock.css" />
</head>
<body>
<div class="main">
<div id="clock">
<ul class="hours">
<li>
<span> 1 </span>
</li>
<li>
<span> 2 </span>
</li>
<li>
<span> 3 </span>
</li>
<li>
<span> 4 </span>
</li>
<li>
<span> 5 </span>
</li>
<li>
<span> 6 </span>
</li>
<li>
<span> 7 </span>
</li>
<li>
<span> 8 </span>
</li>
<li>
<span> 9 </span>
</li>
<li>
<span> 10 </span>
</li>
<li>
<span> 11 </span>
</li>
<li>
<span> 12 </span>
</li>
</ul>
<div class="hr-wrapper">
<div class="hand hr"></div>
</div>
<div class="min-wrapper">
<div class="hand min"></div>
</div>
<div class="sec-wrapper">
<div class="hand sec"></div>
</div>
</div>
</div>
</body>
<script>
const dateInfo = new Date();
const hr =
dateInfo.getHours() > 12
? dateInfo.getHours() - 12
: dateInfo.getHours(),
min = dateInfo.getMinutes(),
sec = dateInfo.getSeconds(),
milsec = dateInfo.getMilliseconds();
let hrAngle = hr * 30 + (min * 6) / 12,
minAngle = min * 6 + (sec * 6) / 60,
secAngle = sec * 6 + (milsec * 0.36) / 1000;
function setAngle(wrapper, angle) {
document.querySelector("." + wrapper).style.transform =
"rotate(" + angle + "deg)";
}
setAngle("hr-wrapper", hrAngle);
setAngle("min-wrapper", minAngle);
setAngle("sec-wrapper", secAngle);
</script>
</html>
css 文件
* {
padding: 0;
margin: 0;
}
body {
color: #333;
font-family: Helvetica, sans-serif;
display: flex;
justify-content: center;
align-items: center;
}
.main {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
flex-direction: column;
}
ul {
list-style: none;
top: 0;
margin: 0;
padding: 0;
position: absolute;
text-align: center;
}
li {
position: absolute;
transform-origin: 50% 100%;
height: 160px;
}
.hours {
left: 120px;
font-size: 23.3333333333px;
letter-spacing: -1.6px;
line-height: 45px;
}
.hours li {
width: 80px;
}
.hours span {
display: block;
}
.hours li:nth-of-type(1) {
transform: rotate(30deg);
}
.hours li:nth-of-type(1) span {
transform: rotate(-30deg);
}
.hours li:nth-of-type(2) {
transform: rotate(60deg);
}
.hours li:nth-of-type(2) span {
transform: rotate(-60deg);
}
.hours li:nth-of-type(3) {
transform: rotate(90deg);
}
.hours li:nth-of-type(3) span {
transform: rotate(-90deg);
}
.hours li:nth-of-type(4) {
transform: rotate(120deg);
}
.hours li:nth-of-type(4) span {
transform: rotate(-120deg);
}
.hours li:nth-of-type(5) {
transform: rotate(150deg);
}
.hours li:nth-of-type(5) span {
transform: rotate(-150deg);
}
.hours li:nth-of-type(6) {
transform: rotate(180deg);
}
.hours li:nth-of-type(6) span {
transform: rotate(-180deg);
}
.hours li:nth-of-type(7) {
transform: rotate(210deg);
}
.hours li:nth-of-type(7) span {
transform: rotate(-210deg);
}
.hours li:nth-of-type(8) {
transform: rotate(240deg);
}
.hours li:nth-of-type(8) span {
transform: rotate(-240deg);
}
.hours li:nth-of-type(9) {
transform: rotate(270deg);
}
.hours li:nth-of-type(9) span {
transform: rotate(-270deg);
}
.hours li:nth-of-type(10) {
transform: rotate(300deg);
}
.hours li:nth-of-type(10) span {
transform: rotate(-300deg);
}
.hours li:nth-of-type(11) {
transform: rotate(330deg);
}
.hours li:nth-of-type(11) span {
transform: rotate(-330deg);
}
.hours li:nth-of-type(12) {
transform: rotate(360deg);
}
.hours li:nth-of-type(12) span {
transform: rotate(-360deg);
}
#clock {
background: #fff;
border: 15px solid #222;
border-radius: 50%;
position: relative;
width: 320px;
height: 320px;
margin: auto;
}
.hr-wrapper,
.min-wrapper,
.sec-wrapper {
position: absolute;
width: 320px;
height: 320px;
}
.hand {
position: absolute;
bottom: 50%;
transform-origin: 50% 100%;
}
.hr {
background: #222;
left: 152px;
width: 13px;
height: 105px;
border-radius: 10px;
animation: rotateHand 43200s linear infinite;
}
.hr:after {
background: #222;
border-radius: 50%;
content: "";
display: block;
position: absolute;
bottom: -8px;
width: 13px;
height: 16px;
}
.min {
background: #222;
left: 155px;
width: 9px;
height: 125px;
border-radius: 8px;
animation: rotateHand 3600s linear infinite;
}
.min:after {
background: #222;
border-radius: 50%;
content: "";
display: block;
position: absolute;
bottom: -8px;
width: 9px;
height: 16px;
}
.sec {
background: #d00;
left: 156.5px;
width: 5px;
height: 132px;
border-radius: 8px;
animation: rotateHand 60s linear infinite;
}
.sec:after {
background: #d00;
border-radius: 50%;
content: "";
display: block;
position: absolute;
bottom: -3.5px;
width: 5px;
height: 7px;
}
@keyframes rotateHand {
to {
transform: rotate(1turn);
}
}
main 主进程 js
const { app, BrowserWindow, ipcMain } = require("electron");
let mainWindow = null;
function createWindow () {
mainWindow = new BrowserWindow({
width: 600,
height: 500,
title: 'clock',
frame: false,
transparent: true,
});
mainWindow.loadFile(path.join(__dirname, "../renderer/clock.html"));
}
app.whenReady().then(() => {
createWindow();
});
讲解:
在 Electron 里,窗口都是通过实例化 BrowserWindows 类创建出来的
const mainWindow = new BrowserWindow({ width: 800, height: 600 })
这样就会穿件一个宽 800 高 600 的窗口出来,创建的窗口是空的,如果想要在窗口界面上显示内容化,Electron 提供了两个方法:
loadURL:加载指定网站
loadFile:加载本地文件
const mainWindow = new BrowserWindow({ width: 800, height: 600 })
mainWindow.loadFile(path.join(__dirname, '../renderer/clock.html')) //加载本地html文件进行渲染
窗口属性:
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
title: 'clock', // 应用标题
frame: false, // 无边框窗口,可用于自定义菜单
transparent: true, // 使窗口透明
});
根据上面 frame 属性,我们还可以自定义个标题栏结合进程通信实现一个自定义的最小化,最大化和关闭应用功能,如图:
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></title>
<link rel="stylesheet" href="./clock.css" />
+ <script src="./clock.js"></script>
</head>
<body>
+ <div class="main">
+ <div class="title-bar">
+ <button id="minimize">最小化</button>
+ <button id="maximize">最大化</button>
+ <button id="close">关闭</button>
+ </div>
...
</div>
</html>
css 文件增加内容
.title-bar {
display: flex;
justify-content: flex-end;
align-items: center;
width: 100%;
height: 50px;
border-bottom: 1px solid #eee;
margin-bottom: 10px;
}
.title-bar button {
display: flex;
justify-content: center;
align-items: center;
width: 70px;
font-size: 13px;
cursor: pointer;
margin-right: 5px;
}
新增 preload 脚本 js
const { contextBridge, ipcRenderer } = require('electron')
// 注册事件,发送给主进程
contextBridge.exposeInMainWorld('electronAPI', {
handleMinimize: () => ipcRenderer.send('minimize'),
handleMaximize: (callback) => ipcRenderer.send('maximize', callback),
handleClose: (callback) => ipcRenderer.send('close', callback),
})
主进程 main 文件增加
const { app, BrowserWindow, ipcMain } = require("electron");
let mainWindow = null;
function createWindow () {
mainWindow = new BrowserWindow({
width: 600,
height: 500,
title: 'clock',
frame: false,
webPreferences: {
nodeIntegration: true, // 不开启 node 集成
preload: path.join(__dirname, '../preload/clock.js'), // 在 preload 脚本中访问 node 的 API
sandbox: false
},
});
mainWindow.loadFile(path.join(__dirname, "../renderer/clock.html"));
}
app.whenReady().then(() => {
createWindow();
});
// 最小化事件
ipcMain.on('minimize', (event) => {
if (!mainWindow.isMaximized()) {
mainWindow.minimize()
}
})
// 最大化事件
ipcMain.on('maximize', (event) => {
if (!mainWindow.isMaximized()) {
mainWindow.maximize()
} else {
mainWindow.restore()
}
})
// 关闭事件
ipcMain.on('close', (event) => {
mainWindow.close()
})
讲解
这里面主要是 html 和 css 里新增了个 title-bar 的内容和样式,作为前端写样式是很简单的,这里不多说,其次是用到了上面的进程通信。
在 preload 脚本里注册事件给与渲染进程发送消息到主进程去,这里主要讲主进程里 BrowserWindow 实例里的相应 API。
这里讲下最大化最小化和关闭的方法
isMaximized:此方法可以判断应用是否处在最大化,调用此方法会根据应用当前的状态返回一个 boolean 值
maximize:此方法窗口最大化时触发
minimize:此方法窗口最小化时触发
restore:此方法当窗口从最小化状态恢复时触发
close:此方法关闭应用
章节完整代码先附上
main 主进程 js
const { app, BrowserWindow, Menu, ipcMain } = require("electron");
const path = require("path");
let mainWindow = null;
const template = [
{
label: "自定义菜单",
submenu: [
{
label: "点我试试",
click: () => {
console.log("try");
},
},
{
label: "默认选中",
type: "checkbox",
checked: true,
},
{ type: "separator" },
{
label: "单选菜单",
submenu: [
{ label: "选项1", type: "radio" },
{ label: "选项2", type: "radio" },
{ label: "选项3", type: "radio" },
],
},
{
label: "多级菜单",
submenu: [
{
label: "二级菜单",
submenu: [{ label: "三级菜单", submenu: [{ label: "四级菜单" }] }],
},
],
},
],
},
{
label: "调试菜单",
submenu: [
{ role: "toggleDevTools" },
{
role: "reload",
accelerator: "Shift+K",
click: () => {
console.log("reload");
},
},
],
},
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
function createWindow() {
mainWindow = new BrowserWindow({
width: 600,
height: 500,
webPreferences: {
nodeIntegration: true,
preload: path.join(__dirname, "../../preload/menu/index.js"),
sandbox: false,
},
});
mainWindow.loadFile(path.join(__dirname, "../../renderer/menu/index.html"));
}
app.whenReady().then(() => {
createWindow();
});
// 上下文菜单
ipcMain.on("open-popup", (event) => {
menu.popup();
});
preload 脚本
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
openPopup: () => ipcRenderer.send("open-popup"),
});
renderer 渲染进程
<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" />
<script src="./index.js"></script>
<title>Menu</title>
</head>
<body>
<div>菜单Menu</div>
</body>
</html>
window.addEventListener("contextmenu", (e) => {
e.preventDefault();
window.electronAPI.openPopup();
});
讲解
Menu 类可以用来创建原生菜单,它基于主进程
template (MenuItemConstructorOptions | MenuItem)[]返回 Menu 一般来说, template 是一个 options 类型的数组,用于构建 MenuItem。
属性 | 含义 |
---|---|
label | 菜单或菜单项标题 |
click | 菜单项的点击事件 |
type | 菜单类型,有 normal、separator、submenu、checkbox、radio 五种 |
submenu | 定义子菜单 |
role | 预定义的菜单项 |
accelerator | 键盘加速键 |
role 属性
role?: ('undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'pasteAndMatchStyle' | 'delete' | 'selectAll' | 'reload' | 'forceReload' | 'toggleDevTools' | 'resetZoom' | 'zoomIn' | 'zoomOut' | 'toggleSpellChecker' | 'togglefullscreen' | 'window' | 'minimize' | 'close' | 'help' | 'about' | 'services' | 'hide' | 'hideOthers' | 'unhide' | 'quit' | 'startSpeaking' | 'stopSpeaking' | 'zoom' | 'front' | 'appMenu' | 'fileMenu' | 'editMenu' | 'viewMenu' | 'shareMenu' | 'recentDocuments' | 'toggleTabBar' | 'selectNextTab' | 'selectPreviousTab' | 'mergeAllWindows' | 'clearRecentDocuments' | 'moveTabToNewWindow' | 'windowMenu')
这些 role 都是 Electron 的预定义的菜单项,有固定的行为,会自动设置了 label、accelerator、click、submenu 等字段,如果有 role 字段,用户自己设置的 click 会被忽略掉,但是 label 和 accelerator 仍然会起作用。
accelerator 属性
accelerator 用于设置键盘加速键(俗称「快捷键」),它的定义如下:
加速键是一个由多个修饰符和单一键代码通过加号+组合而成的的字符串
setApplicationMenu 来生成窗口菜单
这种上下文菜单,只需要直接调用 Menu 的 popup 方法即可,但是需要注意,popup 事件不能直接调用,需要在渲染进程中事件派发去调用,所以还是需要用到渲染进程通信到主进程去调用 menu.popup 方法,代码:
渲染进程:
// 监听contextmenu事件,触发右键调用通信方法
window.addEventListener("contextmenu", (e) => {
e.preventDefault();
window.electronAPI.openPopup();
});
主进程:
// 监听渲染进程发送消息
ipcMain.on("open-popup", (event) => {
// 调用上下文菜单方法
menu.popup();
});
popup 可传入以下对应参数:
- window:指定窗口(默认是当前聚焦的窗口)
- x:菜单位置横坐标(相对于窗口的 x 轴偏移,默认是鼠标位置的横坐标)
- y:菜单位置纵坐标(相对于窗口的 y 轴偏移,默认是鼠标位置的纵坐标)
- callback:菜单关闭回调函数
系统托盘是个特殊区域,通常在桌面的底部,在那里,用户可以随时访问正在运行中的那些程序。在微软的 Windows 里,系统托盘常指任务栏的状态区域;在 Gnome 的桌面时,常指布告栏区域;在 KDE 桌面时,指系统托盘。在每个系统里,托盘是所有正运行在桌面环境里的应用程序共享的区域。
main 主进程
const { app, BrowserWindow, Menu, Tray } = require("electron");
const path = require("path");
let mainWindow = null;
let tray = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 600,
height: 500,
icon: path.join(__dirname, "../../assets/favicon.ico"),
});
const template = [
{
label: "关于",
role: "about",
},
{
type: "separator",
},
{
label: "菜单2",
click: () => {
console.log("点击了菜单2");
},
},
{
label: "子菜单",
submenu: [
{
label: "显示窗口",
click: () => {
console.log("点击了二级菜单");
},
},
{
label: "打开控制台",
role: "toggleDevTools",
},
],
},
{
type: "separator",
},
{
label: "退出",
role: "quit",
},
];
const menu = Menu.buildFromTemplate(template);
tray = new Tray(path.join(__dirname, "../../assets/favicon.ico"));
tray.setToolTip("鼠标停留提示语");
tray.setContextMenu(menu);
}
app.whenReady().then(() => {
createWindow();
});
讲解:
Electron 提供了 Tray 类,只需要 new 一个 Tray 的实例即可创建托盘:
tray = new Tray(path.join(__dirname, "../../assets/favicon.ico"));
创建 Tray 实例的时候,需要提供一个 image 参数,可以是本地图片的绝对路径,也可以是 NativeImage 的实例。
需要特别注意的是:模板图像必须由黑色和透明通道组成。使用模板图像作为菜单栏图标的好处是,它可以适应浅色和深色菜单栏。在 Windows 系统上则不需要按照这种命名规则了,建议在 Windows 上使用 ico 格式的图片,视觉效果会更好一点。
托盘事件
托盘对象上绑定了很多的事件,例如常见的:
● click:鼠标单击
● right-click:鼠标右击
● double-click:双击
● mouse-down:鼠标按下
● mouse-up:鼠标释放
● mouse-enter:鼠标进入
● mouse-leave:鼠标离开
● mouse-move:鼠标移动
这跟 dom 节点上绑定的事件是一样的,比较常用的就是 click 事件。除此之外,还可以响应将文本或文件放到托盘上事件,即 drag-drop 事件:
● drag-enter
● drag-leave
● drag-end
● drop
● drop-text
● drop-files
这个功能是非常有用的,很多 macOS 应用就是通过将文件放到托盘里面来进行下一步处理的,例如图片压缩等。
每个操作系统都有自己的机制向用户显示通知。 Electron 的通知 API 是跨平台的,但对每个 进程类型来说是不同的,Electron 中的消息通知是基于 H5 的 Notification 来实现的
main 主进程
const { app, BrowserWindow } = require("electron");
const path = require("path");
let mainWindow = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
icon: path.join(__dirname, "../../assets/favicon.ico"),
});
mainWindow.loadFile(
path.join(__dirname, "../../renderer/notification/index.html")
);
}
app.whenReady().then(() => {
createWindow();
});
renderer 渲染进程
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<p>Electron消息通知</p>
<button id="button">点击通知</button>
<script src="./index.js"></script>
</body>
</html>
window.addEventListener("DOMContentLoaded", () => {
var button = document.querySelector("#button");
button.onclick = function () {
var option = {
title: "温馨提示",
body: "electron消息通知!",
icon: "../../assets/favicon.ico",
};
const Notification = new window.Notification(option.title, option);
// 提示添加点击事件
Notification.onclick = function () {
console.log("点击了");
};
};
});
webview 是 chromium 浏览器中的概念,它跟 iframe 是非常类似的,但又不一样:
它们的不同点在于:
案列:
main 主进程
const { app, BrowserWindow, Menu, Tray } = require("electron");
const path = require("path");
let mainWindow = null;
function printFrames() {
const frames = mainWindow.webContents.mainFrame.framesInSubtree;
const print = (frame) => frame && frame.url && path.basename(frame.url);
frames.forEach((it) => {
console.log(`current frame: ${print(it)}`);
console.log(
` children: ${JSON.stringify(it.frames.map((it) => print(it)))}`
);
console.log(` parent`, print(it.parent), "\n");
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 600,
height: 500,
webPreferences: {
webviewTag: true,
},
});
mainWindow.loadFile(
path.join(__dirname, "../../renderer/webview/index.html")
);
setTimeout(printFrames, 2000);
}
app.whenReady().then(() => {
createWindow();
});
renderer 渲染进程
<body>
<iframe src="./embed.html"></iframe>
</body>
讲解:
默认情况下,Electron 是不启用 webview 标签的,需要在创建 Window 的时候在 webPreferences 里面设置 webviewTag 为 true 才行:
mainWindow = new BrowserWindow({
width: 600,
height: 500,
webPreferences: {
webviewTag: true,
},
});
主进程在应用启动后,延迟两秒调用 printFrames 方法打印当前页面所有 frames 信息
function printFrames() {
const frames = win.webContents.mainFrame.framesInSubtree
const print = (frame) => frame && frame.url && path.basename(frame.url)
frames.forEach((it) => {
console.log(`current frame: ${print(it)}`)
console.log(` children: ${JSON.stringify(it.frames.map((it) => print(it)))}`)
console.log(` parent`, print(it.parent), '\n')
})
}
使用 iframe 标签
<body>
<iframe src="./embed.html"></iframe>
</body>
打印结果:
current frame: index.html
children: ["embed.html"]
parent null
current frame: embed.html
children: []
parent index.html
可以看到 embed.html 是 index.html 的子 Frame,index.html 是 embed.html 的父 Frame。
使用 webview 标签
<body>
<webview src="./embed.html"></webview>
</body>
打印结果:
current frame: index.html
children: []
parent null
current frame: embed.html
children: []
parent null
embed.html 和 index.html 不存在父子关系,这两个 Frame 是彼此独立的。
在 Electron 应用中,有三类对话框:
它们都是用 Dialog 类统一管理的
该 API 有同步和异步两个版本:
// 同步版本,返回 string[] | undefined,
// 返回用户选择的文件路径,如果对话框被取消了,则返回 undefined。
dialog.showOpenDialogSync([browserWindow, ]options)
// 返回 promise,会 resolve 一个对象,包含:
// canceled: boolean 对话框是否被取消
// filePaths: string[] 用户选择的文件路径数组
dialog.showOpenDialog([browserWindow, ]options)
这里需要注意的是:第一个参数 browserWindow 对象是可选的,如果传值,那么对话框会以模态框的形式展示,附属在这个指定的 browserWindow 对象上,具体的样式为:
比较重要的是 options 对象,有以下的属性:
title
:对话框窗口标题defaultPath
:默认打开的路径buttonLabel
:「确认」按钮的自定义文案filters
:指定可选的文件类型properties
:对话框相关的属性title
在 macOS 系统上,title
属性是无法设置标题的,但是可以通过 message
属性在输入框上方展示一条消息,类似于 Windows 上的标题效果:
defaultPath
defaultPath
用于指定打开路径,默认情况下会打开上次用户选择的路径,如果应用需要打开特定目录下的文件时,可以使用此参数。
buttonLabel
在 macOS 系统下,默认的文案是「打开」,可以通过 buttonLabel
来修改这个文案,例如:
filters
用于根据后缀对文件类型进行过滤,值是一个数组,数组里面是一个包含 name 和 extension 属性的对象,例如:
filters: [
{ name: '图片', extensions: ['jpg', 'png', 'gif'] },
{ name: '视频', extensions: ['mkv', 'avi', 'mp4'] },
{ name: '自定义文件类型', extensions: ['json'] },
{ name: '任意类型', extensions: ['*'] },
]
main 主进程
const { app, BrowserWindow, dialog, ipcMain } = require("electron");
const path = require("path");
let mainWindow;
const createWindow = () => {
mainWindow = new BrowserWindow({
width: 1200,
height: 1200,
webPreferences: {
nodeIntegration: true,
preload: path.join(__dirname, "../../preload/dialog/index.js"),
sandbox: false,
},
});
mainWindow.loadFile(path.join(__dirname, "../../renderer/dialog/index.html"));
// 关闭窗口弹框确认
mainWindow.on("close", (e) => {
e.preventDefault();
dialog
.showMessageBox(mainWindow, {
type: "warning",
title: "关闭",
message: "是否要关闭窗口",
buttons: ["取消", "确定"],
})
.then((index) => {
if (index.response === 1) {
app.exit();
}
});
});
};
app.whenReady().then(() => {
createWindow();
});
ipcMain.on("open-dialog", (event) => {
dialog
.showSaveDialog(mainWindow, {
title: "ben前端工程师",
buttonLabel: "确定打开",
properties: ["openfile", "multiSelections"],
filters: [
{ name: "图片", extensions: ["jpg", "png", "gif"] },
{ name: "视频", extensions: ["mkv", "avi", "mp4"] },
{ name: "自定义文件类型", extensions: ["json"] },
{ name: "任意类型", extensions: ["*"] },
],
})
.then((results) => {
console.log(results.filePaths);
console.log(results.canceled);
});
});
preload 脚本进程
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
openDialog: () => ipcRenderer.send("open-dialog"),
});
render 渲染进程
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<p>Dialog</p>
<button id="button">打开Dialog</button>
<script src="./index.js"></script>
</body>
</html>
window.addEventListener("DOMContentLoaded", () => {
const btn = document.getElementById("button");
btn.addEventListener("click", () => {
window.electronAPI.openDialog();
});
});
showSaveDialog 的属性和 showOpenDialog 几乎是完全一致的,区别在于 showOpenDialog 是选择已经存在的文件和目录,howSaveDialog 则是用于指定一个文件路径,用于将来保存信息。
showOpenDialog 对话框没有标题栏,而保存对话框是默认有标题栏的,所以 title 属性起作用,默认是「存储」,我们可以改成任意的字符串。
dialog.showMessageBox([browserWindow, ]options)
options 的选项为:
title
:标题message
:内容detail
:额外信息type
:类型icon
:图标buttons
:按钮textWidth
:文本宽度defaultId
:默认选中的按钮索引cancelId
:按 ESC 取消时返回的索引Electron 中可以设置全局快捷键和局部快捷键,全局快捷键就是注册到系统里面的,全局生效的快捷键;而应用快捷键是指使用应用程序时才生效的快捷键,它们的实现逻辑是不一样的,接下来就为大家详细介绍。
Electron 提供 globalShortcut 模块可以用于注册或取消全局快捷键。使用方法为:
globalShortcut.register(accelerator, callback) // 注册单个快捷键
globalShortcut.registerAll(accelerators, callback) // 批量注册快捷键
其中参数的含义为:
accelerator 必须是有效的快捷键字符串
callback 是当注册成功后,用户按下快捷键之后执行的回调函数
有效的快捷键由多个功能键和一个键码中间用加号(+)组合而成,例如:
Ctrl + T
CmdOrCtrl + Shift + Z
常用的功能键和键码如下,设置快捷键的时候可以从功能键里面选择多个,从键码里面选择一个:
功能键 | Cmd、Ctrl、CmdOrCtrl、Alt、Shift、Meta |
---|---|
键码 | 0~9、A~Z、F1~F24、Space、Tab、Backspace、Delete、Enter、Esc 等 |
不过有两点需要注意:
示例代码如下:
const { app, globalShortcut } = require('electron')
app.whenReady().then(() => {
registerGlobalShortcut('Cmd+Alt+K')
})
function registerGlobalShortcut(shortcut) {
if (!shortcut) return false
let flag = false
try {
flag = globalShortcut.isRegistered(shortcut)
if (flag) return true
flag = globalShortcut.register(shortcut, () => {
console.log('toggle shortcut')
})
} catch (e) {
console.error(e)
}
return flag
}
注意 globalShortcut 的 API 需要在 app ready 之后才能调用,否则会直接退出:
Electron.app/Contents/MacOS/Electron exited with signal SIGTRAP
代码中有一行判断快捷键是否被注册的函数:
globalShortcut.isRegistered(accelerator)
然而该方法只能检测当前应用是否注册过这个快捷键,并不能检测到快键键是否被其他应用占用,即使被其他应用注册过了,该方法依然会返回 false,只有当前应用成功注册了这个全局快捷键,才会返回 true。
取消快捷键注册的方法:
globalShortcut.unregister(accelerator) // 取消注册指定快捷键
globalShortcut.unregisterAll() // 取消注册所有快捷键