文章目录
- 番茄钟应用
- 起步
- [主进程 app 和窗口管理 BrowserWindow](#主进程 app 和窗口管理 BrowserWindow)
-
- [app 、BrowserWindow](#app 、BrowserWindow)
- [ready 事件](#ready 事件)
- webContent:主进程控制网页
- 退出应用
- 装载网页到窗口
-
- 资源来源安全声明
- [SPA 单页应用](#SPA 单页应用)
- 进程的环境
- 进程间通信
- 案例:番茄钟应用
番茄钟应用
在一个番茄钟应用中,主进程和渲染进程要做什么?
从上方的进程工作内容可以发现,有些进程工作内容不仅仅是番茄应用会用到,其实所有应用都会用到。这些是一个应用基本通用的。
比如主进程 app 模块管理应用、BrowserWindow 模块创建窗口,进程间通信。
起步
安装
bash
npm install electron --save-dev
初始化
bash
npm init -y
package.json 的 main 字段指定的是 Electron 应用的入口文件,就像 vite 构建 SPA 的入口文件是 html 一样。因此这个入口文件很重要,它就是主进程。
author、license 和 description可为任意值,但对于应用打包是必填项。
启动 electron 项目
bash
electron .
这个命令会找到入口文件并执行。
通常我们会设为一个 script 脚本来使用:
json
{
"scripts": {
"dev": "electron .",
}
}
nodemon 启动项目
开发的时候,频繁手动启停应用很麻烦。我们可以借助 nodemon 自动监听文件更改,执行electron .
命令。
nodemon 可以监听文件变动,并执行命令。
它有几个主要配置:
- --watch:监听的文件,默认为
*.*
,也就是当前目录的所有文件 - --ext:监听的文件后缀,默认为 js,mjs,html
- --ignore:忽略监听,默认已经忽略常见的应该忽略的文件,如 node_module
- --exec:执行命令,默认可以执行 js 脚本。
- --delay:延迟启动。默认情况下当它检测到文件更改时就会立即重启你的应用程序。然而,如果你想要控制 nodemon 的重启频率,以避免在保存文件时过于频繁地重启(例如,当你正在进行大量编辑时),你可以使用 --delay(简写为 -d)选项来设置重启延迟。
-d 1.5
:延迟1.5秒启动
nodemon 最终会监听的文件规则:
nodemon 会先读取 watch 里面需要监听的文件或文件路径,再从文件中选择监控 ext 中指定的后缀名,最后去掉从 ignore 中指定的忽略文件或文件路径。
electron 项目中,nodemon 已经监听了 js,但没有 html。我们希望改了 html,也能重新启动,所以要加上后缀为 html 的文件监听。
json
{
"scripts": {
"dev": "nodemon --ext js,html,json --delay 1.5 --exec electron ."
}
}
当然如果配置项太多,也可以单独添加一个配置文件:
json
{
"ext": "js,html,json",
"delay": "1.5"
}
主进程 app 和窗口管理 BrowserWindow
app 、BrowserWindow
javascript
// 主进程
const { app, BrowserWindow } = require("electron");
我们使用 CommonJS 语法导入了两个 Electron 的核心模块:
- app,它就是主进程,控制您应用程序的事件生命周期。
- BrowserWindow,它负责创建和管理应用窗口。
您可能注意到了 app 和 BrowserWindow 两个模块名的大小写差异。 Electron 遵循 JavaScript 传统约定,以帕斯卡命名法 (PascalCase) 命名可实例化的类 (如 BrowserWindow, Tray 和 Notification),以驼峰命名法 (camelCase) 命名不可实例化的函数、变量等 (如 app, ipcRenderer, webContents) 。
javascript
// 为了更好做类型检查,如使用ts。则从 electron/main 中引入
const { app, BrowserWindow } = require('electron/main')
Electron 的许多核心模块都是 Node.js 的事件触发器,遵循 Node.js 的异步事件驱动架构。 app 模块就是其中一个。
node 事件与异步 IO 模型
Events | Node.js v21.6.2 Documentation
javascript
// node.js 自定义事件
const EventEmitter = require('events');
const myEmitter = new EventEmitter();
myEmitter.on('test', () => {
console.log('test event occurred');
});
myEmitter.emit('test');
ready 事件
在 app 模块的事件中,ready 事件最为重要。它被触发了,说明主线程启动完毕了,这时候才可以创建 BrowserWindows 实例,也就是建立窗口界面。
通常我们使用触发器的 .on 函数来监听 Node.js 事件。但是 ready 事件特殊一点,Electron 暴露了 app.whenReady()
方法,作为其 ready 事件的专用监听器,这样可以避免直接监听 .on 事件带来的一些问题。
javascript
app.on('ready', () => {})
app.whenReady().then(() => {})
在 Electron 中,只有在 app 模块的 ready 事件触发后才能创建 BrowserWindows 实例。 您可以通过使用 app.whenReady() API 来监听此事件,并在其成功后调用 createWindow() 方法。
javascript
const { app, BrowserWindow } = require('electron/main')
const createWindow = () => {
// 新建窗口
const win = new BrowserWindow({
// 窗口大小
width: 800,
height: 600,
// 窗口出现位置,默认是居中出现
x: 1500,
y: 200,
// 窗口置顶,方便开发时查看
alwaysOnTop: true
})
// 装载 UI 界面
win.loadFile('index.html')
// 与渲染进程的网页内容交互
const contents = win.webContents
console.log(contents)
}
app.whenReady().then(() => {
createWindow()
})
webContent:主进程控制网页
主进程的主要目的是使用 BrowserWindow 模块创建和管理应用程序窗口。
BrowserWindow 类的每次实例化就是创建一个应用程序窗口,也就是一个 html 网页,也就是一个渲染进程。
可以从主进程用窗口实例的 webContent
对象与网页进行交互。
- webContent :渲染以及控制 web 页面
比如应用启动就打开网页调试台:
javascript
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600
})
// 打开调试台
win.webContents.toggleDevTools();
}
退出应用
在 Windows 和 Linux 上,应用的使用习惯一般是关闭所有窗口后,应用就自动退出了。现在我们希望让 Electron 应用继续保持这个使用习惯。
可以监听 app 模块的 window-all-closed
事件,并调用 app.quit()
来退出您的应用程序。
通过检查 Node.js 的 process.platform变量,您可以针对特定平台运行特定代码。 请注意,Electron 目前只支持三个平台:win32 (Windows), linux (Linux) 和 darwin (macOS) 。
javascript
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
MacOS 用户的使用习惯并不是这样的。即使该应用的所有窗口关闭了,应用实际也没退出,重新点击它就会立即打开一个窗口。
为了让 Electron 应用在 Mac 平台继续保持这个用户习惯,可以监听 app 模块的 activate 事件。如果没有任何活动的 BrowserWindow,调用 createWindow() 方法新建一个。
javascript
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
装载网页到窗口
在 Electron 中,每个窗口展示一个页面,后者可以来自本地的 HTML,也可以来自 URL。
win.loadURL()
:BrowserWindow | Electronwin.loadFile()
BrowserWindow | Electron
资源来源安全声明
html 文件需要指定加载资源的源:
html
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
如果没有这句meta,electron 会在控制台报警告。因为 electron 对于渲染进程引入的资源有安全限制。
self 表示自己的。比如当前页面允许引入 b站的资源,则可以在后面补上 b 站的源链接。
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<meta
http-equiv="X-Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<title>Hello from Electron renderer!</title>
</head>
<body>
<h1>Hello from Electron renderer!</h1>
<p>👋</p>
</body>
</html>
SPA 单页应用
一个窗口就是一个网页,一个网页就是一个渲染进程。
网页中的要求是符合 web 技术规范。html 中可以使用 css,引入 js 脚本。
这种原始的基本网页结构有没有似曾相识的感觉?是的,单独一个网页就是 SPA 应用。这里网页引入的 js 脚本完全可以是构建工具打包后的 js 文件。
如果加载的是 SPA,这样一个 electron 窗口,就能实现复杂的功能。
electron 也就是这样和 vue 或 react 结合的。
进程的环境
Chromium 沙盒
Chromium的一个关键安全特性是,有些进程是在沙盒中运行。沙盒隔离了这些进程的风险,同时限制了它的权限。
沙盒里的进程如果要执行权限外的操作,沙盒进程需要通过 IPC 将任务交给权限更大的进程去完成。
Chromium 中除主进程外,其他绝大部分进程都是沙盒进程,其中包括渲染器进程,以及功能性进程,如音频服务、GPU 服务和网络服务。
Electron 主进程
每个 Electron 应用都有一个单一的主进程,作为应用程序的入口点。
Electron 的主进程是一个拥有着完全操作系统访问权限的 Node.js 环境,这意味着它具有 require 模块和使用所有 Node.js API 的能力。
Electron 渲染进程
从 Electron 20 开始,渲染进程也默认启用了沙盒。 electron 中的沙盒进程和 Chromium 中的沙盒差不多。
一个沙盒化的渲染器不会有一个 Node.js 环境,它只有 chromium 网页的环境。因此渲染进程中无法执行 Node.js 代码,比如 require 函数等。
那渲染器进程用户界面怎样才能与 Node.js 和 Electron 的原生桌面功能进行交互。 而事实上,确实没有直接导入 Electron 內容脚本的方法。
配置 Electron 沙盒
对于大多数应用程序来说,沙盒是最佳选择。 在某些与沙盒不兼容的使用情况下(例如,在渲染器中使用原生的 Node.js 模块时),可以禁用特定进程的沙盒。 但这会带来安全风险,特别是当未受信任的代码或内容存在于未沙盒化的进程中时。
为单个进程禁用沙盒。
两种方式二选一:
sandbox: false
:关闭该渲染进程沙盒nodeIntegration: true
:允许该渲染进程集成 Node 环境。这个操作也能关闭沙盒
typescript
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
sandbox: false
// nodeIntegration: true
}
})
win.loadURL('https://google.com')
})
预加载脚本
概念
为了将 Electron 的不同类型的进程桥接在一起,我们需要使用被称为 预加载 的特殊脚本。
这些脚本虽运行于渲染器的环境中,却具有访问 HTML DOM 和 Node.js、Electron API 的部分权限。
这意味着预加载脚本可以增强沙盒中渲染进程的能力。
什么叫部分权限?
从 Electron 20 开始,预加载脚本也默认 沙盒化 ,不再拥有完整 Node.js 环境的访问权。
这意味着预加载脚本中的 require 函数是个打了 polyfilled 的阉割版函数。它只能加载一些特定的模块。
- 具体是哪些可查看:进程沙盒化 | Electron
对于一些页面上想用的第三方 npm 包,你可能会想是否可以在预加载脚本中用,然后再暴露给网页脚本,也就是渲染进程用。想法很美好,现实很骨感。因为是个阉割版,所以这个想法是行不通的。
页面想用第三方 npm 包,必须借助构建工具打包成 SPA 应用。
预加载脚本注入时机就像谷歌浏览器插件中的内容脚本一样。它会在渲染器加载网页之前注入。
什么是内容脚本?
内容脚本是在网页环境中运行的文件。通过使用标准文档对象模型 (DOM),用户能够读取浏览器所访问网页的详细信息,对这些网页进行更改,并将信息传递给父级扩展程序。
比如你想写一个插件,功能是修改网页的背景色,那修改背景色的代码就是内容脚本,它会在渲染器加载网页之前注入。
注册预加载脚本
预加载脚本可以在 BrowserWindow 构造方法中的 webPreferences 选项里被附加到主进程。
javascript
const path = require("node:path");
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, "preload.js")
}
});
win.loadFile(path.resolve(__dirname, "index.html"));
};
// ...
基本使用
预加载脚本可以增强渲染器,具体怎么做?
可以通过两个对象:
- Window
- contextBridge
Window 就是 BOM 中的那个 Window 对象。
因为预加载脚本与浏览器共享同一个全局 Window 接口,并且可以访问 Node.js API,所以它通过在全局 window 中暴露任意 API 来增强渲染器,以便你的网页内容使用。
说白了就是预加载脚本把内容挂载 window 对象上,然后网页中的脚本来 window 中取。
javascript
// preload.js
window.myAPI = {
desktop: true
}
// renderer.js
console.log(window.myAPI)
// => undefined
怎么上面结果是 undefined?
虽然预加载脚本与其所附着的渲染器在共享着一个全局 window 对象,但您并不能从中直接附加任何变动到 window 之上,因为默认存在 contextIsolation 上下文隔离。
上下文隔离
上下文隔离意味着预加载脚本与渲染器的主要运行环境是隔离开来的,是两个独立的上下文环境。这对安全性很重要,因为它有助于阻止网站访问 Electron 的内部组件 和 您的预加载脚本可访问的高等级权限的API 。
这意味着,实际上,您的预加载脚本访问的 window 对象并不是网站所能访问的对象。 例如,如果您在预加载脚本中设置 window.hello = 'wave' 并且启用了上下文隔离,当网站尝试访问window.hello对象时将返回 undefined。
所以我们將使用 contextBridge
模块来安全地实现交互:
- contextBridge:在隔离的上下文中创建一个安全的、双向的、同步的桥梁。
通过上下文桥可以安全的将内容挂载到 window 对象上,页面脚本也是从 window 对象上取取数据。
javascript
// Preload (Isolated World)
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld(
'electron',
{
doThing: () => ipcRenderer.send('do-a-thing')
}
)
// Renderer (Main World)
window.electron.doThing()
关闭该渲染进程的上下文隔离:
contextIsolation: false
换句话说,要无视风险释放渲染进程的所有能力,就是要允许渲染进程集成 node 环境,并关闭上下文隔离。
typescript
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
})
win.loadURL('https://google.com')
})
结合 ts
另外,如果您正在使用 TypeScript 构建 Electron 应用程序,则必须给通过 context bridge 暴露的 API 添加类型声明。要不然 window 对象拿不到数据。
javascript
contextBridge.exposeInMainWorld('electronAPI', {
loadPreferences: () => ipcRenderer.invoke('load-prefs')
})
您可以创建一个 interface.d.ts 类型声明文件,并且全局增强 Window 接口。
typescript
// interface.d.ts
export interface IElectronAPI {
loadPreferences: () => Promise<void>,
}
declare global {
interface Window {
electronAPI: IElectronAPI
}
}
预加载脚本的主要用途
- 实现 IPC 通信。在预加载脚本中暴露 IPC 通信代码。
- 注意:IPC 默认是无法在网页脚本中发起的,因为里面都没有 require 函数,都无法加载 ipcRenderer 模块
注意:不要直接暴露 API 函数。这样相当于所有渲染进程都有了 IPC 能力,这上下文隔离隔离了个寂寞。
正确的做法是只暴露具体实现。
javascript
// ❌ 错误使用 暴露API
contextBridge.exposeInMainWorld('myAPI', {
send: ipcRenderer.send
})
// ✅ 正确使用 暴露具体实现
contextBridge.exposeInMainWorld('myAPI', {
loadPreferences: () => ipcRenderer.invoke('load-prefs')
})
- 如果您正在为远程 URL 上托管的现有 web 应用开发 Electron 封裝,则您可在渲染器的 window 全局变量上添加自定义的属性,好在 web 客户端用上仅适用于桌面应用的设计逻辑 。相当于补充一下环境变量信息。
进程间通信
进程间通信 | Electron
ipcMain | Electron
ipcRenderer | Electron
主要会用到两个模块:
- 主进程:
ipcMain
- 渲染进程:
ipcRenderer
两者发消息有几种模式:
- 渲染进程单方面发给主进程,主进程只管收(只有单向)
- 渲染进程单方面发给主进程,主进程不仅能收,还能响应结果给渲染进程(双向沟通了)
- 主进程向渲染进程发消息
- 渲染进程向另一个渲染进程发消息
第二种模式。双向 IPC 的一个常见应用是从渲染器进程代码调用主进程模块并等待结果。这是最常用的,因为渲染进程比较弱鸡,大部分工作要交给主进程完成,自己只要拿结果。
ipcRenderer.invoke()
:渲染进程发消息,并在 Promise 回调中获取结果ipcMain.handle()
:主进程收到消息,并在回调中返回结果
javascript
// 渲染进程 invoke 发消息
ipcRenderer.invoke('some-name', someArgument).then((result) => {
// 此处拿到主进程返回的结果 result
})
// 主进程 handle 处理消息
ipcMain.handle('some-name', async (event, someArgument) => {
// 调用主进程里的代码
const result = await doSomeWork(someArgument)
// 返回结果给渲染进程
return result
})
注意:实际上 IPC 通信,会有三个环境参与。渲染进程、预加载脚本环境、主进程。不是上面 API 演示的这么简单。
typescript
// preload.js
const { contextBridge, ipcRenderer } = require('electron/renderer')
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile')
})
typescript
// renderer.js
const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')
btn.addEventListener('click', async () => {
const filePath = await window.electronAPI.openFile()
filePathElement.innerText = filePath
})
typescript
// main.js
const handleFileOpen = () => {};
app.whenReady().then(() => {
ipcMain.handle('dialog:openFile', handleFileOpen)
createWindow()
})
第三种模式,主进程向渲染进程发消息。
消息需要通过其 WebContents 实例发送到渲染器进程。 此 WebContents 实例包含一个 send 方法,其使用方式与 ipcRenderer.send 相同。
webContents是一个EventEmitter. 负责渲染和控制网页, 是 BrowserWindow 对象的一个属性。
javascript
// main.js
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
// 主进程发送消息
mainWindow.webContents.send('update-counter', 1),
}
app.whenReady().then(() => {
// 主进程监听接收消息
ipcMain.on('counter-value', (_event, value) => {
console.log(value) // will print value to Node console
})
createWindow()
}
javascript
// prelaod.js
const { contextBridge, ipcRenderer } = require('electron/renderer')
contextBridge.exposeInMainWorld('electronAPI', {
onUpdateCounter: (callback) => ipcRenderer.on('update-counter', (_event, value) => callback(value)),
counterValue: (value) => ipcRenderer.send('counter-value', value)
})
javascript
// renderer.js
const counter = document.getElementById('counter')
// 渲染进程监听接收消息
window.electronAPI.onUpdateCounter((value) => {
const oldValue = Number(counter.innerText)
const newValue = oldValue + value
counter.innerText = newValue.toString()
// 渲染进程发送消息
window.electronAPI.counterValue(newValue)
})