最近用 React + Electron + Ant Design 開發了一個 app. 經過一番折騰,雖能 build 出來能正常運行,但碰到兩個問題影響到了 app 的性能:
- 功能簡單的程序竟有三百多兆容量——需要瘦身。
- React 程序如何正確調用 Electron 的 NodeJS 模塊功能。
下面記錄一下解決過程。
瘦身
搜了一下,貌似沒有辦法通過剪裁 Electron 中 Chromium 各種無用功能來減少 app 體積。要不就用別人做的魔改版——並不是很想。終於,在 stackoverflow 站找到了良法:
I managed to reduce the final size of my mac app from 250MB to 128MB by moving 'electron' and my reactJs dependencies to devDependencies in package.json ... since all I need is going to be in the final bundle.js
確實,很多按常理放在 dependencies
的東西,其實都可以放到 devDependencies
中去,因為 react-scripts 會通過 Webpack 把在 React 項目中用到的各種庫都打包壓縮到 build 文件夾里,之后讓 Electron 根據 dependencies
所列再打包一遍,毫無意義,build 根本不會用到。照帖中所說,騰挪之后,用 electron-builder
打包,速度快了不少,打包后,程序大小從 368MB 銳減至 185MB,安裝包更是只有 57.4MB。絕了,這數字讓減肥廚狂喜。
瘦身后,運行程序,一切正常。瘦身成功。
渲染進程與主進程的通信
對於 Electron desktop app 而言,如果不能讓“網頁”調用到 node 模塊的功能,那 Electron 的包裝就失去了意義。在 app 中,React 網頁在渲染進程,Electron 和 node 模塊在主進程,如何連接兩者是關鍵。
Bad
起初,我用了一種比較笨的辦法,讓 React 直接調用 electron 主進程的對象。方法是這樣的,首先在配置窗口屬性的時候加入:
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
preload: path.join(__dirname, 'preload.js')
}
nodeIntergration
開啟后,使 Electron 集成了 node 的功能,可以使用 require 等方法了。 [contextIsolation
](https://www.electronjs.org/docs/tutorial/context-isolation) 關閉后,渲染進程和主進程就有了共同的上下文對象。
簡而言之,預加載腳本 (preload.js) 和渲染進程可以共用 window 對象。比如,我在 preload.js
中可以這樣寫:
const fs = require('fs-extra');
window.fs = fs;
然后在 React 代碼中就可以通過 window 調用到 fs 了:
// React scripts
// const fs = require('fs-extra'); // ✖️
const fs = window.fs; // ✔️
這樣確實很方便,於是我把很多要用到的 node 模塊都放在 preload.js
去加載,一路開發下去,暢通無阻。但是,我忽然發現,React 如果用到讀取文件的方法時,只能在第一次渲染出來時起作用,用 Electron app 的 reload 重新加載頁面,就不會再讀取。這導致我為了調試,只能關掉 electron 腳本重開,才能正確讀取到本地自定義的配置文件。而且,如果 preload 過多地通過 require()
加載模塊,也會影響程序的啟動速度。最重要地,官方也不推薦這種做法:Do not enable Node.js Integration for Remote Content, Enable Context Isolation for Remote Content.
出於安全的考慮,Electron 官方希望我們關閉 node 的集成並使用獨立的上下文:
webPreferences: {
nodeIntegration: false, // default
contextIsolation: true, // default
preload: path.join(__dirname, 'preload.js')
}
取而代之的,是讓我們通過 API 接口去進行渲染與底層的通信——也是前后端分離的思想。
Good
我在 stackoverflow 找到一個不錯的實踐方式,簡述如下。
首先,在入口腳本處 (main.js) 定義調用 node 模塊的各種方法:
// main.js
const { app, BrowserWindow, ipcMain };
const fs = require('fs');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
//...
});
}
app.on('ready', createWindow);
ipcMain.on('toMain', (event, args) => {
fs.readFile('path/to/file', (error, data) => {
// 用 node 的 fs 模塊進行文件處理。
// response = ...
// 將結果發送給 webContents,等待渲染進程去獲取。
mainWindow.webContents.send('fromMain', response);
});
});
其中,'toMain' 是渲染進程向主進程發送請求的頻道名,'fromMain' 是渲染進程從主進程取回響應的頻道名。如何在渲染進程和主進程之間架起溝通的橋梁,是在預加載腳本中要做的事:
const { contextBridge, ipcRenderer } = require('electron');
// 只暴露 API 方法,不暴漏完整對象。
contextBridge.exposeInMainWorld(
'api', {
send: (channel, data) => {
// 注冊請求名。
let validChannels = ['toMain'];
if (validChannels.includes(channel)) {
// ipcRenderer 向主進程發送請求。
ipcRenderer.send(channel, data);
}
},
receive: (channel, func) => {
// 注冊響應名。
let validChannels = ['formMain'];
if (validChannels.includes(channel)) {
// 渲染進程通過 channel 名調用接收方法,從主進程拿到響應內容,再用自己的方法進行處理。
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
}
}
);
預加載腳本只需加載“發送”和“接收”兩種方法即可,這樣啟動時的加載時間就減少了。
最后在 React 渲染進程的腳本中是這樣調用的:
const loadConfig = (setFunction) => {
// 向主進程發送請求。主進程通過 .webContents.send() 將響應結果發送給 fromMain 處理。
window.api.send('inMain');
// 通過 fromMain 接收響應結果,然后對其進行處理。
window.api.receive('fromMain', result => {
if (typeof setFunction !== 'function') {
return;
}
setFunction(result);
});
};
整個通信過程有點像:組織上 (preload) 規定了我獲取情報的方式,然后我 (renderer) 通過暗號 ("toMain") 向 (send) 某部門 (main) 要情報,某部門搞定后,將情報放在某個地方,讓我用口令 ("fromMain") 去取 (receive),取回來之后我再另作處理……
雖然看上去有點復雜,但多實操幾遍,就能領悟其中道理,進而逐漸體會到此法之妙。改了之后,之前遇到的 reload 無法再次加載配置文件的問題就自然解決了,運行 app 也感覺流暢了許多。
我想我的 Electron 開發之路也算是入門了吧。