Electron App 的初步优化
最近用 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 开发之路也算是入门了吧。