Electron窗口打开速度1s到0.2s
最近项目进行到尾声,就开始对客户端做一些性能方面的优化。首先发现的问题就是其中一类子窗口的打开速度实在不尽人意,但是这些窗口内容并不复杂,就考虑系统性优化一遍,找到影响速度的地方。
首先要说明的是我们客户端一共有三种类型的窗口:
* 软件主界面
* 子窗口A(Modal,Parent是主界面,进程常驻,关闭只隐藏,所以打开是秒开)
* 子窗口类型B (独立窗口,需要就创建,关闭就销毁)
所以子窗口A只会存在1个,而子窗口B是可以同时创建多个的。现在问题就出在窗口类型B。当然如果只是图快,完全可以采用窗口池的概念,预先创建好窗口,需要的时候显示窗口并渲染UI组件即可,但是我们客户端本身就是内存消耗大户,实在没法这么来了,所以还是本着能优化就优化的原则,把问题消灭掉才行,因为这种慢还会影响到软件启动到UI呈现的时间,下面会说。
最初软件的工程师设计的时候采用的是单文件实现的全部功能,所以每个窗口都是载入的同一个html(也是同样的js脚本),所以各种功能都打包在一个js文件中,这个文件没压缩的时候是18MB+,所以这次优化的思路主要是围绕js脚本加载这一个方向去改进。
加载时间分析
首先我使用performance.now()打印了该窗口下列关键点的performance时间:
1、preload.js开始/结束
2、app.js加载前时间
3、第一行js脚本执行时间(单独引入一个js脚本,只打印下时间放在入口的第一行)
4、入口依赖执行完毕之后的时间
5、createApp的时间
6、子窗口组件onMounted的时间
然后就很直观的发现了几个超过0.2s的地方:首先是preload.js 其次是app.js的加载时间 然后是第一行js执行之后到其它依赖模块加载完成的时间,有了这些信息就可以有针对性的去处理了。
preload.js 优化
虽然在Electron开发过程中使用preload.js是很正常的事情,但是经过我测试即使preload.js 只导入一个很简单的模块,它就会阻塞页面后续内容的加载执行,而且很耗时(0.1~0.3)。而且同样的几个模块,使用preload.js加载跟直接在渲染进程通过js脚本加载会更慢,所以我直接干掉了B类型窗口的preload.js,然后在渲染进程入口出通过一个普通的js文件直接把常用的模块挂在window上了。
window.nodeAPI = {
fs: require('fs'),
path: require('path'),
electron: require('electron'),
....
}
当然原来还有一些其它的一些耗时模块,比如logger,既然需要直接转移到主进程执行,然后渲染进程的日志通过IPC通讯发给主进程处理。另外还有一些低频使用的模块,完全没有预加载的必要,可以在需要的时候再加载,webpack配置的时候通过externals处理下:
// webpack.base.config.js
module.exports = {
externals: {
'@electron/remote': 'require("@electron/remote")',
...
}
}
简单概括就是,能不用preload.js就抛弃掉,高频的模块可以在渲染进程放在前面加载好,低频的模块在业务中加载即可,也不用打包到渲染进程的代码中,直接从node_modules中require,这样对于多平台的包也是有益的。
app.js加载速度优化
上面说了,这个项目的入口js打包完有18MB多,很显然这是不合格的,那么我们就要对这个入口文件进行瘦身,首先配置webpack压缩代码:
// webpack.prod.config.js
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
mode: 'production',
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
keep_classnames: true,
},
mangle: {
keep_classnames: true, // 保留类名
keep_fnames: false,
},
},
}),
],
},
}
因为项目的特殊性,需要保留class的名字,所以在配置TerserPlugin的时候就不能去混淆修改class的名字,否则build之后需要实例化特定的类的时候就找不到这些class了。压缩完代码入口js直接减少了50%,载入速度也从500ms减少到了200ms附近。然后就是拆分UI组件,上面提到,我们所有窗口都是采用了同一个js入口文件,然后在代码中判断的窗口渲染哪个组件,类似router的实现:
// renderer/index.ts
import MainWindow from "@/pages/MainWindow.vue"
import Worker from "@/pages/worker.vue"
import ChildWinowA from "@/pages/ChildWinowA.vue"
import ChinldWindowB from "@/pages/ChinldWindowB"
const createAppInstance = () {
if (isMainWindow()) {
return createApp(MainWindow)
}
if (isWorkerWindow()) {
return createApp(Worker)
}
if (isChildWindowA()) {
return createApp(ChildWinowA)
}
if (isChildWindowB()) {
return createApp(ChildWindowB)
}
}
这样所有的组件都会被打包到一个js中,如果在childB窗口中只需要展示某几个简单组件,那一样会载入全部的js包就太可怕了,所以我们需要将与当前窗口无关的组件做成异步组件,然后按需加载:
// renderer/index.ts
const MainWindow = defineAsyncComponent(() => import(“@/pages/MainWindow.vue”))
const ChildWindowA = defineAsyncComponet(() => import("@/pages/ChildWindowA.vue"))
// 然后判断根据需要渲染
const createAppInstance = () {
if (isMainWindow()) {
return createApp(MainWindow)
}
if (isChildWindowA()) {
return createApp(ChildWinowA)
}
if (isChildWindowB()) {
return createApp(ChildWindowB)
}
}
通过上面这个不起眼的改动,入口文件打包后的大小从10MB降到5MB多,因为MainWindow.vue确实比较大。根据这个思路我们接着在childWindowB组件中,把涉及到的几十个UI组件都做成异步,这样打开子窗口的时候只需要加载很少的相关js文件,速度又提升了几十毫秒。
node_modules 包加载优化
上面我针对UI组件做了一些按需加载,实际测试过程中还有一些代码中的错误实践同样会影响加载速度,比如下面代码中的部分:
// 不推荐
import path from "path"; // 全局已经有了,又单独去引用
import { BrowserWindow } from "electron";
export const getMainWindow = (): BrowerWindow => {} // 把导入进来的BrowserWindow当类型用
// 更好的做法
import { type BrowserWindow } from "electron";
const { path, fs } = window.nodeAPI; // 直接使用全局
另外像是一些三方的模块,之前的开发同事图省事这样写:
// index.ts
window.aliOSS = require("ali-oss");
本身就是一个低频使用的模块,放在入口提前加载就没有必要了,而且还会被打包进最终的入口js文件中,对速度的影响很大,这种第三方的sdk建议都是按需使用,不参与打包,直接在渲染进程require即可:
// 比如采用ali-oss上传文件
export uploadFile = () => {
const oss = require('ali-oss');
...
...
}
// 或者使用某sdk
export initWebIM = () => {
const webSDK = require('easemob-websdk');
const im = new webSDK({ ... });
}
// 然后webpack.prod.config.js配置下
module.exports = {
externals: {
'ali-oss': 'require("ali-oss")',
'easemob-websdk': 'require("easemob-websdk")',
}
}
这些模块只会在使用的时候从本地加载,不会参与构建过程,也不会阻塞主界面的渲染。经过系统的对这些node/electron模块以及第三方sdk的加载优化,组件挂载的时间又减少了0.3s左右。
使用自定义协议优化静态资源加载
到目前为止,我们的B类型子窗口打开速度已经降到0.3s多点(入口js 3.68MB),配合loading,给人反馈已经很好了,但是入口js加载的时间200多毫秒让我觉得还有优化空间。因为主窗口在打开的时候已经把入口js加载过了,如果子窗口创建的时候能利用上之前的加载,那么就还能提升时间。这个时候先想到的是用serviceWorker把app.js缓存上,但是sw并不支持file协议,所以还要在主进程开启一个http服务用来加载静态资源。很快就按照这个办法试了一下,确实有提升但是感觉不是很牢靠,而且涉及到版本更新还要处理serviceWorker缓存的更新,成本有点高就作为备选了。
后面在寻找electron缓存方案的时候,发现不少文章都是针对http的资源做本地缓存方案,看来没人想对本地资源做再做缓存(可能真的没必要)。但是在看到VSCode采用自定义协议加载静态资源的时候,我还是想做一个尝试,就是能否把静态资源第一次加载后放在内存中,然后后面加载的时候直接从内存读,这样就少了一次磁盘的IO,于是赶紧做了一个尝试:
// main.js
const STAR_PROTOCOL = 'star-file';
const assets_cache = new Map();
// 注册自定义协议
protocol.registerSchemesAsPrivileged([
{
scheme: STAR_PROTOCOL,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
codeCache: true,
}
}
]);
// 监听处理自定协议的文件加载
protocol.handle(STAR_PROTOCOL, (requset) => {
const filePath = requset.url.split('?')[0].replace(STAR_PROTOCOL +'://renderer/', '');
const contentType = getResourceContentType(filePath);
// 如果缓存过文件,直接返回
if (assets_cache.has(requset.url)) {
const content = assets_cache.get(requset.url);
return new Response(content, {
headers: { "content-type": contentType },
});
} else {
try {
// 从本地读取
const staticPath = path.join(dirname, '../../renderer/dist/');
const content = fs.readFileSync(path.join(staticPath, filePath));
assets_cache.set(requset.url, content);
return new Response(content, {
headers: { "content-type": contentType },
});
} catch (e) {
return new Response('File Not Found');
}
}
});
// 窗口加载文件
mainWindow.loadURL("star-file://renderer/index.html");
就这样,打开速度又快了100多ms,最终在0.18s上下,基本秒开,当然这种方案带来的内存占用还没有做评估,有没有其它潜在风险我也还在探索,还有就是之前的优化已经做到0.25到0.3s上下,已经是很不错的打开速度了,是否还有这种优化的必要性我还在考虑。
其它优化
除了上面提到的,我也给需要等待的窗口加上了loading,虽然内容的渲染需要时间,但是窗口一旦创建loading会立即展示,因为loading是直接在html中实现的,给用户的反馈还是很好的。另外也针对主进程代码中的包加载跟打包做了一些优化,比如压缩合并,虽然对速度影响很小,但是对安装包体积也算是做了一些缩小。
最终效果
- preload.js 0.8s
- UI组件按需加载 0.6s
- 低频使用的慢模块排除打包 0.3s
- 采用自定义协议 0.18s
总结
整个优化的思路其实很简单,因为对应的窗口本身就不存在什么复杂的逻辑,UI层面的代码执行速度就不可能慢,所以很好判断就是加载环节的问题。一方面是组件根据需要按需加载,另外就是第三方的包,如果特别影响速度可以排除打包,等代码执行的时候再加载,这样也能降低入口文件大小。我并没有提到对代码层面的性能优化,这个需要根据实际业务场景去分析了,后面如果发现有执行上的低效率模块再开一篇文章记录。