本文所有源码均在:github.com/Sunny-117/e...
本文收录在《Electron桌面客户端应用程序开发入门到原理》掘金专栏
本文介绍
该章节主要聚焦于 Electron 基础相关的知识:
- Electron 基本介绍
- 进程和线程的概念
- 主进程和渲染进程
- 两者之间的通信
- 渲染进程之间的通信
- 窗口相关的知识
- 基础的窗口知识
- 多窗口的管理
- 应用常见的设置
- 快捷键
- 应用级别快捷键
- 全局快捷键
- 托盘图标
- 剪切板
- 系统通知
- 快捷键
- 系统对话框
- 菜单
- 自定义菜单
- 右键菜单
- 数据持久化方案
- 使用浏览器能力做持久化
- 使用Node.js能力做持久化
- 生命周期
- 预加载脚本和上下文隔离
Electron基本介绍
Electron是一个使用前端技术(HTML、CSS、JS)来开发桌面应用的框架。
什么是桌面应用?
顾名思义,就是需要安装包安装到电脑上的应用程序,常见的桌面应用:QQ、视频播放器、浏览器、VSCode
桌面应用的特点:
- 平台依赖性
- 需要本地安装
- 可以是瘦客户端,也可以是厚客户端
- 所谓的瘦客户端,指的是严重依赖于服务器,离线状态下没办法使用(QQ、浏览器)
- 厚客户端刚好相反,并不严重依赖服务器,离线状态下也可以使用(视频播放器、VSCode)
- 更新和维护:需要用户重新下载和安装新的版本
在早期的时候,要开发一个桌面应用,能够选择的技术框架并不多:
- Qt
- GTK
- wxWidgets
这三个框架都是基于 C/C++ 语言的,因此就要求开发者也需要掌握 C/C++ 语言,对于咱们前端开发人员来讲,早期是无法涉足于桌面应用的开发的。
StackOverflow 联合创始人 Jeff 说:
凡是能够使用 JavaScript 来书写的应用,最终都必将使用 JavaScript 来实现。
使用前端技术开发桌面应用相关的框架实际上有两个:
- NW.js
- Electron
这两个框架都与中国开发者有极深的渊源。
2011 年左右,中国英特尔开源技术中心的王文睿(Roger Wang)希望能用 Node.js 来操作 WebKit,而创建了 node-webkit 项目,这就是 Nw.js 的前身,但当时的目的并不是用来开发桌面 GUI 应用。
中国英特尔开源技术中心大力支持了这个项目,不仅允许王文睿分出一部分精力来做这个开源项目,还给了他招聘名额,允许他招聘其他工程师来一起完成。
NW.js 官网:nwjs.io/
2012 年,故事的另一个主角赵成(Cheng Zhao)加入王文睿的小组,并对 node-webkit 项目做出了大量的改进。
后来赵成离开了中国英特尔开源技术中心,帮助 GitHub 团队尝试把 node-webkit 应用到 Atom 编辑器上,但由于当时 node-webkit 并不稳定,且 node-webkit 项目的走向也不受赵成的控制,这个尝试最终以失败告终。
但赵成和 GitHub 团队并没有放弃,而是着手开发另一个类似 node-webkit 的项目 Atom Shell,这个项目就是 Electron 的前身。赵成在这个项目上倾注了大量的心血,这也是这个项目后来广受欢迎的关键因素之一。再后来 GitHub 把这个项目开源出来,最终更名为 Electron。
Electron 官网:www.electronjs.org/
这两个框架实际上都是基于 Chromium 和 Node.js 的,两个框架的对比如下表所示:
能力 | Electron | NW.js |
---|---|---|
崩溃报告 | 内置 | 无 |
自动更新 | 内置 | 无 |
社区活跃度 | 良好 | 一般 |
周边组件 | 较多,甚至很多官方提供组件 | 一般 |
开发难度 | 一般 | 较低 |
知名应用 | 较多 | 一般 |
维护人员 | 较多 | 一般 |
从上表可以看出,无论是在哪一个方面,Electron 都是优于 NW.js。
Electron 特点
在 Electron 的内部,集成了两大部件:
- Chromium:为 Electron 提供了强大的 UI 能力,可以在不考虑兼容的情况下,利用 Web 的生态来开发桌面应用的界面。
- Node.js:让 Electron 有了底层的操作能力,比如文件读写,集成 C++,而且还可以使用大量开源的 npm 包来辅助开发。
而且 Chromium 和 Node.js 都是跨平台的,这意味着我们使用 Electron 所开发的应用也能够很轻松的解决跨平台的问题。
搭建 Electron 项目
首先创建一个新的目录,例如 client,然后使用 npm init -y 进行一个初始化。
接下来需要安装 Electron 依赖:
js
npm install --save-dev electron
之后分别创建 index.html 和 index.js 文件:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- 书写桌面程序界面的 -->
<h1>Hello Electron</h1>
<p>Hello from Electron!!!</p>
</body>
</html>
index.html 负责的是我们桌面应用的视图。
js
// index.js
const { app, BrowserWindow } = require("electron");
// 创建窗口的方法
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
});
win.loadFile("index.html");
};
// whenReady是一个生命周期方法,会在 Electron 完成应用初始化后调用
// 返回一个 promise
app.whenReady().then(() => {
createWindow();
});
该文件就是我们桌面应用的入口文件。
最后需要在 package.json 中添加执行命令:
js
"scripts": {
"start": "electron ."
},
最后通过 npm start 就可以启动了。
关于 meta 标签安全策略
html
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<meta>
标签:这是一个 HTML 元素,用于提供关于 HTML 文档的元数据。在这个例子中,它被用来定义内容安全策略。http-equiv="Content-Security-Policy"
:这个属性表示<meta>
标签定义了一个等同于 HTTP 响应头的内容。在这里,它指定了内容安全策略的类型。content="default-src 'self'; script-src 'self'"
:这部分定义了具体的策略内容:default-src 'self'
:这意味着对于所有的加载资源(如脚本、图片、样式表等),默认只允许从当前源(即同一个域)加载。这是一个安全措施,用于防止跨站点脚本(XSS)攻击,因为它不允许从外部或不受信任的来源加载内容。script-src 'self'
:这是一个特定的指令,仅适用于 JavaScript 脚本。它进一步限定脚本只能从当前源加载。这个指令实际上是冗余的,因为default-src 'self'
已经设定了同样的策略,但它可以被用来重写default-src
对于特定资源类型的默认设置。
总结来说,这段代码的目的是增加网页的安全性,通过限制只能从当前网站加载资源,以此来防止潜在的跨站脚本攻击。这是一个在现代 web 开发中常用的安全最佳实践。
进程与线程
什么是进程 ?
假设我们的电脑看作是一个工厂,电脑上面是可以运行各种应用程序的(浏览器、Word、音乐播放器、视频播放器....)每一个应用程序都可以看作是一个独立的工作区域,这个独立的工作区域就是我们的进程。
每个进程都会有独立的内存空间和系统资源,每个进程之间是独立的,这意味着假设有一个进程崩了,那么不会影响其他的进程。
什么又是线程 ?
刚才我们将进程比做工厂里面一个独立的工作区域,那么每个工作区域都有员工的,一个独立的工作区域是可以有多个员工的,类似的,一个进程也可以有多个线程,线程之间进行协同工作,共享相同的数据和资源。线程是操作系统所能够调度的最小单位。
如下图所示:
同样都是线程,其中的一个线程能够创建其他的 6 个线程,并且有决定这些线程能够做什么的能力,那么这个线程就被称之为主线程。
在一个进程中所拥有的所有的资源,所有的线程都有权利去使用,这个就叫做"进程资源共享"。
理论上来讲,一个应用会对应一个进程,但是这并不是绝对的。一些大型的应用,在进行架构设计的时候,会设计为多进程应用。比较典型的就是 Chrome 浏览器。在 Chrome 浏览器中,一个标签页会对应一个进程,当前还有很多除了标签页以外的一些其他的进程。这样做的好处在于一个标签页崩溃后,不会影响其他的标签页。
这样的应用我们就称之为"多进程应用",如下图所示:
和前面所提到的主线程类似,如果一个应用是多进程应用,那么也会有一个"主进程",起到一个协调和管理其他子进程的作用。
例如,在 Node.js 里面,我们可以通过 child_process 这个模块来创建一个子进程,那么在这种情况下,启动这些子进程的 Node.js 应用实例就会被看作是主进程,child_process 就是子进程。主进程负责管理这些子进程,比如分配任务,处理通信和同步数据之类的。
回到 Electron 桌面应用,当我们启动一个 Electron 桌面应用的时候,该应用对应的也是一个多进程应用,如下图所示:
这里面 Electron 是主进程,对应的就是我们应用入口文件的 index.js,该主进程负责的任务有:
- 管理整个 Electron 应用程序的生命周期
- 访问文件系统以及获取操作系统的各种资源
- 处理操作系统发出的各种事件
- 创建并管理菜单栏
- 创建并管理应用程序窗口
Electron Helper(Renderer)该进程就是我们窗口所对应的渲染进程。
假设在任务管理器将该进程关闭掉,我们会发现窗口不再渲染任何的东西,但是应用还存在,窗口也还存在。
这里就需要说一下,实际上在 Electron 应用中,有一个窗口进程,由窗口进程来创建的窗口,之后才是渲染进程来渲染的页面。这也是为什么我们关闭了渲染进程,但是窗口还存在的原因。
假设我们创建了多个窗口,那么会有多个窗口进程么?
多个窗口下仍然只有一个窗口进程,由这个窗口进程负责绘制多个窗口,不同的窗口里面会有不同的渲染进程来渲染页面。
如下图所示:
最后再明确一个点,一个窗口只能对应一个渲染进程么 ?
其实也不是,哪怕我是在一个窗口里面,我也是可以有多个渲染进程的。如何做到?通过 webview 加载其他的页面,当你使用 webview 的时候,也会对应一个渲染进程。
主进程和渲染进程通信
在多进程的应用中,进程之间的通信是必不可少的。
进程间通信,英语叫做 interprocess communication,简称叫做 IPC。这个 IPC 进程通信机制是由操作系统所提供的一种机制,允许应用中不同的进程之间进行一个交流。
在 Electron 中,我们需要关注两类进程间的通信:
- 主进程和渲染进程之间的通信
- 渲染进程彼此之间的通信
在 Electron 中,已经为我们提供了对应的模块 ipcMain 和 ipcRenderer 来实现这两类进程之间的通信。
ipcMain模块
- ipcMain.on(channel, listener)
- 这个很明显是一个监听事件,on 方法监听 channel 频道所触发的事件
- listener 是一个回调函数,当监听的频道有新消息抵达时,会执行该回调函数
- listener(event, args...)
- event 是一个事件对象
- args 是一个参数列表
- listener(event, args...)
- ipcMain.once(channel, listener):和上面 on 的区别在于 once 只会监听一次
- ipcMain.removeListener(channel, listener):移除 on 方法所绑定的事件监听。
具体可以参阅:www.electronjs.org/docs/latest...
ipcRenderer模块
基本上和上面的主进程非常的相似。
-
ipcRenderer.on(channel, listener)
- 和上面主进程的 on 方法用法一样
-
ipcRenderer.send(channel, ...args)
- 此方法用于向主进程对应的 channel 频道发送消息。
- 注意 send 方法传递的内容是被序列化了的,所以并非所有数据类型都支持
这两个模块实际上是基于 Node.js 里面 EventEmitter 模块实现的。例如:
js
// index.js
const event = require('./event');
// 触发事件
event.emit("some_event");
js
// event.js
const EventEmitter = require("events").EventEmitter;
const event = new EventEmitter();
// 监听自定义事件
event.on("some_event", () => {
console.log("事件已触发");
});
module.exports = event;
使用消息端口通信
MessageChannel
文档地址:developer.mozilla.org/en-US/docs/...
MessageChannel 是一个浏览器所支持的 Web API,它允许我们创建一个消息通道,并通过它的两个 MessagePort 属性发送数据。每个 MessageChannel 实例都有两个端口:port1 和 port2,这使得它们可以相互通信,就像是一个**双向通信**的管道。
双向通信和单向通信是通信系统中的两种基本通信模式。
- 双向通信
双向通信,顾名思义,是**信息可以在两个方向上流动的通信方式**。这意味着参与**通信的双方既可以发送信息,也可以接收信息**。
生活中也有很多双向通信的例子:
- 两个人在进行面对面的对话。每个人都可以说话(发送信息)也可以听对方说话(接收信息)。这种通信方式允许实时的互动和反馈
- 使用即时通讯软件聊天,双方都可以发送和接收消息。
常见的双向通信的实现:
电话通话:两个人可以同时进行听和说的活动。
网络聊天应用(如WhatsApp, WeChat):用户可以发送消息并接收对方的回复。
WebSocket 协议:在 Web 开发中,WebSocket 提供了一个全双工通信渠道,允许数据在客户端和服务器之间双向流动。
- 单向通信
单向通信是指**信息只能在一个方向上流动的通信方式**。这意味着**通信的一方仅能发送信息,而另一方仅能接收信息**,反向的信息流动是不可能的。
生活中也存在单向通信的例子:
收听广播或看电视。广播站或电视台(发送方)向外播出节目,而听众或观众(接收方)只能接收内容,不能通过这个渠道回应。在这种情况下,信息的流动是单向的。
常见的单项通信的实现:
- 广播系统:如无线电广播,只能传输信息,收听者不能通过广播回传信息。
- 通知系统:比如网站的推送通知功能,服务器可以向客户端发送通知,但客户端不能通过这些通知回复服务器。
- RSS Feeds:允许用户订阅来自网站的更新,但用户不能通过 RSS 向网站发送信息。
这个功能特别适合于需要从**一个上下文(比如主页面)与另一个上下文(例如 Web Worker 或者 iframe)安全地通信的情况**。也就是说,进行跨上下文进行通信。
Electron中的消息端口
在 Electron 中,涉及到一个主进程、一个渲染进程。如果是在渲染进程中,那么我们是可以正常使用 MessageChnnel 的。
但是如果换做是在主进程中,是**不存在 MessageChannel 类的**,因为这其实是一个 Web API,主进程不是网页,它没有 Blink 的集成,因此自然是不能使用的。
不过,Electorn 中针对该情况,为主进程新增了一个 MessageChannelMain 类,该类的行为就类似于 MessageChannel。
窗口
几乎所有包含图形界面的操作系统都是以窗口为基础构建各自的用户界面的。系统内小到一个计算器,大到一个复杂的业务系统,都是基于窗口而创建的。如果开发人员要开发一个有良好用户体验的 GUI 应用,势必会在窗口的控制上下足功夫。
Electron 中的窗口由 BrowserWindow 对象来创建,可以配置的属性多达几十个,这里我们将介绍一些比较常用的属性,以及一些比较常见的需求。
主要包含以下内容:
- 窗口相关配置
- 组合窗口
- 窗口的层级
窗口相关配置
这一块儿基本上都是传递给 BrowserWindow 的配置项。
基础属性
- maxWidth:设置窗口的最大宽度
- minWidth:设置窗口的最小宽度
- maxHeight:设置窗口的最大高度
- minHeight:设置窗口的最小高度
- resizeable:是否可以改变大小,当设置 resizeable 为 false 之后,代表不可缩放,前面所设置的 maxWidth ... 这些就没有意义了
- moveable:是否可以移动
窗口位置
默认窗口出现在屏幕的位置是在正中间,但是我们可以通过 x、y 属性来控制窗口出现在屏幕的位置
- x:控制窗口在屏幕的横向坐标
- y:控制窗口在屏幕的纵向坐标
标题栏文本和图标
关于窗口的标题栏,实际上是可以在多个地方设置的。
既然可以在多个地方进行设置,那么这里自然会涉及到一个优先级的问题。优先级从高到低依次:
- HTML文档的 title
- BrowserWindow 里面的 title 属性
- package.json 里面的 name
- Electron 默认值:Electron
除了标题栏文本,我们还可以设置对应的图标:
- icon:设置标题栏的图标,一般来讲是 ico 格式
js
// 创建窗口方法
const createWindow = () => {
const win = new BrowserWindow({
// ...
icon: path.join(__dirname, "logo.ico")
});
win.loadFile("window/index.html");
};
标题栏、菜单栏和边框
默认我们所创建的窗口,是有标题栏、菜单栏以及边框的,不过这个也是能够控制的。通过 frame 配置项来决定是否要显示。
- frame:true/false 默认值是 true
组合窗口
桌面应用有些时候是有多个窗口的,多个窗口彼此之间是相互独立,也就是说,假设我关闭了一个窗口,对另外一个窗口是没有影响的。
但是在有一些场景中,多个窗口之间存在一定程度的联动,例如两个窗口存在父窗口和子窗口之间的关系,父窗口关闭之后,子窗口也一并被关闭掉了。
在 Electron 中,类似这样的需求可以非常简单的被实现,Electron 提供了父子窗口的概念,通过 parent 来指定一个窗口的父窗口。
当窗口之间形成了父子关系之后,两个窗口在行为上就会有一定的联系:
- 子窗口可以相对于父窗口的位置来定位
- 父窗口在移动的时候,子窗口也跟着移动
- 父窗口关闭了,子窗口也应该一并被关闭掉
- .....
窗口的层级
当我们创建多个窗口的时候,默认情况下最后面创建的窗口,就在越上层。但是如果两个窗口是独立的话,那么当用户点击对应的窗口的时候,被点击的窗口会处于最上层。
但是在某些场景下,我们就是需要置顶某一些窗口,有两种方式可以办到:
- alwaysOnTop:true/false
- 该配置属性虽然也能够置顶窗口,但是没有办法进行更新细粒度的设置
- window.setAlwaysOnTop(flag, level, relativeLevel):该方法可以进行一个更细粒度的控制
- flag:一个布尔值,用于设置窗口是否始终位于顶部。如果为 true,窗口将始终保持在最前面;如果为 false,则取消这一设置
- level(可选):一个字符串,指定窗口相对于其他窗口的层次。常用的值包括 'normal', 'floating', 'torn-off-menu', 'modal-panel', 'main-menu', 'status', 'pop-up-menu', 'screen-saver' 等。这个参数在不同的操作系统上可能会有不同的行为。
- relativeLevel(可选):一个整数,用于在设置了 level 的情况下进一步微调窗口层次。
多窗口管理
实际上要对多窗口进行管理,原理是非常简单的,主要就是将所有的窗口的引用存储到一个 map 里面,之后要对哪一个窗口进行操作,直接从 map 里面取出对应的窗口引用即可。
js
// 该 map 结构存储所有的窗口引用
const winMap = new Map();
// ...
// 往 map 里面放入对应的窗口引用
winMap.set(config.name, win);
很多时候,我们的窗口不仅是多个,还需要对这多个窗口进行一个分组。这个时候简单的更改一下 map 的结构即可。
首先在窗口配置方面,新增一个 group 属性,表明该窗口是哪一个分组的。
js
const win1Config = {
name: "win1",
width: 600,
height: 400,
show: true,
group: "grounp1" // 新增一个 grounp 的属性
file: "window/index.html",
};
第二步,在创建了窗口之后,从 map 里面获取对应分组的数组,这里又分为两种情况:
- 该分组名下的数组存在:直接将该窗口引入放入到该分组的数组里面
- 该分组还不存在:创建新的数组,并且将该窗口引用放入到新分组里面
核心代码如下:
js
if (config.group) {
// 根据你的分组名,先找到对应的窗口数组
let groupArr = winMap.get(config.group);
if (groupArr) {
// 如果数组存在,直接 push 进去
groupArr.push(win);
} else {
// 新创建一个数组,作为该分组的第一个窗口
groupArr = [win];
}
// 接下来更新 map
winMap.set(config.group, groupArr);
}
之后,在窗口进行关闭操作时,还需要将关闭的窗口实例从 map 结构中移除掉。
js
// 接下来还需要监听窗口的关闭事件,以便在窗口关闭时将其从 map 结构中移除
win.on("close", () => {
groupArr = winMap.get(config.group);
// 因为当前的窗口已经关闭,所以我们需要将其从数组中移除
groupArr = groupArr.filter((item) => item !== win);
// 接下来更新 map
winMap.set(config.group, groupArr);
// 如果该分组下已经没有窗口了,我们需要将其从 map 结构中移除
if (groupArr.length === 0) {
winMap.delete(config.group);
}
});
应用常见设置
- 快捷键
- 托盘图标
- 剪切板
- 系统通知
快捷键
在 Electron 中,页面级别的快捷键直接使用 DOM 技术就可以实现。
例如,我们在渲染进程对应的 JS 中书写如下的代码:
js
// 设置一个页面级别的快捷键
window.onkeydown = function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === "q") {
// 用户按的键是 ctrl + q
// 我们可以执行对应的快捷键操作
console.log("您按下了 ctrl + q 键");
}
};
有些时候我们还有注册全局快捷键的需求。所谓全局快捷键,指的是操作系统级别的快捷键,也就是说,即便当前的应用并非处于焦点状态,这些快捷键也能够触发相应的动作。
在 Electron 中想要注册一个全局的快捷键,可以通过 globalShortcut 模块来实现。
例如���
js
const { globalShortcut, app, dialog } = require("electron");
app.on("ready", () => {
// 需要注意,在注册全局快捷键的时候,需要在 app 模块的 ready 事件触发之后
// 使用 globalShortcut.register 方法注册之后会有一个返回值
// 这个返回值是一个布尔值,如果为 true 则表示注册成功,否则表示注册失败
const ret = globalShortcut.register("ctrl+e", () => {
dialog.showMessageBox({
message: "全局快捷键 ctrl+e 被触发了",
buttons: ["好的"],
});
});
if (!ret) {
console.log("注册失败");
}
console.log(
globalShortcut.isRegistered("ctrl+e")
? "全局快捷键注册成功"
: "全局快捷键注册失败"
);
});
// 当我们注册了全局快捷键之后,当应用程序退出的时候,也需要注销这个快捷键
app.on("will-quit", function () {
globalShortcut.unregister("ctrl+e");
globalShortcut.unregisterAll();
});
几个核心的点:
- 需要在应用 ready 之后才能注册全局快捷键
- 使用 globalShortcut.register 来进行注册
- 通过 globalShortcut.isRegistered 可以检查某个全局快捷键是否已经被注册
- 当应用退出的时候,需要注销所注册的全局快捷键,使用 globalShortcut.unregister 进行注销
托盘图标
有些时候,我们需要将应用的图标显示在托盘上面,当应用最小化的时候,能够通过点击图标来让应用显示出来。
例如 Mac 下面:
在 Electron 里面为我们提供了 Tray 这个模块来配置托盘图标。
例如:
js
function createTray() {
// 构建托盘图标的路径
const iconPath = path.join(__dirname, "assets/tray.jpg");
tray = new Tray(iconPath);
// 我们的图标需要有一定的功能
tray.on("click", function () {
win.isVisible() ? win.hide() : win.show();
});
// 还可以设置托盘图标对应的菜单
const contextMenu = Menu.buildFromTemplate([
{
label: "显示/隐藏",
click: () => {
win.isVisible() ? win.hide() : win.show();
},
},
{
label: "退出",
click: () => {
app.quit();
},
},
]);
tray.setContextMenu(contextMenu);
}
在上面的代码中,我们就创建了一个托盘图标。几个比较核心的点:
- 图片要选择合适,特别是大小,一般在 20x20 左右
- 创建 tray 实例对象,之后托盘图标就有了
- 之后就可以为托盘图标设置对应的功能
- 点击功能
- 菜单目录
剪切板
这个在 Electron 里面也是提供了相应的模块,有一个 clipboard 的模块专门用于实现剪切板的相关功能。
大致的使用方式如下:
js
const { clipboard } = require('electron');
clipboard.writeText("你好"); // 向剪切板写入文本
clipboard.writeHTML("<b>你好HTML</b>"); // 向剪切板写入 HTML
系统通知
这也是一个非常常见的需求,有些时候,我们需要给用户发送系统通知。
在 Electron 中,可以让系统发送相应的应用通知,不过这个和 Electron 没有太大的关系,这是通过 HTML5 里面的 notification API 来实现的。
例如:
js
const notifyBtn = document.getElementById("notifyBtn");
notifyBtn.addEventListener("click", function () {
const option = {
title: "您有一条新的消息,请及时查看",
body: "这是一条测试消息,技术支持来源于 HTML5 的 notificationAPI",
};
const myNotify = new Notification(option.title, option);
myNotify.onclick = function () {
console.log("用户点击了通知");
};
});
核心的点有:
- 使用 HTML5 所提供的 Notification 来创建系统通知
- new Notification 之后能够拿到一个返回值,针对该返回值可以绑定一个点击事件,该点击事件会在用户点击了通知消息后触发
系统对话框与菜单
每个桌面应用都或多或少的要与系统 API 打交道。比如显示系统通知、在系统托盘区显示一个图标、通过"打开文件对话框"打开系统内一个指定的文件、通过"保存文件对话框"把数据保存到系统磁盘上面等。
早期的 Electron 对这方面支持不足,但随着使用者越来越多,用户需求也越来越多且各不相同,Electron 在这方面的支持力度也越来越强。
这节课我们来看两个方面:
- 系统对话框
- 菜单
系统对话框
在 Electron 中,可以使用一个 dialog 的模块来实现打开系统对话框的功能。
ipcRenderer.invoke 和 ipcMain.handle 可以算作是一组方法,这一组方法主要就是处理异步调用。
举一个例子,如下:
js
// 主进程
const { ipcMain } = require("electron");
ipcMain.handle('get-data', async (event, ...args) => {
// 这里就可以执行一些异步的操作,比如读取文件、查询数据库等
// args 是参数列表,是从渲染进程那边传递过来的
const data = ...; // 从一些异步操作中拿到数据
return data;
})
js
// 渲染进程
const { ipcRenderer } = require("electron");
async function fetchData(){
try{
const data = await ipcRenderer.invoke('get-data', /* 后面可以传递额外的参数 */);
// 后面就可以在拿到这个 data 之后做其他的操作
} catch(e){
console.error(e);
}
}
fetchData();
接下来我们在 handle 的异步回调函数中,用到了 BrowserWindow.getFocusedWindow 方法,该方法用于获取当前聚焦的窗口,或者换一句话说,就是获取用户当前正在交互的 Electron 窗口的引用。
如果当前没有窗口获取焦点,那么会返回 null。
使用场景
这个方法在需要对当前用户正与之交互的窗口执行操作时非常有用。比如:
- 在当前获得焦点的窗口中打开一个对话框。
- 调整或查询当前活跃窗口的大小、位置等属性。
- 对当前用户正在使用的窗口应用特定的逻辑或视觉效果。
菜单
自定义菜单
在使用 Electron 开发桌面应用的时候,Electron 为我们提供了默认的菜单,但是这个菜单仅仅是用于演示而已。
我们可以自定义我们应用的菜单。
在 Electron 中,想要自定义菜单,可以使用 Menu 这个模块。代码如下:
js
// 做我们的自定义菜单
const { Menu } = require("electron");
// 定义我们的自定义菜单
const menuArr = [
{
label: "",
},
{
label: "菜单1",
submenu: [
{
label: "菜单1-1",
},
{
label: "菜单1-2",
click() {
// 该菜单项目被点击后要执行的逻辑
console.log("你点击了菜单1-2");
},
},
],
},
{
label: "菜单2",
submenu: [
{
label: "菜单2-1",
},
{
label: "菜单2-2",
click() {
// 该菜单项目被点击后要执行的逻辑
console.log("你点击了菜单2-2");
},
},
],
},
{
label: "菜单3",
submenu: [
{
label: "菜单3-1",
},
{
label: "菜单3-2",
click() {
// 该菜单项目被点击后要执行的逻辑
console.log("你点击了菜单3-2");
},
},
],
},
];
// 创建菜单
const menu = Menu.buildFromTemplate(menuArr);
// 设置菜单,让我们的自定义菜单生效
Menu.setApplicationMenu(menu);
核心的步骤就是:
- 自定义菜单数组
- 创建菜单:Menu.buildFromTemplate 方法
- 设置菜单:Menu.setApplicationMenu
现在我们遇到了一个问题:无法打开开发者工具了,这给我们调试代码带来了很大的不便,我们需要解决这个问题。之所以无法打开,是因为 Electron 默认菜单中,包含了一些基本的功能,其中就有打开开发者工具的快捷方式,但是一旦我们自定义了菜单,这些默认项目就不存在了,默认的功能也就没了。
要解决这个问题,我们只需要在菜单模板中添加一个专门用于打开开发者工具的项目,以及设置快捷键:
js
{
label: "开发者工具",
submenu: [
{
label: "切换开发者工具",
accelerator:
process.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I",
click(_, focusedWindow) {
if (focusedWindow) focusedWindow.toggleDevTools();
},
},
],
}
在设置菜单的时候,我们是可以为菜单设置一个 role 属性。
js
{ label: '菜单3-1', role: "paste" }
role 是菜单项中一个特殊的属性,用于指定一些常见的操作和行为。例如常见的复制、粘贴、剪切等。
当你设置了 role 属性之后,Electron 会自动实现对应的功能,你就不需要在编写额外的代码。
使用 role 的好处:
- 简化开发
- 一致性
- 自动的状态管理:例如当剪贴板为空的时候,粘贴的操作会自动处于禁用状态
右键菜单
这个也是一个非常常见的需求,我们需要在页面上点击鼠标右键的时候,显示右键菜单。
这个功能非常简单,只需要在对应渲染进程对应的窗口上编写 HTML、CSS 和 JS 相应的逻辑即可。
核心的 JS 代码逻辑如下:
js
const { ipcRenderer } = require("electron");
const btn = document.getElementById("btn");
btn.addEventListener("click", async function () {
// 我们需要弹出一个对话框
const result = await ipcRenderer.invoke("show-open-dialog");
console.log(result);
});
const menu = document.getElementById("menu");
// 点击右键时对应的事件
window.oncontextmenu = function (e) {
e.preventDefault();
menu.style.left = e.clientX + "px";
menu.style.top = e.clientY + "px";
menu.style.display = "block";
};
// 用户点击右键菜单上面的某一项的时候
// 注意下面的查询 DOM 的方式只会获取到第一个匹配的元素
// 因此右键菜单上面的功能只会绑定到第一个菜单项上面
document.querySelector(".menu").onclick = function () {
console.log("这是右键菜单上面的某一个功能");
};
// 当用户点击窗口的其他地方的时候,右键菜单应该消失
window.onclick = function () {
menu.style.display = "none";
};
主要就是针对 3 个方面绑定事件:
- 右键点击的时候
- 右键点击后,出现的菜单上面的项目需要绑定对应的事件
- 点击窗口其他位置的时候,右键菜单要消失
进度条
在 Electron 中实现进度条功能是一种增强用户体验的有效方法,特别是在处理需要等待的任务(如文件上传或下载)时。
进度条使窗口能够向用户提供其进度信息,而无需被切换到前台。
在Windows环境下,进度条被显示在任务栏按钮上。
在MacOS环境下,进度条将被显示在dock栏图标上
在Linux系统中,Unity桌面也有相似的特性,能在Launcher上显示进度条。
在这三种环境中,进度条功能都是通过同一个 API 来实现的:
- BrowserWindow 实例对象的 setProgressBar 方法:该方法介于 0 和 1 之间的小数来表示进度。
- 例如如果有一个耗时很长的任务,它当前的进度是 63%,那么可以用 setProgressBar(0.63) 来显示这一进度。
- 将参数设置为负值 (例如, -1) 将删除 progress bar。
- 设定值大于 1 在 Windows 中将表示一个不确定的进度条 ,或在其他操作系统中显示为 100%
- 所谓不确定的进度条,指的是你也不知道这个操作需要多长时间才能完成。
数据持久化
由于 Electron 本身体质特殊,是 Chromium 和 Node.js 的结合体,因此在实现数据持久化的时候,可以从两个方面入手:
- 基于 Node.js 的本地文件持久化
- 基于浏览器技术的持久化
设置存储目录
首先第一步我们需要设置用户数据存储目录。注意,这个目录一般不会设置在安装目录下面,因为安装目录不可靠,随时面临被清空重置的可能。
一般来讲,操作系统都会有一个默认的目录,来为应用程序存储对应的用户个性化数据。
不同的操作系统里面,对应的这个默认目录地址是不一样的。
Windows
- 用户数据目录:在 Windows 操作系统中,应用程序通常在以下路径之一存储用户数据:
C:\Users\[用户名]\AppData\Roaming\
:用于存储漫游数据,即用户在不同计算机上使用相同用户账户时可以访问的数据。C:\Users\[用户名]\AppData\Local\
:用于存储特定于单个计算机的数据。
- 程序数据目录:对于所有用户共享的应用数据,通常存储在
C:\ProgramData\
目录。
macOS
- 用户数据目录: 在macOS中,应用程序的用户数据通常存储在用户的库目录(Library)中,路径为:
/Users/[用户名]/Library/
:这个目录用于存储应用程序的配置文件、缓存和其他用户特定的数据。
- 应用支持目录:特别是,许多应用程序将数据存储在
/Users/[用户名]/Library/Application Support/
目录。
Linux
- 用户数据目录:在 Linux 系统中,用户个性化数据通常存储在用户的主目录下的隐藏文件夹中。这些目录的名称通常以点( . )开始,例如:
/home/[用户名]/.[应用程序名称]/
:用于存储该应用程序的个性化设置和数据。 - 配置文件:一些通用的配置文件可能存储在
/home/[用户名]/.config/
目录中。
在 Electron 中可以通过 app.getPath("userData") 来获取到默认的用户数据目录。
另外,在 app.getPath 方法中传入不同的参数,能够获取到不同用途的路径:
"home"
: 用户的主目录。"appData"
: 当前用户的应用程序目录。"userData"
: 对应用户个性化数据的目录。"temp"
: 临时文件目录。"exe"
: 当前的可执行文件目录。"desktop"
: 用户的桌面目录。"documents"
: 用户的文档目录。"downloads"
: 用户的下载目录。"music"
: 用户的音乐目录。"pictures"
: 用户的图片目录。"videos"
: 用户的视频目录。
例如:
js
console.log(app.getPath("userData")); // /Users/jie/Library/Application Support/demo
console.log(app.getPath("home")); // /Users/jie
console.log(app.getPath("desktop")); // /Users/jie/Desktop
console.log(app.getPath("documents")); // /Users/jie/Documents
console.log(app.getPath("downloads")); // /Users/jie/Downloads
console.log(app.getPath("music")); // /Users/jie/Music
有些时候,为了提升用户的体验,允许用户自己来选择将应用的用户数据存储到哪个位置,通过:
js
app.setPath('appData', 'path');
- appData: 要重置的路径的名称
- path:具体的路径
读写本地文件
关于读写本地文件这一块儿,就是利用 Node.js 里面和文件处理相关的 fs 模块的 API。
这里再推荐一个第三方的库:fs-extra,这个库在原本的 fs 模块的基础上又做了一层封装,添加了很多更好用的 API。
原生 fs 模块,在删除一个目录的时候,如果该目录下面有子目录,子目录下面又有子目录,那么该目录是无法删除,要删除一个目录的前提就是该目录必须是空的,因此在原生 fs 模块里面,就会涉及到递归操作
js
const fs = require('fs');
const path = require('path');
function deleteDirectory(directoryPath) {
if (fs.existsSync(directoryPath)) {
fs.readdirSync(directoryPath).forEach((file) => {
const currentPath = path.join(directoryPath, file);
if (fs.lstatSync(currentPath).isDirectory()) {
// 递归删除子目录
deleteDirectory(currentPath);
} else {
// 删除文件
fs.unlinkSync(currentPath);
}
});
// 删除目录
fs.rmdirSync(directoryPath);
console.log(`Directory ${directoryPath} has been removed successfully.`);
} else {
console.log('Directory not found.');
}
}
// 定义要删除的目录路径
const dirPath = './path/to/your/directory';
try {
deleteDirectory(dirPath);
} catch (err) {
console.error(`Error occurred while removing directory: ${err.message}`);
}
fs-extra
js
const fs = require('fs-extra');
// 定义要删除的目录路径
const directoryPath = './path/to/your/directory';
try {
// 删除目录及其所有内容
fs.removeSync(directoryPath);
console.log(`Directory ${directoryPath} has been removed successfully.`);
} catch (err) {
console.error(`Error occurred while removing directory: ${err.message}`);
}
示例二
原生 fs 模块在创建目录之前需要先判断该目录是否存在,只有在不存在的情况下,才能够创建
原生 fs 模块相关代码:
js
const fs = require('fs');
const path = require('path');
const dirPath = path.join(__dirname, 'exampleDir');
// 检查目录是否存在
if (!fs.existsSync(dirPath)) {
// 如果目录不存在,则创建它
fs.mkdirSync(dirPath, { recursive: true });
console.log('Directory created successfully!');
} else {
console.log('Directory already exists.');
}
fs-extra 模块提供了 ensureDir 方法:
js
const fs = require('fs-extra');
const path = require('path');
const dirPath = path.join(__dirname, 'exampleDir');
// 如果已经存在,代码将继续执行而不会发生错误。
// 如果目录不存在,则创建它
fs.ensureDir(dirPath)
.then(() => console.log('Directory ensured successfully!'))
.catch(err => console.error(err));
关于 fs-extra 更多内容可以参阅:github.com/jprichardso...
第三方库
有些时候,我们要存储的数据就是一个简单 JSON 数据,那么这个时候我们可以选择 electron-store 来进行存储。
electron-store 是专为 Electron 设计,并且专门用于存储 JSON 这种轻量级数据。
我们在使用这个第三方库的时候,引入即用,都不需要指定文件的路径和文件名。
常用的方法如下:
引入和实例化
在 Electron 的主进程或渲染进程中,引入并实例化 electron-store
:
js
const Store = require('electron-store');
const store = new Store();
存储数据
使用 set
方法存储数据。您可以存储字符串、数字、对象等类型的数据:
js
// 存储一个简单的值
store.set('unicorn', '🦄');
// 存储一个对象
store.set('user', {
name: 'Alice',
age: 25
});
读取数据
使用 get
方法读取数据,如果指定的键不存在,可以返回一个默认值:
js
// 读取数据
console.log(store.get('unicorn')); // 输出:🦄
// 读取对象的属性
console.log(store.get('user.name')); // 输出:Alice
// 读取不存在的键,返回默认值
console.log(store.get('foo', '默认值')); // 输出:默认值
检查键是否存在
使用 has
方法检查存储中是否存在某个键:
js
if (store.has('unicorn')) {
console.log('Unicorn exists');
}
删除数据
使用 delete
方法删除存储中的数据:
js
// 删除一个键
store.delete('unicorn');
配置和选项
在实例化 electron-store
时,您可以传递一些选项来自定义其行为:
js
const store = new Store({
name: 'my-data', // 自定义存储文件的名称,默认是 'config'
encryptionKey: 'aes-256-cbc', // 加密存储的数据
cwd: 'some/path', // 自定义存储文件的路径
fileExtension: 'json' // 文件扩展名,默认是 'json'
});
另外,electron-store 默认是将数据存储到对应操作系统的 userData 目录下面。
- macOS:
~/Library/Application Support/YourApp
- Windows:
C:\Users\YourName\AppData\Local\YourApp
更多关于 electron-store 的使用,可以参阅:github.com/sindresorhu...
浏览器技术持久化
- localStorage
- IndexedDB
- Dexie.js
localStorage
在 Electron 中,如果你打开了多个 BrowserWindow 的实例,那么它们默认情况下会共享同一个 localStorage 空间。
另外,关于多个窗口是否共享 localStorage 这一点,虽然默认是多窗口共享,但是是可以进行配置的。
例如,在创建窗口二的时候,可以添加如下的配置:
js
const secondWin = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
partition: "persist:myCustomPartition",
},
});
- partition:用于定义该窗口数据存储的独立性和持久性
- persist:这是一个前缀,该前缀表明这是一个持久性的会话
- myCustomPartition:这个标识符代表该会话的唯一名称。这里就是通过不同的 partition 名称,给应用中的不同窗口创建了隔离的存储空间。
IndexedDB
IndexedDB 是一种低级的 API,用于在用户的浏览器中存储大量的结构化数据。这个 API 使用索引来实现对数据的高性能搜索。它允许你创建、读取、导航和写入客户端数据库中的数据。IndexedDB 对于需要在客户端存储大量数据或无需持续联网的应用程序特别有用。
基础介绍
- 数据库:IndexedDB 创建的是一个数据库,你可以在其中存储键值对。
- 对象仓库:这是数据库中的一个"表",用于存储数据对象。
- 事务:数据的读写操作是通过事务进行的。
- 键:数据存储的标识。
- 索引:用于高效搜索数据。
打开数据库
在使用 IndexedDB 之前,需要打开一个数据库。如果指定的数据库不存在,浏览器会创建它。
javascript
let db;
const request = indexedDB.open("MyTestDatabase", 1);
request.onerror = function(event) {
// 错误处理
console.log("Database error: " + event.target.errorCode);
};
request.onsuccess = function(event) {
db = event.target.result;
};
创建对象仓库
对象仓库类似于 SQL 数据库中的表。以下是在数据库的升级过程中创建对象仓库的示例:
javascript
request.onupgradeneeded = function(event) {
const db = event.target.result;
// 创建一个对象仓库来存储我们的数据。我们将使用 "id" 作为键路径,因为我们假设它是唯一的。
const objectStore = db.createObjectStore("name", { keyPath: "id" });
// 创建一个索引来通过 name 进行搜索。
objectStore.createIndex("name", "name", { unique: false });
};
添加数据
一旦有了对象仓库,就可以往里面添加数据了。这需要在一个事务中完成。
javascript
function addData() {
const transaction = db.transaction(["name"], "readwrite");
const objectStore = transaction.objectStore("name");
const data = {id: "1", name: "Zhang San"};
const request = objectStore.add(data);
request.onsuccess = function(event) {
console.log("数据添加成功");
};
request.onerror = function(event) {
console.log("数据添加失败");
};
}
读取数据
从对象仓库中读取数据也很简单:
javascript
function readData() {
const transaction = db.transaction(["name"]);
const objectStore = transaction.objectStore("name");
const request = objectStore.get("1"); // 使用 id 读取数据
request.onerror = function(event) {
console.log("事务失败");
};
request.onsuccess = function(event) {
if (request.result) {
console.log("Name: " + request.result.name);
} else {
console.log("未找到数据");
}
};
}
更新数据
更新数据与添加数据类似,但通常会先读取现有数据,然后进行修改。
javascript
function updateData() {
const transaction = db.transaction(["name"], "readwrite");
const objectStore = transaction.objectStore("name");
const request = objectStore.get("1");
request.onsuccess = function(event) {
const data = event.target.result;
data.name = "Li Si"; // 修改名称
const requestUpdate = objectStore.put(data);
requestUpdate.onerror = function(event) {
console.log("更新失败");
};
requestUpdate.onsuccess = function(event) {
console.log("更新成功");
};
};
}
删除数据
删除数据也很直接:
javascript
function deleteData() {
const request = db.transaction(["name"], "readwrite")
.objectStore("name")
.delete("1");
request.onsuccess = function(event) {
console.log("数据删除成功");
};
}
以上就是 IndexedDB 的基本用法。通过这些基本操作,可以在前端应用中实现复杂的数据存储需求。不过,记得 IndexedDB 的操作都是异步的,所以你可能需要管理好回调或者使用async/await
来处理这些异步操作。
Dexie.js
除了使用浏览器原生的 IndexedDB 以外,我们还可以使用 Dexie.js,该第三方库提供了更简洁、更易用的 API。
IndexedDB
- 原生 Web API:IndexedDB 是一个低级的 API,直接内置于现代浏览器中,用于在客户端存储大量结构化数据。
- 复杂性:直接使用 IndexedDB 可能相当复杂,主要是因为它的异步性质和繁琐的错误处理。它的 API 设计更偏向于底层,提供了大量的灵活性,但也使得简单操作变得复杂。
- 事务管理:IndexedDB 需要显式地处理事务。事务、对象存储、索引等需要仔细管理和协调。
- 无包装器:直接使用 IndexedDB 意味着编写更多的引导和设置代码,例如处理数据库的版本升级逻辑。
Dexie.js
- 封装库:Dexie.js 是一个对 IndexedDB 进行封装的库,提供了一个简单、更易于理解和使用的 API。
- 简化的操作:通过 Dexie.js,复杂的 IndexedDB 操作变得更简单。例如,它简化了异步操作的处理,使得使用 promises 和 async/await 变得直观。
- 错误处理:Dexie.js 提供了更加友好和简洁的错误处理方式。
- 强化的功能:Dexie.js 增加了一些额外的功能,如简化的索引查询和批量操作。
- 事务管理:Dexie.js 简化了事务管理。你仍然需要理解 IndexedDB 的事务概念,但 Dexie.js 提供了更简单的方法来处理它们。
- 易于升级:在 Dexie.js 中,处理数据库的版本升级更加简单和直观。
Dexie.js 的优势
- 更简洁的代码:使用 Dexie.js 可以写出更清晰、更简洁的代码,尤其是在处理复杂查询和大量的异步操作时。
- 易于维护:由于 API 更加简单,维护和更新使用 Dexie.js 编写的代码通常比直接使用 IndexedDB 更容易。
- 更好的错误处理:Dexie.js 提供了更友好的错误处理机制,有助于更容易地诊断问题。
- 社区支持:Dexie.js 拥有一个活跃的社区,提供了丰富的文档和社区支持。
下面简单介绍一下它的基本语法:
js
let db = new Dexie("testDb");
db.version(1).stores({ articles: "id", settings: "id"});
第一行创建一个名为 testDb 的 IndexedDB 数据库。第二行中的 db.version(1) 表示数据库的版本。
在 IndexedDB 中,有版本的概念,例如假设现在的应用的数据库版本号为 1 (默认值也是 1 ),新版本应用希望更新数据结构,可以把数据库版本号设置为 2 。当用户打开应用访问数据时,会触发 IndexedDB 的 upgradeneeded 事件,我们可以在此事件中完成数据的迁移工作。
在 Dexie.js 中,对 IndexedDB 的版本 API 进行了封装,所以在上面的代码中,我们使用 db.version 方法获得当前版本的实例,然后调用实例方法 stores,并传入数据结构对象。
数据结构对象相当于传统数据库的表,与传统数据库不同的是,我们不必为数据结构对象指定每一个字段的字段名,此处我们为 IndexedDB 添加了两个表 articles 和 settings ,它们都有一个必备字段 id,其他字段可以在写入数据时临时决定。
将来如果版本更新,数据库版本号变为 2 时,数据库增加了一张表 users,代码如下:
js
db.version(2).stores({ articles: "id", settings: "id", users: "id"});
此时 Dexie.js 会为我们进行相应的处理,在增加新的表的同时,原有表以及表里面的数据不变。这为我们从容地控制客户端数据库版本提供了强有力的支撑。
下面来看一下使用 Dexie.js 进行常用数据操作的代码:
js
// 增加数据
await db.articles.add({ id: 0, title: 'test'});
// 查询数据
await db.articles.filter(article => article.title === 'test');
// 修改数据
await db.articles.put({ id: 0, title: 'testtest'});
// 删除数据
await db.articles.delete(id);
// 排序数据
await db.articles.orderBy('title');
注意,上面的代码中使用到了 await 关键字,所以使用的时候,应该放在 async 标记的函数里面才能正常执行。
更多有关 Dexie.js 的使用,可以参阅官网:dexie.org/
生命周期
我们在最早就接触了一个 Electron 的生命周期方法:
js
// whenReady 是一个生命周期方法,当 Electron 完成初始化后会调用这个方法
app.whenReady().then(() => {
createWindow();
});
该方法是在 Electron 完成初始化后会调用这个方法。
- will-finish-launching:在应用完成基本启动进程之后触发
- ready:当 Electron 完成初始化后触发
- window-all-closed:所有窗口都关闭的时候触发,特别是在 windows 和 linux 里面,所有窗口都关闭则意味着应用退出
js
app.on("window-all-closed", ()=>{
// 当操作系统不是 darwin(macOS) 的时候
if(process.platform !== 'darwin'){
// 退出应用
app.quit();
}
})
- before-quit:退出应用之前触发
- will-quit:即将退出应用的时候
- quit:退出应用的时候
你可以在 www.electronjs.org/docs/latest... 看到更多的 app 模块的生命周期方法。
除了 app 模块以外,BrowserWindow 也有很多的事件钩子:
- closed:当窗口被关闭的时候
- focus:当窗口聚焦的时候
- show:当窗口展示的时候
- hide:当窗口隐藏的时候
- maxmize:当窗口最大化的时候
- minimize:当窗口最小化的时候
你可以在 www.electronjs.org/docs/latest... 这里看到更多的关于 BrowserWindow 的事件钩子。
一个简单的使用示例:
js
win.on("minimize", () => {
console.log("窗口最小化了");
});
渲染进程的权限
在 Electron 中,出于安全性的考虑,实际上提供给渲染进程的可用的 API 是比较少的。
在早期的时候,Electron 团队其实提供了一个名为 remote 的模块,该模块也能够达到主进程和渲染进程互访的目的,降低两者之间通信的难度。
但是该模块本身带来了一些问题:
- 性能问题
- 通过 remote 模块倒是可以使用原本只能在主进程里面使用的对象、类型、方法,但是这些操作都是跨进程的。在操作系统中,一旦涉及到跨进程的操作,性能上的损耗可能会达到几百倍甚至上千倍。
- 假设我们在渲染进程里面通过 remote 模块使用了主进程的 BrowserWindow 来创建一个窗口实例,不仅创建该窗口实例的过程很慢,你后面使用这个窗口实例的过程也很慢,小到更新属性,大到使用方法,都是跨进程。
- 影子对象
- 在渲染进程中通过 remote 模块使用到了主进程里面的某个对象,看上去是得到了主进程里面真正的对象,但实际上不是,得到的是一个对象的代理(影子)。
- 这个影子对象和主进程里面真正的原本的对象还是有一定区别。首先,原本的对象的原型链上面的属性是不会被映射到渲染进程的影子对象上面。另外,类似于 NaN、Infinity 这样的值在渲染进程的影子对象里面得到是 undefined。这意味着假设在主进程里面有一个方法返回一个 NaN 的值,通过渲染进程的影子对象来调用该方法的话,得到的却是 undefined。
- 存在安全性问题
- 使用 remote 模块后,渲染进程可以很轻松的直接访问主进程的模块和对象,这会带来一些安全性问题,可能会导致一些渲染进程里面的恶性代码利用该特性进行攻击。
Electron 团队意识到这个问题之后,将 remote 模块标记为了"不赞成"。
从 Electron 10 版本开始,要使用 remote 模块,必须手动开启
js
const { app, BrowserWindow } = require("electron");
// 创建窗口方法
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
enableRemoteModule: true // 允许使用 remote 模块
}
});
win.loadFile("window/index.html");
};
// whenReady 是一个生命周期方法,当 Electron 完成初始化后会调用这个方法
app.whenReady().then(() => {
createWindow();
});
开启之后,就可以在渲染进程中直接通过 remote 使用一些原本只能在主进程中才能使用的 API
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="btn">新建页面</button>
<script>
let { remote } = require('electron');
let newWin = null;
btn.onclick = function () {
// 创建新的渲染进程
newWin = new remote.BrowserWindow({
webPreferences: { nodeIntegration: true }
})
// 在新的渲染进程中加载 html 文件
newWin.loadFile('./index2.html');
}
</script>
</body>
</html>
之后,从 Electron14版本开始,彻底废除了 remote 模块。
不过,如果你坚持要用,也有一个替代品,就是 @electron/remote:www.npmjs.com/package/@el...
生命周期
我们在最早就接触了一个 Electron 的生命周期方法:
js
// whenReady 是一个生命周期方法,当 Electron 完成初始化后会调用这个方法
app.whenReady().then(() => {
createWindow();
});
该方法是在 Electron 完成初始化后会调用这个方法。
- will-finish-launching:在应用完成基本启动进程之后触发
- ready:当 Electron 完成初始化后触发
- window-all-closed:所有窗口都关闭的时候触发,特别是在 windows 和 linux 里面,所有窗口都关闭则意味着应用退出
js
app.on("window-all-closed", ()=>{
// 当操作系统不是 darwin(macOS) 的时候
if(process.platform !== 'darwin'){
// 退出应用
app.quit();
}
})
- before-quit:退出应用之前触发
- will-quit:即将退出应用的时候
- quit:退出应用的时候
你可以在 www.electronjs.org/docs/latest... 看到更多的 app 模块的生命周期方法。
除了 app 模块以外,BrowserWindow 也有很多的事件钩子:
- closed:当窗口被关闭的时候
- focus:当窗口聚焦的时候
- show:当窗口展示的时候
- hide:当窗口隐藏的时候
- maxmize:当窗口最大化的时候
- minimize:当窗口最小化的时候
你可以在 www.electronjs.org/docs/latest... 这里看到更多的关于 BrowserWindow 的事件钩子。
一个简单的使用示例:
js
win.on("minimize", () => {
console.log("窗口最小化了");
});
渲染进程的权限
在 Electron 中,出于安全性的考虑,实际上提供给渲染进程的可用的 API 是比较少的。
在早期的时候,Electron 团队其实提供了一个名为 remote 的模块,该模块也能够达到主进程和渲染进程互访的目的,降低两者之间通信的难度。
但是该模块本身带来了一些问题:
- 性能问题
- 通过 remote 模块倒是可以使用原本只能在主进程里面使用的对象、类型、方法,但是这些操作都是跨进程的。在操作系统中,一旦涉及到跨进程的操作,性能上的损耗可能会达到几百倍甚至上千倍。
- 假设我们在渲染进程里面通过 remote 模块使用了主进程的 BrowserWindow 来创建一个窗口实例,不仅创建该窗口实例的过程很慢,你后面使用这个窗口实例的过程也很慢,小到更新属性,大到使用方法,都是跨进程。
- 影子对象
- 在渲染进程中通过 remote 模块使用到了主进程里面的某个对象,看上去是得到了主进程里面真正的对象,但实际上不是,得到的是一个对象的代理(影子)。
- 这个影子对象和主进程里面真正的原本的对象还是有一定区别。首先,原本的对象的原型链上面的属性是不会被映射到渲染进程的影子对象上面。另外,类似于 NaN、Infinity 这样的值在渲染进程的影子对象里面得到是 undefined。这意味着假设在主进程里面有一个方法返回一个 NaN 的值,通过渲染进程的影子对象来调用该方法的话,得到的却是 undefined。
- 存在安全性问题
- 使用 remote 模块后,渲染进程可以很轻松的直接访问主进程的模块和对象,这会带来一些安全性问题,可能会导致一些渲染进程里面的恶性代码利用该特性进行攻击。
Electron 团队意识到这个问题之后,将 remote 模块标记为了"不赞成"。
从 Electron 10 版本开始,要使用 remote 模块,必须手动开启
js
const { app, BrowserWindow } = require("electron");
// 创建窗口方法
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
enableRemoteModule: true // 允许使用 remote 模块
}
});
win.loadFile("window/index.html");
};
// whenReady 是一个生命周期方法,当 Electron 完成初始化后会调用这个方法
app.whenReady().then(() => {
createWindow();
});
开启之后,就可以在渲染进程中直接通过 remote 使用一些原本只能在主进程中才能使用的 API
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="btn">新建页面</button>
<script>
let { remote } = require('electron');
let newWin = null;
btn.onclick = function () {
// 创建新的渲染进程
newWin = new remote.BrowserWindow({
webPreferences: { nodeIntegration: true }
})
// 在新的渲染进程中加载 html 文件
newWin.loadFile('./index2.html');
}
</script>
</body>
</html>
之后,从 Electron14版本开始,彻底废除了 remote 模块。
不过,如果你坚持要用,也有一个替代品,就是 @electron/remote:www.npmjs.com/package/@el...
预加载脚本
所谓预加载脚本,指的是执行于渲染进程当中,但是要先于网页内容开始加载的代码。
在预加载脚本中,可以使用 Node.js 的 API,并且由于它是在渲染进程中,也可以使用渲染进程的 API 以及 DOM API,另外还可以通过 IPC 和主进程之间进行通信,从而达到调用主进程模块的目的。
因此,预加载脚本虽然是在渲染进程中,但是却拥有了更多的权限。
下面是一个简单的示例:
js
// preload.js
const fs = require("fs");
window.myAPI = {
write: fs.writeSync,
};
在 preload.js 中,我们引入了 Node.js 的 API,并且由于预加载脚本和渲染进程里面的浏览器共享一个全局的 window 对象,因此我们可以将其挂载到 window 对象上面。
之后需要在 webPreferences 里面指定预加载脚本的路径,注意这是一个绝对路径,这意味着最好使用 path.join 方法去拼接路径。
js
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
preload: path.join(__dirname, "preload.js"),
},
但是需要注意,从 Electron12版本开始,默认是开启了上下文隔离的,这意味着预加载脚本和渲染进程里面的浏览器不再共享 window 对象,我们在 preload 里面对 window 的任何修改,不会影响渲染进程里面的 window 对象。
上下文隔离
上下文隔离(contextIsolation)是 Electron 里面的一个非常重要的安全特性,用于提高渲染进程里面的安全性。从 Electron12 版本开始默认就开启,当然目前可以在 webPreferences 里面设置关闭。
上下文隔离打开之后,主要是为了将渲染进程中的 JS 上下文环境和主进程隔离开,减少安全性风险。
举个例子:
假设有一个 Electron 程序,在没有隔离的情况,其中一个渲染进程进行文件相关的操作,例如文件删除,这就可能导致安全漏洞。
现在,在启动了上下文隔离之后,渲染进程是无法直接使用 Node.js 里面的模块的。
那么如果我在渲染进程中就是想要使用一些 Node.js 的相关模块,该怎么办呢?这里就可以通过预加载脚本来选择性的向渲染进程暴露,提高了安全性。
下面是一个简单的示例:
js
const fs = require("fs");
const { contextBridge } = require("electron");
// 通过 contextBridge 暴露给渲染进程的方法
contextBridge.exposeInMainWorld("myAPI", {
write: fs.writeSync,
open: fs.openSync,
});
在预加载脚本中,我们通过 contextBridge 的 exposeInMainWorld 方法来向渲染进程暴露一些 Node.js 里面的 API,这样做的一个好处在于渲染进程中只能使用到暴露出来的 API,其他没有暴露的是无法使用。
在渲染进程中,通过如下的方式来使用:
js
// 渲染进程 index.js
console.log(window.myAPI, "window.myAPI");
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
// 打开文件
const fd = window.myAPI.open("example.txt", "w");
// 写入内容
window.myAPI.write(fd, "This is a test");
});
当我们使用 contextBridge 向渲染进程暴露方法的时候,有两个方法可选:
- exposeInMainWorld:允许向渲染进程的主世界(MainWorld)暴露 API.
该方法接收两个参数:
- apiKey:在主世界的 window 对象下暴露的 API 名称
- api(Object):要暴露的方法,一般封装到一个对象里面
js
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('myAPI', {
doSomething: () => console.log('在主世界中做了些事情!')
});
- exposeInIsolatedWorld:允许向渲染进程的隔离世界(IsolatedWorld)暴露 API.
该方法接收 4 个参数:
- worldId:隔离世界的唯一标识
- apiKey:想在隔离世界的 window 对象下暴露的 API 名称
- api(Object):要暴露的方法,一般封装到一个对象里面
- options:附加渲染
js
// 在预加载脚本中
const { contextBridge } = require('electron');
contextBridge.exposeInIsolatedWorld(
'isolatedWorld', // 隔离世界的标识
'myIsolatedAPI', // 在隔离世界中暴露的 API 名称
{
doSomethingElse: () => console.log('在隔离世界中做了些事情!')
},
{}
);
js
// 在隔离世界的网页脚本中
window.myIsolatedAPI.doSomethingElse(); // 输出:"在隔离世界中做了些事情!"
一般来讲 exposeInMainWorld 就够用了。
本文所有源码均在:github.com/Sunny-117/e...
「❤️ 感谢大家」
如果你觉得这篇内容对你挺有有帮助的话: 点赞支持下吧,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。觉得不错的话,也可以阅读 Sunny 近期梳理的文章(感谢掘友的鼓励与支持 🌹🌹🌹):
我的博客:
Github: https://github.com/sunny-117/
前端八股文题库: sunny-117.github.io/blog/
前端面试手写题库: github.com/Sunny-117/j...
手写前端库源码教程: sunny-117.github.io/mini-anythi...
热门文章
- ✨ 爆肝 10w 字,带你精通 React18 架构设计和源码实现【上】
- ✨ 爆肝 10w 字,带你精通 React18 架构设计和源码实现【下】
- 前端包管理进阶:通用函数库与组件库打包实战
- 🍻 前端服务监控原理与手写开源监控框架 SDK
- 🚀 2w 字带你精通前端脚手架开源工具开发
- 🔥 爆肝 5w 字,带你深入前端构建工具 Rollup 高阶使用、API、插件机制和开发
- 🚀 Rust 构建简易实时聊天系统
专栏