这一讲会重点介绍如何集成 Node.js、使用 preload 脚本、进程间双向通信、上下文隔离等,为大家揭开 Electron 更强大的能力。
集成 Node.js
企业级桌面应用的资源都是本地化的,离线也能使用,所以需要把 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 网页运行在浏览器沙箱环境里面,没有能力调用操作系统 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)
运行程序会发现操作系统和版本号已经显示出来了:
使用 preload 脚本
直接在网页上调用 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 的内容删除,改成仅打印一行文字:
console.log('renderer index.js')
然后在 src 目录下新增 preload/index.js 文件,代码为:
console.log('preload index.js')
console.log('platform', require('os').platform())
运行之后观察一下控制台输出,可以发现 preload/index.js 代码先执行,renderer/index.js 代码后执行,而且 preload 中可以直接调用 node.js 的 API:
如果你在运行的时候报错了,提示 module not found:
这是因为从 Electron 20 版本开始,渲染进程默认开启沙箱模式,需要指定 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)
})
主进程和渲染进程
主进程运行在一个完整的 Node.js 环境中,负责控制整个应用的生命周期,并管理渲染进程。Electron 依据 package.json 的 main 字段作为主进程的入口文件,在我们的项目中就是 src/main/index.js 文件,先注释掉 createWindow 那行代码,然后启动应用:
app.whenReady().then(() => {// createWindow()
})
去活动监视器中查看,发会现启动了三个进程:
pid 为 8188,名称为 Electron 的进程就是主进程,那多出来的两个进程是做什么的呢?打开活动监视器,强制退出 Electron Helper 进程,然后观察控制台输出:
通过错误信息中我们知道 Electron Helper 进程是负责网络服务的,而且有保活机制,如果 crash 的话会被重新拉起,Electron Helper (GPU) 从名称上就能知道是负责 GPU 渲染的,也被主进程保活了,所以一个 Electron 应用至少会存在上述三个进程。实际上,一个 Electron 应用最多有五类进程:
- Electron
- Electron Helper
- Electron Helper (GPU)
- Electron Helper (Plugin)
- Electron Helper (Renderer)
Electron 进程就是主进程,剩下的四个进程都是主进程创建出来的,用于辅助主进程完成对应任务。在 macOS 系统下右键进入到 Electron.app 里面,在 Contents/Frameworks 目录下可以看到它们的身影:
它们的作用分别是:
进程名 | 中文名 | 作用 |
---|---|---|
Electron | 主进程 | 负责界面显示、用户交互、子进程管理,控制应用程序的地址栏、书签,前进/后退按钮等,同时提供存储等功能 |
Electron Helper | 网络进程 | 负责页面的网络资源加载 |
Electron Helper (Renderer) | 渲染进程 | 负责网页排版和交互(排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中) |
Electron Helper (GPU) | GPU 进程 | 负责 GPU 渲染 |
Electron Helper (Plugin) | 插件进程 | 负责插件的运行 |
如果我们修改代码,调用三次 createWindow 函数:
app.whenReady().then(() => {createWindow()createWindow()createWindow()
})
运行之后会创建三个窗口:
活动监视器中也会出现三个 Electron Helper (Renderer) 渲染进程:
但是如果你以为每个窗口就对应一个渲染进程,那就大错特错了!真正产生渲染进程的不是 createWindow,而是里面的 loadFile/loadURL 函数,不信你把 loadFile 那一行注释掉:
function createWindow() {mainWindow = new BrowserWindow({ /* 代码省略 */ })
// 下面的代码才是产生渲染进程的原因// mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
}
会发现窗口了三个空白窗口,里面什么内容都没有,而且活动监视器中没有找到任何 Electron Helper (Renderer) 进程。
进程间通信
Electron 为主进程和渲染进程分别提供了对应的 API:
由于渲染进程中无法直接调用主进程的 API,但有些场景需要在渲染进程知道主进程 API 的结果,例如操作系统会允许用户设置外观的主题色:
如果现在要做一个功能,实现两个需求:
- 判断系统是否是深色模式
- 可切换应用主题色为深色或浅色
首先把页面布局搭好:
<body><p id="isDarkMode">当前系统是采用暗黑模式:</p><button onclick="setTheme('light')">设置浅色</button><button onclick="setTheme('dark')">设置深色</button>
</body>
判断系统是否为深色模式,需要用到主进程提供的 nativeTheme API,把下面的代码加到 src/main/index.js 里面可以打印出调用结果:
const { nativeTheme } = require('electron')
console.log('isDarkMode', nativeTheme.shouldUseDarkColors)
那渲染进程如何拿到这个结果呢?这就用到了进程间通信的能力了,Electron 为开发者封装了三种通信的方式:
sendSync
&returnValue
send
&reply
invoke
&handle
sendSync
& returnValue
这是同步调用的方式,渲染进程的代码为:
const value = ipcRenderer.sendSync('isDarkMode')
console.log('sendSync reply', value)
主进程代码:
ipcMain.on('isDarkMode', (event, args) => {event.returnValue = nativeTheme.shouldUseDarkColors
})
send
& reply
异步回调的方式,渲染进程代码:
ipcRenderer.send('isDarkMode')
ipcRenderer.on('isDarkMode', (event, value) => {console.log('on reply', value)
})
主进程代码:
ipcMain.on('isDarkMode', (event, args) => {event.reply('isDarkMode', nativeTheme.shouldUseDarkColors)
})
invoke
& handle
异步 Promise 方式,渲染进程代码:
ipcRenderer.invoke('isDarkMode').then((value) => console.log('invoke reply', value))
主进程代码:
ipcMain.handle('isDarkMode', (event, args) => {return nativeTheme.shouldUseDarkColors
})
这里推荐大家使用 invoke & handle 组合来进行通信,不过需要注意:相同的事件名称,on 方法可以注册多次,但是 handle 方法只能注册一次,否则会报错:
App threw an error during load
Error: Attempted to register a second handler for 'isDarkMode'at IpcMainImpl.handle (node:electron/js2c/browser_init:193:325)
最后用一张图总结一下进程间通信:
上下文隔离
上面讲到,preload.js 脚本中可以访问 node.js 的 全部API 和 Electron 提供的渲染进程 API,这个脚本最终也是会注入到 index.html 页面里面的,在 webPreferences 的选项当中有个 contextIsolation 配置,表示是否开启上下文隔离(默认开启),它的具体含义为:
preload.js 脚本和 index.html 是否共享相同的 document 和 window 对象
为了让大家更直观的理解这个概念,我们来完成上一节的第二个需求:
- 可切换应用主题色为深色或浅色
在 preload.js 中增加以下代码:
window.setTheme = (theme) => {ipcRenderer.invoke('setTheme', theme)
}
当点击设置浅色/深色按钮的时候,发现报错了:
原因就在于默认开启了 contextIsolation 导致的,preload.js 中的 window 和 index.html 中的 window 不是同一个对象,我们把它给关掉再试试:
mainWindow = new BrowserWindow({width: 800,height: 600,webPreferences: {nodeIntegration: false,contextIsolation: false, // 关闭上下文隔离sandbox: false,preload: path.join(__dirname, '../preload/index.js'),},
})
再点击报错就消失了。这是怎么回事呢?在开启 contextIsolation 选项之后,点击控制台 top 箭头,发现有两个上下文:
- top:网页 index.html 的上下文
- Electron Isolated Context:preload.js 的隔离上下文
这两个上下文之间不同享全局变量,例如 document、window 等。
上下文(context)其实是 V8 中的全局作用域的概念,每个上下文中都会有一个独立的 window 对象,它们彼此之间是隔离开来的,有各自的全局作用域、全局变量和原型链。
所以在 preload.js 中给 window 变量添加属性,外面是拿不到的,只能通过另外一个 contextBridge API 来暴露:
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('preloadApi', {setTheme: (theme) => {ipcRenderer.invoke('setTheme', theme)},
})
调用方式为 window.preloadApi.setTheme
,因此需要把 index.html 改成这样:
<body><p id="isDarkMode">当前系统是采用暗黑模式:</p><button onclick="preloadApi.setTheme('light')">设置浅色</button><button onclick="preloadApi.setTheme('dark')">设置深色</button><style> @media (prefers-color-scheme: dark) {body {background-color: black;color: white;}} </style>
</body>
然后在主进程中响应 setTheme 调用,设置应用的主题色:
ipcMain.handle('setTheme', (event, theme) => {nativeTheme.themeSource = theme
})
最终实现的效果如下:
总结
Electron 与浏览器最大的区别就是集成了 Node.js 的能力,不过能力越强风险也越大,如何在其中找到一个平衡点呢?
- Electron 允许开发者指定本地 preload 脚本,让其拥有无条件访问 Node.js 的权利,并且在页面开始加载之前就注入进去
- Electron 为了避免 preload 脚本污染页面全局变量,提供了上下文隔离的能力
- Electron 三种进程间通信的方式,极大地方便了开发者在渲染进程中调用主进程 API
最后
整理了75个JS高频面试题,并给出了答案和解析,基本上可以保证你能应付面试官关于JS的提问。
有需要的小伙伴,可以点击下方卡片领取,无偿分享