使用Electron打造跨平臺桌面應用

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

早期桌面應用的開發主要藉助原生 C/C++ API 進行,由於需要反覆經歷編譯過程,且無法分離界面 UI 與業務代碼,開發調試極爲不便。後期出現的 QT 和 WPF 在一定程度上解決了界面代碼分離和跨平臺的問題,卻依然無法避免較長時間的編譯過程。近幾年伴隨互聯網行業的迅猛發展,尤其是 NodeJS、Chromium 這類基於 W3C 標準開源應用的不斷涌現,原生代碼與 Web 瀏覽器開發逐步走向融合,Electron 正是在這種背景下誕生的。

Electron 是由 Github 開發,通過將ChromiumNodeJS整合爲一個運行時環境,實現使用 HTML、CSS、JavaScript 構建跨平臺的桌面應用程序的目的。Electron 源於 2013 年 Github 社區提供的開源編輯器 Atom,後於 2014 年在社區開源,並在 2016 年的 5 月和 8 月,通過了 Mac App Store 和 Windows Store 的上架許可,VSCode、Skype 等著名開源或商業應用程序,都是基於 Electron 打造。爲了方便編寫測試用例,筆者在 Github 搭建了一個簡單的 Electron 種子項目Octopus,讀者可以基於此來運行本文涉及的示例代碼。

construction

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
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello Electron</title>
  </head>
  <body>
    <h1>你好,Electron!</h1>
    <!-- 所有NodeJS可用的API都可以通過renderer.js的process屬性訪問 -->
    <h2>
      當前Electron版本:
      <script>
        document.write(process.versions.electron);
      </script>
    </h2>
    <h2>
      當前NodeJS版本:<script>
        document.write(process.versions.node);
      </script>
    </h2>
    <h2>
      當前Chromium版本:
      <script>
        document.write(process.versions.chrome);
      </script>
    </h2>
    <script>
      // 這裏也可以包含運行在當前進程裏的其它文件
      require("./renderer.js");
    </script>
  </body>
</html>

使用命令行工具執行npm start命令之後,上述 HTML 代碼在筆者 Linux 操作系統內被渲染爲如下界面。應用當中,可以通過CTRL+R重新加載頁面,或者使用CTRL+SHIFT+I打開瀏覽器控制檯。

hello-electron

一個 Electron 應用的主進程只會有一個,渲染進程則會有多個。

主進程與渲染進程

  • 主進程main process)管理所有的 web 頁面以及相應的渲染進程,它通過BrowserWindow來創建視圖頁面。
  • 渲染進程renderer processes)用來運行頁面,每個渲染進程都對應自己的BrowserWindow實例,如果實例被銷燬那麼渲染進程就會被終止。

structure

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 應用程序菜單和上下文菜單當中。

menu

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 的<webview>標籤基於 Chromium,由於開發變動較大官方並不建議使用,而應考慮<iframe>或者 Electron 的BrowserView等選擇,或者完全避免在頁面進行內容嵌入。

<webview><iframe>最大不同是運行於不同的進程當中,Electron 應用程序與嵌入內容之間的所有交互都是異步進行的,這樣可以保證應用程序與嵌入內容雙方的安全。

1
<webview id="uinika" src="http://localhost:5000/web/server/electron.html"></webview>

webContents 屬性

webContentsBrowserWindow對象的一個屬性,負責渲染和控制 Web 頁面。

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

let window = new BrowserWindow({ width: 600, height: 500 });
window.loadURL("https://uinika.github.io/");

let contents = window.webContents;
console.log(contents);

window.open() 函數

該函數用於打開一個新窗口並加載指定url,調用後將會爲該url創建一個BrowserWindow實例,並返回一個BrowserWindowProxy對象,但是該對象只能對打開的url頁面進行有限的控制。正常情況下,如果希望完全控制新窗口,可以直接創建一個新的BrowserWindow

1
2
// window.open(url[, frameName][, features])
window.open("https://github.com", "_blank", "nodeIntegration=no");

BrowserWindowProxy對象擁有如下屬性和方法:

  • win.closed:子窗口關閉後設置爲true的布爾屬性。
  • win.blur():將焦點從子窗口中移除。
  • win.close():強制關閉子窗口, 而不調用其卸載事件。
  • win.eval(code)code字符串,需要在子窗口 Eval 的代碼。
  • win.focus():聚焦子窗口(即將子窗口置頂)。
  • win.print():調用子窗口的打印對話框。
  • win.postMessage(message, targetOrigin):向子窗口發送信息。

Electron 進程

Electron 的process對象繼承自 NodeJS 的process對象,但是新增了一些有用的事件、屬性、方法。

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

let mainWindow;

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

  console.log(process.type); // 當前進程類型是browser主進程還是renderer渲染進程,browser
  console.log(process.versions.node); // NodeJS版本,10.2.0
  console.log(process.versions.chrome); // Chrome版本,66.0.3359.181
  console.log(process.versions.electron); //Electron版本,3.0.13
  console.log(process.resourcesPath); // 資源目錄路徑,D:\Workspace\octopus\node_modules\electron\dist\resources
});

沙箱機制

Chromium 通過將 Web 前端代碼放置在一個與操作系統隔離的沙箱中運行,從而保證惡意代碼不會侵犯到操作系統本身。但是 Electron 中渲染進程可以調用 NodeJS,而 NodeJS 又需要涉及大量操作系統調用,因而沙箱機制默認是禁用的。

某些應用場景下,需要運行一些不確定安全性的外部前端代碼,爲了保證操作系統安全,可能需要開啓沙箱機制。此時首先在創建BrowserWindow時傳入sandbox屬性,然後在命令行添加--enable-sandbox參數傳遞給 Electron 即可完成開啓。

1
2
3
4
5
6
7
8
9
let win;
app.on("ready", () => {
  window = new BrowserWindow({
    webPreferences: {
      sandbox: true
    }
  });
  window.loadURL("http://google.com");
});

使用sandbox選項之後,將會阻止 Electron 在渲染器中創建一個 NodeJS 運行時環境,此時新窗口中的window.open() 將按照瀏覽器原生的方式工作。

MacBook TouchBar 支持

針對 Mac 筆記本電腦上配置的 TouchBar 硬件,Electron 提供了一系列相關的類與操作接口:TouchBarTouchBarButtonTouchBarColorPickerTouchBarGroupTouchBarLabelTouchBarPopoverTouchBarScrubberTouchBarSegmentedControlTouchBarSliderTouchBarSpacer

創建應用圖標

用於將 PNG 或 JPG 圖片設置爲托盤、Dock 和應用程序的圖標。

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

const Icon = new Tray("/images/icon.png");

let window = new BrowserWindow({ icon: "/images/window.png" });

安全原則

由於 Electron 的發佈通常落後最新版本 Chromium 幾周甚至幾個月,因此特別需要注意如下這些安全性問題:

使用安全的協議加載外部內容

外部資源儘量使用更安全的協議加載,比如HTTP換成HTTPSWS換成WSSFTP換成FTPS等。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 錯誤 -->
<script crossorigin src="http://cdn.com/react.js"></script>
<link rel="stylesheet" href="http://cdn.com/scss.css" />

<!-- 正確 -->
<script crossorigin src="https://cdn.com/react.js"></script>
<link rel="stylesheet" href="https://cdn.com/scss.css" />

<script>
  browserWindow.loadURL("http://uinika.github.io/); // 錯誤
  browserWindow.loadURL("https://uinika.github.io/"); // 正確
</script>

加載外部內容時禁用 NodeJS 集成

使用BrowserWindowBrowserView<webview>加載遠程內容時,都需要通過禁用 NodeJS 集成去限制遠程代碼的執行權限,避免惡意代碼跨站攻擊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 錯誤 -->
<webview nodeIntegration src="page.html"></webview>

<!-- 正確 -->
<webview src="page.html"></webview>

<script>
  /** 錯誤 */
  const mainWindow = new BrowserWindow();
  mainWindow.loadURL("https://my-website.com");

  /** 正確 */
  const mainWindow = new BrowserWindow({
    webPreferences: {
      nodeIntegration: false,
      preload: "./preload.js"
    }
  });
</script>

對於需要與遠程代碼共享的變量或函數,可以通過將其掛載至當前頁面的window全局對象來實現。

渲染進程中啓用上下文隔離

上下文隔離是 Electron 提供的試驗特性,通過爲遠程加載的代碼創造一個全新上下文環境,避免與主進程中的代碼出現衝突或者相互污染。

1
2
3
4
5
6
7
// 主進程
const mainWindow = new BrowserWindow({
  webPreferences: {
    contextIsolation: true,
    preload: "preload.js"
  }
});

處理遠程內容中的會話許可

當頁面嘗試使用某個特性時,會彈出通知讓用戶手動進行確認;而默認情況下,Electron 會自動批准所有的許可請求。

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

session.fromPartition("some-partition").setPermissionRequestHandler((webContents, permission, callback) => {
  const url = webContents.getURL();

  if (permission === "notifications") {
    callback(true); // 通過許可請求
  }

  if (!url.startsWith("https://my-website.com")) {
    return callback(false); // 拒絕許可請求
  }
});

不要禁用 webSecurity

在渲染進程禁用webSecurity將導致許多重要的安全性功能被關閉,因此 Electron 默認開啓。

1
2
3
4
5
const mainWindow = new BrowserWindow({
  webPreferences: {
    webSecurity: false // 錯誤的做法,缺省該屬性使用默認值即可。
  }
});

定義 CSP 安全策略

內容安全策略 CSP 允許 Electron 通過webRequest對指定 URL 的訪問進行約束,例如允許加載https://uinika.github.io/這個源,那麼https://hack.attacker.com將不會被允許加載,CSP 是處理跨站腳本攻擊、數據注入攻擊的另外一層保護措施。

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

session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
  callback({
    responseHeaders: {
      ...details.responseHeaders,
      "Content-Security-Policy": ["default-src 'none'"]
    }
  });
});

使用file://協議打開本地文件時,可以通過元數據標籤<meta>的屬性來添加 CSP 約束。

1
<meta http-equiv="Content-Security-Policy" content="default-src 'none'" />

別設置 allowRunningInsecureContent 爲 true

Electron 默認不允許在 HTTPS 頁面中加載 HTTP 來源的代碼,如果將allowRunningInsecureContent屬性設置爲true會禁用這種保護。

1
2
3
4
5
const mainWindow = new BrowserWindow({
  webPreferences: {
    allowRunningInsecureContent: true // 錯誤的做法,缺省該屬性使用默認值即可。
  }
});

不要開啓實驗性功能

開發人員可以通過experimentalFeatures屬性啓用未經嚴格測試的 Chromium 實驗性功能,不過 Electron 官方出於穩定性和安全性考慮並不建議這樣做。

1
2
3
4
5
const mainWindow = new BrowserWindow({
  webPreferences: {
    experimentalFeatures: true // 錯誤的做法,缺省該屬性使用默認值即可。
  }
});

不要使用 enableBlinkFeatures

Blink 是 Chromium 內置的 HTML/CSS 渲染引擎,開發者可以通過enableBlinkFeatures啓用其某些默認是禁用的特性。

1
2
3
4
5
const mainWindow = new BrowserWindow({
  webPreferences: {
    enableBlinkFeatures: ["ExecCommandInJavaScript"] // 錯誤的做法,缺省該屬性使用默認值即可。
  }
});

禁用 webview 的 allowpopups

開啓allowpopups屬性將使window.open()創建一個新的窗口和BrowserWindows,若非必要狀況,儘量不要使用此屬性。

1
2
3
4
5
<!-- 錯誤 -->
<webview allowpopups src="page.html"></webview>

<!-- 正確 -->
<webview src="page.html"></webview>

驗證 webview 選項與參數

通過渲染進程創建的<WebView>默認不集成 NodeJS,但是它可以通過webPreferences屬性創建出一個獨立的渲染進程。在<WebView>標籤開始渲染之前,Electron 將會觸發一個will-attach-webview事件,可以通過該事件防止創建具有潛在不安全選項的 Web 視圖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.on("web-contents-created", (event, contents) => {
  contents.on("will-attach-webview", (event, webPreferences, params) => {
    // 如果未使用或者驗證位置合法,那麼將會剝離預加載腳本。
    delete webPreferences.preload;
    delete webPreferences.preloadURL;

    // 禁用NodeJS集成。
    webPreferences.nodeIntegration = false;

    // 驗證正在加載的URL。
    if (!params.src.startsWith("https://www.zhihu.com/people/uinika/columns")) {
      event.preventDefault();
    }
  });
});

禁用或限制網頁跳轉

如果 Electron 應用程序不需要導航或只需導航至特定頁面,最佳實踐是將導航限制在已知範圍,並禁止其它類型的導航。可以通過在will- navigation事件處理函數中調用event.preventDefault()並添加額外的判斷來實現這一點。

1
2
3
4
5
6
7
8
9
10
11
const URL = require("url").URL;

app.on("web-contents-created", (event, contents) => {
  contents.on("will-navigate", (event, navigationUrl) => {
    const parsedUrl = new URL(navigationUrl);

    if (parsedUrl.origin !== "https://www.zhihu.com/people/uinika/posts") {
      event.preventDefault();
    }
  });
});

禁用或限制新窗口創建

限制在 Electron 應用程序中創建額外窗口,並避免因此帶來額外的安全隱患。webContents創建新窗口時會觸發一個web-contents-created事件,該事件包含了將要打開的 URL 以及相關選項,可以在這個事件中檢查窗口的創建,從而對其進行相應的限制。

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

app.on("web-contents-created", (event, contents) => {
  contents.on("new-window", (event, navigationUrl) => {
    // 通知操作系統在默認瀏覽器上打開URL
    event.preventDefault();
    shell.openExternal(navigationUrl);
  });
});

Electron 2.0 版本開始,會在可執行文件名爲 Electron 時會爲開發者在控制檯顯示安全相關的警告和建議,開發人員也可以在process.envwindow對象上配置ELECTRON_ENABLE_SECURITY_WARNINGSELECTRON_DISABLE_SECURITY_WARNINGS手動開啓或關閉這些警告。

應用發佈

Electron 的發佈有別於傳統桌面應用程序編譯打包的發部過程,需要首先下載已經預編譯完成的二進制包,Linux 下二進制包結構如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
➜  electron-v3.0.7-linux-x64 tree -L 2
.
├── blink_image_resources_200_percent.pak
├── content_resources_200_percent.pak
├── content_shell.pak
├── electron
├── icudtl.dat
├── libffmpeg.so
├── libnode.so
├── LICENSE
├── LICENSES.chromium.html
├── locales
├── natives_blob.bin
├── ui_resources_200_percent.pak
├── v8_context_snapshot.bin
├── version
└── views_resources_200_percent.pak
└── resources
   ├── default_app.asar
   └── electron.asar

接下來,就可以部署前面編寫的源代碼,Electron 裏主要有如下兩種部署方式:

  1. 直接將代碼放置到resources下的子目錄,比如app目錄:
1
2
3
4
5
6
7
├── app
│   ├── package.json
│   └── resource
│       ├── index.html
│       └── main.js
├── default_app.asar
└── electron.asar
  1. 將應用打包加密爲asar文件以後放置到resources目錄,比如app.asar文件。
1
2
3
├── app.asar
├── default_app.asar
└── electron.asar

asar 打包源碼

asar 是一種簡單的文件擴展格式,可以將文件如同tar格式一樣前後連接在一起,支持隨機讀寫,並使用 JSON 來保存文件信息,可以方便的讀取與解析。Electron 通過它可以解決 Windows 文件路徑長度的限制,提高require語句的加載速度,並且避免源代碼泄漏。

首先,需要安裝asar這個 npm 包,然後可以選擇全局安裝然通過命令行使用。

1
2
➜  npm install asar
➜  asar pack electron-demo app.asar

當然,更加工程化的方式是通過代碼來執行打包操作,就像下面這樣:

1
2
3
4
5
6
7
8
9
10
11
let asar = require("asar");

src = "../octopus";
dest = "build/app.asar";

/** 打包完成後的回調函數 */
callback = () => {
  console.info("asar打包完成!");
};

asar.createPackage(src, dest, callback);

Electron 在 Web 頁面可以通過file:協議讀取 asar 包中的文件,即將 asar 文件視爲一個虛擬的文件夾來進行操作。

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

mainWindow.loadURL("file:///path/to/example.asar/static/index.html");

如果需要對 asar 文件進行 MD5 或者 SHA 完整性校驗,可以對 asar 檔案文件本身進行操作。

1
asar list app.asar

rcedit 編輯可執行文件

RcEdit是一款通過編輯窗口管理器的 rc 文件來對其進行配置的工具,Nodejs 社區提供了node-rcedit工具對 Windows 操作系統的.exe文件進行配置,首先通過npm i rcedit --save-dev爲項目安裝該依賴項。

1
2
3
var rcedit = require("rcedit");

rcedit(exePath, options, callback);

rcedit()函數包含有如下屬性:

  1. exePath:需要進行修改的 Windows 可執行文件所在路徑。
  2. options:一個擁有如下屬性的配置對象。
    • version-string:版本字符串;
    • file-version:文件版本;
    • product-version:產品版本;
    • icon:圖標文件.ico的路徑;
    • requested-execution-level:需要修改的執行級別(asInvokerhighestAvailablerequireAdministrator)。
    • application-manifest:本地清單文件的路徑。
  3. callback:函數執行完畢之後回調,完整的函數簽名爲function(error)

yarn 包管理器

Yarn 是一款由 Facebook 推出的 JavaScript 包管理器,與 NodeJS 提供的 Npm 一樣使用package.json作爲包信息文件。

測試當前 Yarn 的安裝版本:

1
yarn --version

初始化新項目:

1
yarn init

添加依賴包:

1
2
3
yarn add [package]
yarn add [package]@[version]
yarn add [package]@[tag]

將依賴項添加到不同的依賴項類別:devDependencies、peerDependencies 和 optionalDependencies。

1
2
3
yarn add [package] --dev
yarn add [package] --peer
yarn add [package] --optional

升級依賴包:

1
2
3
yarn upgrade [package]
yarn upgrade [package]@[version]
yarn upgrade [package]@[tag]

移除依賴包:

1
yarn remove [package]

可以直接使用yarn命令安裝項目的全部依賴:

1
yarn install

windows-installer

windows-installer是一個用於爲 Electron 應用程序構建 Windows 安裝程序的 Npn 包,底層基於Squirrel一組用於管理C#C++開發的 Windows 應用程序的安裝、更新的工具庫)進行實現。

1
npm install --save-dev electron-winstaller
1
2
3
4
5
6
7
8
9
10
var electronInstaller = require("electron-winstaller");

resultPromise = electronInstaller.createWindowsInstaller({
  appDirectory: "/tmp/build/my-app-64",
  outputDirectory: "/tmp/build/installer64",
  authors: "My App Inc.",
  exe: "myapp.exe"
});

resultPromise.then(() => console.log("It worked!"), e => console.log(`No dice: ${e.message}`));

electron-build

除了像上面這樣通過預編譯包手動打包應用程序,也可以採用electron-forgeelectron-packager等第三方包來完成這項工作,在這裏筆者選擇electron-builder來進行自動化打包任務。

Electron Userland 是一個維護 Electron 模塊的第三方社區,electron-builder 是由其維護的一款能夠同時處理 Windows、MacOS、Linux 多平臺的打包編譯工具。由於 electron-builder 工具包的文件體積較大,其社區強烈推薦使用更快速的yarn來代替npm作爲包管理方案。

1
yarn add electron-builder --dev

electron-builder 能夠以命令行或者JavaScript API的方式進行使用:

(1)如果安裝在項目目錄下的node_modules目錄,可以直接通過 NodeJS 提供的npx以命令行方式使用:

1
npx electron-builder

(2)也可以像使用其它 Npm 包那樣直接調用 electron-builder 提供的 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"use strict";

const builder = require("electron-builder");
const Platform = builder.Platform;

// Promise is returned
builder
  .build({
    targets: Platform.MAC.createTarget(),
    config: {
      "//": "build options, see https://goo.gl/QQXmcV"
    }
  })
  .then(() => {
    // handle result
  })
  .catch(error => {
    // handle error
  });

官方推薦使用electron-webpack-quick-start作爲 Electron 應用的項目模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  // 指定編譯配置
  "build": {
    "appId": "your.id",
    "mac": {
      "category": "your.app.category.type"
    }
  },
  // 添加編譯指令
  "scripts": {
    "pack": "electron-builder --dir",
    "dist": "electron-builder"
  },
  // 確保原生依賴總是匹配Electron版本
  "postinstall": "electron-builder install-app-deps
}

如果項目中存在原生依賴,還需要設置nodeGypRebuildtrue

如果需要調試electron-builder的行爲,那麼需要設置DEBUG=electron-builder環境變量。

1
2
set DEBUG=electron-builder  // Cmder
$env:DEBUG=electron-builder  // PowerShell

electron-forge

electron-forge同樣是由 Electron Userland 維護的一款命令行工具,用於快速建立、打包、發佈一個 Electron 應用程序。

1
2
3
4
λ  npm install -g electron-forge
λ  electron-forge init my-new-project
λ  cd my-new-project
λ  electron-forge start

目前 Github 上 electron-builder 的 Star 遠遠超過 electron-forge,由於筆者項目需要使用 React,因而也就選用帶有支持 React 項目模板的 electron-builder,需要嘗試 electron-forge 的同學可以移步官網查看更多信息。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章