使用Electron打造跨平台桌面应用

https://uinika.github.io/web/server/electron.html

早期桌面应用的开发主要借助原生 C/C++ API 进行,由于需要反复经历编译过程,且无法分离界面 UI 与业务代码,开发调试极为不便。后期出现的 QT 和 WPF 在一定程度上解决了界面代码分离和跨平台的问题,却依然无法避免较长时间的编译过程。近几年伴随互联网行业的迅猛发展,尤其是 NodeJS、Chromium 这类基于 W3C 标准开源应用的不断涌现,原生代码与 Web 浏览器开发逐步走向融合,Electron 正是在这种背景下诞生的。

Electron 是由 Github 开发,通过将Chromium和NodeJS整合为一个运行时环境,实现使用 HTML、CSS、JavaScript 构建跨平台的桌面应用程序的目的。Electron 源于 2013 年 Github 社区提供的开源编辑器 Atom,后于 2014 年在社区开源,并在 2016 年的 5 月和 8 月,通过了 Mac App Store 和 Windows Store 的上架许可,VSCode、Skype 等著名开源或商业应用程序,都是基于 Electron 打造。为了方便编写测试用例,笔者在 Github 搭建了一个简单的 Electron 种子项目Octopus,读者可以基于此来运行本文涉及的示例代码。

Getting Start

首先,让我们通过npm initgit init新建一个项目,然后通过如下npm语句安装最新的 Electron 稳定版。

1
➜ npm i -D electron@latest

然后向项目目录下的package.json文件添加一条scripts语句,便于后面通过npm start命令启动 Electron 应用。

1
2
3
4
5
6
7
8
9
10
11
{
  // ... ...
  "author": "Hank",
  "main": "resource/main.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^3.0.7"
  }
}

然后在项目根目录下新建resource文件夹,里面分别再建立index.htmlmain.js两个文件,最终形成如下的项目结构:

1
2
3
4
5
6
7
8
electron-demo
├── node_modules
├── package.json
├── package-lock.json
├── README.md
└── resource
    ├── index.html
    └── main.js

main.js是 Electron 应用程序的主入口点,当在命令行运行这段程序的时候,就会启动一个 Electron 的主进程,主进程当中可以通过代码打开指定的 Web 页面去展示 UI。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/** main.js */
const { app, BrowserWindow } = require("electron");

let mainWindow;

app.on("ready", () => {
  mainWindow = new BrowserWindow({ width: 800, height: 500 });

  mainWindow.setMenu(null);

  // mainWindow.loadFile("index.html"); // 隐藏Chromium菜单
  // mainWindow.webContents.openDevTools() // 开启调试模式

  mainWindow.on("closed", () => {
    mainWindow = null;
  });
});

app.on("window-all-closed", () => {
  /* 在Mac系统用户通过Cmd+Q显式退出之前,保持应用程序和菜单栏处于激活状态。*/
  if (process.platform !== "darwin") {
    app.quit();
  }
});

app.on("activate", () => {
  /* 当dock图标被点击并且不会有其它窗口被打开的时候,在Mac系统上重新建立一个应用内的window。*/
  if (mainWindow === null) {
    createWindow();
  }
});

Web 页面index.html运行在自己的渲染进程当中,但是能够通过 NodeJS 提供的 API 去访问操作系统的原生资源(例如下面代码中的process.versions语句),这正是 Electron 能够跨平台执行的原因所在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32


  
    
    Hello Electron
  
  
    

你好,Electron!

当前Electron版本:

当前NodeJS版本:

当前Chromium版本:

使用命令行工具执行npm start命令之后,上述 HTML 代码在笔者 Linux 操作系统内被渲染为如下界面。应用当中,可以通过CTRL+R重新加载页面,或者使用CTRL+SHIFT+I打开浏览器控制台。

一个 Electron 应用的主进程只会有一个,渲染进程则会有多个。

主进程与渲染进程

  • 主进程main process)管理所有的 web 页面以及相应的渲染进程,它通过BrowserWindow来创建视图页面。
  • 渲染进程renderer processes)用来运行页面,每个渲染进程都对应自己的BrowserWindow实例,如果实例被销毁那么渲染进程就会被终止。

Electron 分别在主进程渲染进程提供了大量 API,可以通过require语句方便的将这些 API 包含在当前模块使用。但是 Electron 提供的 API 只能用于指定进程类型,即某些 API 只能用于渲染进程,而某些只能用于主进程,例如上面提到的BrowserWindow就只能用于主进程。

1
2
3
const { BrowserWindow } = require("electron");

ccc = new BrowserWindow();

Electron 通过remote模块暴露一些主进程的 API,如果需要在渲染进程中创建一个BrowserWindow实例,那么就可以借助这个 remote 模块:

1
2
3
4
const { remote } = require("electron"); // 获取remote模块
const { BrowserWindow } = remote; // 从remote当中获取BrowserWindow

const browserWindow = new BrowserWindow(); // 实例化获取的BrowserWindow

Electron 可以使用所有 NodeJS 上提供的 API,同样只需要简单的require一下。

1
2
3
const fs = require("fs");

const root = fs.readdirSync("/");

当然,NodeJS 上数以万计的 npm 包也同样在 Electron 可用,当然,如果是涉及到底层 C/C++的模块还需要单独进行编译,虽然这样的模块在 npm 仓库里并不多。

1
const S3 = require("aws-sdk/clients/s3");

既然 Electron 本质是一个浏览器 + 跨平台中间件的组合,因此常用的前端调试技术也适用于 Electron,这里可以通过CTRL+SHIFT+I手动开启 Chromium 的调试控制台,或者通过下面代码在开发模式下自动打开:

1
mainWindow.webContents.openDevTools(); // 开启调试模式

核心模块

本节将对require("electron")所获取的模块进行概述,便于后期进行分类查找。

app 模块

Electron 提供的app模块即提供了可用于区分开发和生产环境的app.isPackaged属性,也提供了关闭窗口的app.quit()和用于退出程序的app.exit()方法,以及window-all-closedready等 Electron 程序事件。

1
2
3
4
const { app } = require("electron");
app.on("window-all-closed", () => {
  app.quit(); // 当所有窗口关闭时退出应用程序
});

可以使用app.getLocale()获取当前操作系统的国际化信息。

BrowserWindow 模块

工作在主进程,用于创建和控制浏览器窗口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 主进程中使用如下方式获取。
const { BrowserWindow } = require("electron");

// 渲染进程中可以使用remote属性获取。
const { BrowserWindow } = require("electron").remote;

let window = new BrowserWindow({ width: 800, height: 600 });
window.on("closed", () => {
  win = null;
});

// 加载远程URL
window.loadURL("https://uinika.github.io/");

// 加载本地HTML
window.loadURL(`file://${__dirname}/app/index.html`);

例如需要创建一个无边框窗口的 Electron 应用程序,只需将BrowserWindow配置对象中的frame属性设置为false即可:

1
2
3
const { BrowserWindow } = require("electron");
let window = new BrowserWindow({ width: 800, height: 600, frame: false });
window.show();

例如加载页面时,渲染进程第一次完成绘制时BrowserWindow会发出ready-to-show事件。

1
2
3
4
5
const { BrowserWindow } = require("electron");
let win = new BrowserWindow({ show: false });
win.once("ready-to-show", () => {
  win.show();
});

对于较为复杂的应用程序,ready-to-show事件的发出可能较晚,会让应用程序的打开显得缓慢。 这种情况下,建议通过backgroundColor属性设置接近应用程序背景色的方式显示窗口,从而获取更佳的用户体验。

1
2
3
4
const { BrowserWindow } = require("electron");

let window = new BrowserWindow({ backgroundColor: "#272822" });
window.loadURL("https://uinika.github.io/");

如果想要创建子窗口,那么可以使用parent选项,此时子窗口将总是显示在父窗口的顶部。

1
2
3
4
5
6
7
const { BrowserWindow } = require("electron");

let top = new BrowserWindow();
let child = new BrowserWindow({ parent: top });

child.show();
top.show();

创建子窗口时,如果需要禁用父窗口,那么可以同时设置modal选项。

1
2
3
4
5
6
7
8
const { BrowserWindow } = require("electron");

let child = new BrowserWindow({ parent: top, modal: true, show: false });

child.loadURL("https://uinika.github.io/");
child.once("ready-to-show", () => {
  child.show();
});

globalShortcut 模块

使用globalShortcut模块中的register()方法注册快捷键。

1
2
3
4
5
6
7
8
const { app, globalShortcut } = require("electron");

app.on("ready", () => {
  // 注册一个快捷键监听器。
  globalShortcut.register("CommandOrControl+Y", () => {
    // 当按下Control +Y键时触发该回调函数。
  });
});

Linux 和 Windows 上【Command】键会失效, 所以要使用 CommandOrControl(既 MacOS 上是【Command】键 ,Linux 和 Windows 上是【Control】键)。

clipboard 模块

用于在系统剪贴板上执行复制和粘贴操作,包含有readText()writeText()readHTML()writeHTML()readImage()writeImage()等方法。

1
2
const { clipboard } = require("electron");
clipboard.writeText("一些字符串内容");

globalShortcut 模块

用于在 Electron 应用程序失去键盘焦点时监听全局键盘事件,即在操作系统中注册或注销全局快捷键。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const { app, globalShortcut } = require("electron");

app.on("ready", () => {
  // 注册全局快捷键
  const regist = globalShortcut.register("CommandOrControl+A", () => {
    console.log("快捷键被摁下!");
  });

  if (!regist) {
    console.log("注册失败!");
  }
  // 检查快捷键是否注册成功
  console.log(globalShortcut.isRegistered("CommandOrControl+A"));
});

app.on("will-quit", () => {
  // 注销快捷键
  globalShortcut.unregister("CommandOrControl+A");
  // 清空所有快捷键
  globalShortcut.unregisterAll();
});

ipcMain 与 ipcRenderer 模块

用于主进程到渲染进程的异步通信,下面是一个主进程与渲染进程之间发送和处理消息的例子:

1
2
3
4
5
6
7
8
9
10
11
// 主进程
const { ipcMain } = require("electron");
ipcMain.on("asynchronous-message", (event, arg) => {
  console.log(arg); // 打印 "ping"
  event.sender.send("asynchronous-reply", "pong");
});

ipcMain.on("synchronous-message", (event, arg) => {
  console.log(arg); // 打印 "ping"
  event.returnValue = "pong";
});
1
2
3
4
5
6
7
8
//渲染器进程,即网页
const { ipcRenderer } = require("electron");
console.log(ipcRenderer.sendSync("synchronous-message", "ping")); // 打印 "pong"

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

如果需要完成渲染器进程到主进程的异步通信,可以选择使用ipcRenderer对象。

用于主进程,用于创建原生应用菜单和上下文菜单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { app, BrowserWindow, Menu } = require("electron");

let mainWindow;

const template = [
  {
    label: "自定义菜单",
    submenu: [{ label: "菜单项-1" }, { label: "菜单项-2" }]
  }
];

app.on("ready", () => {
  mainWindow = new BrowserWindow({ width: 800, height: 500 });
  mainWindow.setMenu(Menu.buildFromTemplate(template));
  mainWindow.loadFile("resource/index.html");
});

使用MenuItem类可以添加菜单项至 Electron 应用程序菜单和上下文菜单当中。

netLog 模块

用于记录网络日志。

1
2
3
4
5
6
7
const { netLog } = require("electron");

netLog.startLogging("/user/log.info");
/** 一些网络事件发生之后 */
netLog.stopLogging(path => {
  console.log("网络日志log.info保存在", path);
});

powerMonitor 模块

通过 Electron 提供的powerMonitor模块监视当前电脑电源状态的改变,值得注意的是,在app模块的ready事件被触发之前, 不能引用或使用该模块。

1
2
3
4
5
6
7
8
const electron = require("electron");
const { app } = electron;

app.on("ready", () => {
  electron.powerMonitor.on("suspend", () => {
    console.log("系统将要休眠了!");
  });
});

powerSaveBlocker 模块

阻止操作系统进入低功耗 (休眠) 模式。

1
2
3
4
5
6
const { powerSaveBlocker } = require("electron");

const ID = powerSaveBlocker.start("prevent-display-sleep");
console.log(powerSaveBlocker.isStarted(ID));

powerSaveBlocker.stop(ID);

protocol 模块

注册自定义协议并拦截基于现有协议的请求,例如下面代码实现了一个与[file://]协议等效的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { app, protocol } = require("electron");
const path = require("path");

app.on("ready", () => {
  protocol.registerFileProtocol(
    "uinika",
    (request, callback) => {
      const url = request.url.substr(7);
      callback({ path: path.normalize(`${__dirname}/${url}`) });
    },
    error => {
      if (error) console.error("协议注册失败!");
    }
  );
});

net 模块

net模块是一个发送 HTTP(S) 请求的客户端 API,类似于 NodeJS 的 HTTP 和 HTTPS 模块 ,但底层使用的是 Chromium 原生网络库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const { app } = require("electron");

app.on("ready", () => {
  const { net } = require("electron");
  const request = net.request("https://zhihu.com/people/uinika/activities");

  request.on("response", response => {
    console.log(`STATUS: ${response.statusCode}`);
    console.log(`HEADERS: ${JSON.stringify(response.headers)}`);

    response.on("data", chunk => {
      console.log(`BODY: ${chunk}`);
    });

    response.on("end", () => {
      console.log("没有更多数据!");
    });
  });

  request.end();
});

Electron 中提供的ClientRequest类用来发起 HTTP/HTTPS 请求,IncomingMessage类则用于响应 HTTP/HTTPS 请求。

remote 模块

remote模块返回的每个对象都表示主进程中的一个对象,调用这个对象实质是在发送同步进程消息。因为 Electron 当中 GUI 相关的模块 (如 dialogmenu 等) 仅在主进程中可用, 在渲染进程中不可用,所以remote模块提供了一种渲染进程(Web 页面)与主进程(IPC)通信的简单方法。remote 模块包含了一个remote.require(module)

  • remote.process:主进程中的process对象,与remote.getGlobal("process")作用相同, 但结果已经被缓存。
  • remote.getCurrentWindow():返回BrowserWindow,即该网页所属的窗口。
  • remote.getCurrentWebContents():返回WebContents,即该网页的 Web 内容
  • remote.getGlobal(name):该方法返回主进程中名为name的全局变量。
  • remote.require(module):返回主进程内执行require(module)时返回的对象,参数module指定的模块相对路径将会相对于主进程入口点进行解析。
1
2
3
4
5
6
7
project/
├── main
│   ├── helper.js
│   └── index.js
├── package.json
└── renderer
    └── index.js
1
2
3
4
5
6
7
8
9
10
11
// 主进程: main/index.js
const { app } = require("electron");
app.on("ready", () => {
  /* ... */
});

// 主进程关联的模块: main/test.js
module.exports = "This is a test!";

// 渲染进程: renderer/index.js
const helper = require("electron").remote.require("./helper"); // This is a test!

remote模块提供的主进程与渲染进程通信方法比ipcMain/ipcRenderer更加易于使用。

screen 模块

检索有关屏幕大小、显示器、光标位置等信息,应用的ready事件触发之前,不能使用该模块。下面的示例代码,创建了一个可以自动全屏窗口的应用:

1
2
3
4
5
6
7
8
9
10
const electron = require("electron");
const { app, BrowserWindow } = electron;

let window;

app.on("ready", () => {
  const { width, height } = electron.screen.getPrimaryDisplay().workAreaSize;
  window = new BrowserWindow({ width, height });
  window.loadURL("https://github.com");
});

shell 模块

提供与桌面集成相关的功能,例如可以通过调用操作系统默认的应用程序管理文件或Url

1
2
3
const { shell } = require("electron");

shell.openExternal("https://github.com");

systemPreferences 模块

获取操作系统特定的偏好信息,例如在 Mac 下可以通过下面代码获取当前是否开启系统 Dark 模式的信息。

1
2
const { systemPreferences } = require("electron");
console.log(systemPreferences.isDarkMode()); // 返回一个布尔值。

Tray 模块

用于主进程,添加图标和上下文菜单至操作系统通知区域。

1
2
3
4
5
6
7
8
9
10
const { app, Menu, Tray } = require("electron");

let tray = null;

app.on("ready", () => {
  tray = new Tray("/images/icon");
  const contextMenu = Menu.buildFromTemplate([{ label: "Item1", type: "radio" }, { label: "Item2", type: "radio" }, { label: "Item3", type: "radio", checked: true }, { label: "Item4", type: "radio" }]);
  tray.setToolTip("This is my application.");
  tray.setContextMenu(contextMenu);
});

webFrame 模块

定义当前网页渲染的一些属性,比如缩放比例、缩放等级、设置拼写检查、执行 JavaScript 脚本等等。

1
2
const { webFrame } = require("electron");
webFrame.setZoomFactor(5); // 将页面缩放至500%。

session 模块

Electron 的session模块可以创建新的session对象,主要用来管理浏览器会话、cookie、缓存、代理设置等等。

如果需要访问现有页面的session,那么可以通过BrowserWindow对象的webContentssession属性来获取。

1
2
3
4
5
6
7
const { BrowserWindow } = require("electron");

let window = new BrowserWindow({ width: 600, height: 900 });
window.loadURL("https://uinika.github.io/web/server/electron.html");

const mySession = window.webContents.session;
console.log(mySession.getUserAgent());

Electron 里也可以通过session模块的cookies属性来访问浏览器的 Cookie 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { session } = require("electron");

// 查询所有cookies。
session.defaultSession.cookies.get({}, (error, cookies) => {
  console.log(error, cookies);
});

// 查询当前URL下的所有cookies。
session.defaultSession.cookies.get({ url: "http://www.github.com" }, (error, cookies) => {
  console.log(error, cookies);
});

// 设置cookie
const cookie = { url: "https://www.zhihu.com/people/uinika/posts", name: "hank", value: "zhihu" };
session.defaultSession.cookies.set(cookie, error => {
  if (error) console.error(error);
});

使用SessionWebRequest属性可以访问WebRequest类的实例,WebRequest类可以在 HTTP 请求生命周期的不同阶段修改相关内容,例如下面代码为 HTTP 请求添加了一个User-Agent协议头:

1
2
3
4
5
6
7
8
9
10
11
const { session } = require("electron");

// 发送至下面URL地址的请求将会被添加User-Agent协议头
const filter = {
  urls: ["https://*.github.com/*", "*://electron.github.io"]
};

session.defaultSession.webRequest.onBeforeSendHeaders(filter, (details, callback) => {
  details.requestHeaders["User-Agent"] = "MyAgent";
  callback({ cancel: false, requestHeaders: details.requestHeaders });
});

desktopCapturer 模块

用于捕获桌面窗口里的内容,该模块只拥有一个方法:desktopCapturer.getSources(options, callback)

  1. options 对象
    • types:字符串数组,列出需要捕获的桌面类型是screen还是window
    • thumbnailSize:媒体源缩略图的大小,默认为150x150
  2. callback 回调函数,拥有如下 2 个参数:
    • error:错误信息。
    • sources:捕获的资源数组。

如下代码工作在渲染进程当中,作用是将桌面窗口捕获为视频:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const { desktopCapturer } = require("electron");

desktopCapturer.getSources({ types: ["window", "screen"] }, (error, sources) => {
  if (error) throw error;
  for (let i = 0; i < sources.length; ++i) {
    if (sources[i].name === "Electron") {
      navigator.mediaDevices
        .getUserMedia({
          audio: false,
          video: {
            mandatory: {
              chromeMediaSource: "desktop",
              chromeMediaSourceId: sources[i].id,
              minWidth: 1280,
              maxWidth: 1280,
              minHeight: 800,
              maxHeight: 800
            }
          }
        })
        .then(stream => handleStream(stream))
        .catch(error => handleError(error));
      return;
    }
  }
});

function handleStream(stream) {
  const video = document.querySelector("video");
  video.srcObject = stream;
  video.onloadedmetadata = error => video.play();
}

function handleError(error) {
  console.log(error);
}

dialog 模块

调用操作系统原生的对话框,工作在主线程,下面示例展示了一个用于选择多个文件和目录的对话框:

1
2
const { dialog } = require("electron");
console.log(dialog.showOpenDialog({ properties: ["openFile", "openDirectory", "multiSelections"] }));

由于对话框工作在 Electron 的主线程上,如果需要在渲染器进程中使用, 那么可以通过remote来获得:

1
2
const { dialog } = require("electron").remote;
console.log(dialog);

contentTracing 模块

从 Chromium 收集跟踪数据,从而查找性能瓶颈。使用后需要在浏览器打开chrome://tracing/页面,然后加载生成的文件查看结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { app, contentTracing } = require("electron");

app.on("ready", () => {
  const options = {
    categoryFilter: "*",
    traceOptions: "record-until-full,enable-sampling"
  };

  contentTracing.startRecording(options, () => {
    console.log("开始跟踪!");

    setTimeout(() => {
      contentTracing.stopRecording("", path => {
        console.log("跟踪数据已经记录至" + path);
      });
    }, 8000);
  });
});

webview 标签

Electron 的标签基于 Chromium,由于开发变动较大官方并不建议使用,而应考虑