深刻理解Electron

1,923 阅读8分钟

本文部分内容借鉴了这篇文章入门Electron,手把手教你编写完整实用案例,并会对其中的一些代码进行的重点纠正(可能前端项目迭代较快)。另外,这篇文章中也包括一些我对于Electron的额外见解,如有错误,请斧正。

Electron简介

Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。 嵌入 Chromium 和 Node.js 到 二进制的 Electron 允许您保持一个 JavaScript 代码代码库并创建 在Windows上运行的跨平台应用 macOS和Linux——不需要本地开发经验。

Electron不同于一般Web应用,它继承了来自 Chromium 的多进程架构,这使得此框架在架构上非常相似于一个现代的网页浏览器。

这个特性更加切合我们使用客户端的需求。有时,除了主窗口外,还需要很多副窗口。比如使用QQ,我们可以打开多个人的聊天窗口。

快速开始

Electron官网用例

Electron安装

安装Electron包,加入生产环境

// npm
npm install --save-dev electron

// yarn
yarn add --dev electron

配置package.json,通过npm startyarn start运行项目。

// package.json
{
  "scripts": {
    "start": "electron ."
  }
}

进程模型

初次接触Electron,首先需要理解的就是它的进程模型。

Electron将进程主要分为两类:主进程和渲染进程。主进程相当于一个管理员,可以创建与管理各种渲染进程,而渲染进程相当于浏览器中的各个标签页。

进程创建

主进程

主进程在index.js中默认创建,并且可通过下面监听创建完毕和关闭事件。

// index.js
const { app, BrowserWindow } = require('electron')

app.whenReady().then(() => {
    createWindow()
})

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})

渲染进程

创建渲染进程即创建一个窗口,一般一个客户端应该包含一个主渲染进程,我们给其起名为mainWindow

// index.js

// create a window
function createMainWindow() {
    let mainWindow = new BrowserWindow({
        frame: false, 			// no border
        // resizable: false,		// no resize
        width: 800,
        height: 600,
        icon: iconPath,
        webPreferences: {
                backgroundThrottling: false, // run in back
                nodeIntegration: true,       // use nodeApi
                contextIsolation: false,     // relate to preload
                // preload: preLoadPath
        }
    });

    // load the html
    mainWindow.loadFile(indexPath);
    mainWindow.removeMenu();
}

该函数只需在app.whenReady中调用即可创建一个主窗口。

在创建BrowserWindow中可以定义窗口具备的多个属性,如frame定义是否具备边框;resizable定义是否可以拉伸窗口;backgroundThrottling定义是否能够在后台运行。其中值得着重说明的属性是contextIsolation定义上下文隔离,我将会在下面提到这个问题。

注意,根据一些其它的入门教程,在这里使用mainWindow.loalUrl(file://绝对路径)来加载本地页面文件,以现在的版本来说这种加载方法是失效的,除非额外配置对file协议的处理。而现在的版本中只需要使用loadFile即可加载本地文件。

进程通信

进程间通信英文Inter-Process Communication,在主进程中通过ipcMain来调用与其相关的函数。

以下将展示下面三种通信类型。

graph TB
    id1(主进程)-->id2(副渲染进程)
    id1 --> id3(主渲染进程)
    id2 --> id3

渲染进程 to 主进程

ipcMain是仅存在于主进程中的一个对象,只能在主进程中调用,其上可以挂载多个监听器,面向所有渲染进程来监听事件。正是因此,一个好的习惯是在事件名称的命名为“渲染进程名+事件名”。如以下的mainWindow:close表示监听主渲染进程的关闭事件。

主进程通过ipcMain.on来监听事件,渲染进程通过ipcRenderer.send('eventName')来触发事件。

// index.js
ipcMain.on("mainWindow:close", () => {
    mainWindow.hide();
})

// render.js
ipcRenderer.send('mainWindow:close');

主进程监听的事件同时可以获取渲染进程传递的参数,如下(event,task)=>{}

注意,第一个参数一定是event,第二个参数之后才是真正传播的数据。

ipcMain.on("mainWindow:setTaskTimer", (event, task) => {
    // ...
})

与一般的事件监听不同,electron的事件监听具备同步或异步获取函数返回值的特点。

其中通过event.returnValue来处理同步通信,通过event.reply('eventName')来处理异步通信。但是同步通信会阻塞主进程,官方文档中更建议使用异步。

以下为截取的一部分官网给出的例子。

// index.js
ipcMain.on('asynchronous-message', (event, arg) => {
  console.log(arg) // prints "ping"
  event.reply('asynchronous-reply', 'pong')
})

ipcMain.on('synchronous-message', (event, arg) => {
  console.log(arg) // prints "ping"
  event.returnValue = 'pong'
})

// renderer.js
console.log(ipcRenderer.sendSync('synchronous-message', 'ping')) // prints "pong"

ipcRenderer.on('asynchronous-reply', (event, arg) => {
  console.log(arg) // prints "pong"
})
ipcRenderer.send('asynchronous-message', 'ping')

主进程 to 渲染进程

主进程主动给渲染进程发送消息是通过渲染进程的webContents来完成。这里的理解要准确,所说的渲染进程指的是在index.js中创建的渲染进程对象,并不指的是渲染进程的页面中的对象。

此处需要webContents.on("did-finish-load")极有必要,因为只有在渲染进程的页面加载完毕后,webContents.send才能够触发其事件(已测试过send执行时间早于渲染页面加载JS脚本的时间)

viceWindow.webContents.on("did-finish-load", () => {
    viceWindow.webContents.send("event", task);
})

注意,webContents中的监听事件无法像ipcMain一样自定义,对于渲染进程触发的事件,官方文档中给出了明确的列表。“did-finish-load”事件在渲染进程导航加载完成时触发。

渲染进程 to 渲染进程

渲染进程与渲染进程间的通信是通过ipcRenderer.sendTo来完成。

下面例子中的windowId代表目标渲染进程的ID,根据个人测试Electron的进程ID会从1开始按顺序递增,主渲染进程为第一个创建的进程,一般其ID为1。但是,如果想确定获取某个进程的ID,只能够在主进程中通过mainWindow.webContents.id获取,并发送给该渲染进程。

// ipcRenderer.sendTo(windowId, "finishEvent", params);
ipcRenderer.sendTo(1, "finishEvent", params);

这里需要说明的是,如果存在多个窗口,需要处理好各个窗口关系,并存储各个窗口的ID,方便数据传输。

预加载

如果到这里,你还记得前面提到的contextIsolation,那我将很感谢你在认真阅读。

逻辑结构

如果使用Electron不愿意复杂化,那么按照我们的文件目录大概如下:

- project
    - index.js
    - src
        - index.html
        - render.js
        - style.js

如果按照我上面所说进行配置,大概不会碰到什么棘手的问题。

但是按照官方给出的quick-start的例子,其文件目录中还包含一个preload.js脚本,文件目录成为下面这样,这就是预加载,这是因为官放例子中contextIsolation属性设置为true(默认为true

- project
    - index.js
    - preload.js
    - src
        - index.html
        - render.js
        - style.js

上下文隔离

于是,引出Electron的又一个关键的概念,上下文隔离

在一般的我们的前端项目中,渲染html页面的js中是运行在浏览器环境中的,而在Electron中,我们会发现Node中模块在其中也可以使用。(同时需要配置nodeIntegrationtrue

这样的话,在一个复杂项目中,就可以造成污染,重名等问题。于是Electron特意增加了上下文隔离这一概念。

开启上下文隔离的条件是contextIsolation属性设置为true,且增加preload的路径。

function createMainWindow() {
    // create a window
    mainWindow = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            // nodeIntegration: true,
            contextIsolation: false,		 // make preload success
            preload: './preload.js',
        }
    });

    // load the html
    mainWindow.loadFile(indexPath);
    // mainWindow.removeMenu();
}

你会发现,一旦开启该条件,渲染页面的js中无法引入Electron和Node的各种模块。因此,如果想在其中使用,需要配置preload.js,使用contextBridge(上下文桥,这个名字不错)来暴露全局接口到渲染页面的脚本中,在渲染脚本中直接通过window.darkMode来调用preload.js中的togglesystem函数。

以下例子取自官方网站。

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('darkMode', {
  toggle: () => ipcRenderer.invoke('dark-mode:toggle'),
  system: () => ipcRenderer.invoke('dark-mode:system')
})
// renderer.js
document.getElementById('toggle-dark-mode').addEventListener('click', async () => {
  const isDarkMode = await window.darkMode.toggle()
  document.getElementById('theme-source').innerHTML = isDarkMode ? 'Dark' : 'Light'
})

document.getElementById('reset-to-system').addEventListener('click', async () => {
  await window.darkMode.system()
  document.getElementById('theme-source').innerHTML = 'System'
})

于是,electron主进程和渲染进程的处理相互隔离,之间通过一条桥梁连接起来,各干各的事,有需要咱通过“桥”联系。

存储

这里再提一下关于Electron的数据存储。

localStorage

这是我们在前端项目中存储数据的惯用手段,但是该方法在electron中并不是最好的。网上关于其缺点列举的很清楚,在此稍微列举几点:非原子性;容错性低;安全性差等。

electron-store

这是在ELectron中常用的一个存储模块,需要进行安装。

npm install electron-store

使用的话主要是三个函数:

const Store = require("electron-store");
const store = new Store();

store.set('key', 'value');
let value = store.get('key');
let isExist = store.has('key');

该模块配合上下文隔离使用最佳,不容易出现错误。

如果不使用上下文隔离,需要在index.js中通过下面语句引入该项目。

const Store = require("electron-store");
const store = new Store();

我遇到的报错是它需要index.js引入下面样子的监听事件,而监听事件已经在store中进行封装,因此只需要引入上面两行即可。

// 以下代码大概封装在electron-store中,需要在index.js中生效
ipcMain.on('electron-store-get', async (event, val) => {
    if (store.get(val))
        event.returnValue = store.get(val);
    else
        event.returnValue = [];
});

ipcMain.on('electron-store-set', async (event, property, val) => {
    // console.log(val);
    store.set(property, val);
});

最后

关于Electron,我之前已有耳闻,出于兴趣,根据开头那篇文章学习了一下它的用法,在这个过程中碰到了很多问题,大费周折才解决,但是因此有了更深入的见解,故作此文来写个人看法,希望也能对大家有帮助。

根据开头推荐的那篇文章,我同样做了一个todoList的简单应用——Eask(Easy Task),项目及打包内容均上传到我的Github:MrPluto0/Easy-task中,可自行查看源码与下载。

引用

入门Electron,手把手教你编写完整实用案例

Electron官方网站