Electron 企业级应用开发实战(二)

上一讲当中,详细介绍了项目搭建、基础打包、自定义协议、单实例运行和 scheme 唤起等知识点,并开发了「桌面掘金」套壳应用。

但企业级桌面应用不会这么简单,这一讲会重点介绍如何集成 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

本文正在参加「金石计划 . 瓜分6万现金大奖」

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容