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
0:00
/0:07

总结

整个优化的思路其实很简单,因为对应的窗口本身就不存在什么复杂的逻辑,UI层面的代码执行速度就不可能慢,所以很好判断就是加载环节的问题。一方面是组件根据需要按需加载,另外就是第三方的包,如果特别影响速度可以排除打包,等代码执行的时候再加载,这样也能降低入口文件大小。我并没有提到对代码层面的性能优化,这个需要根据实际业务场景去分析了,后面如果发现有执行上的低效率模块再开一篇文章记录。

Read more

那些年玩过的归零山寨币

这两天币安因为调整合约策略导致的不少meme币腰斩再腰斩,作为一个老韭菜看到这样的场景仍然是触目惊心。虽然我早已经不碰这些玩意儿,但是不禁让我想起来这些年经手的归零山寨币还真不少。2017年的夏天我的朋友带我走进了数字货币的世界,不知不觉在币圈熬了8年,最终的结局是没有暴富,也没有了期待,下面就盘点下从入圈到现在归零的山寨币。 归零定义:基本被所有交易所下架/项目方解散或者是无人关注/价格下跌95%以上。以下为2017年到2025年持仓时间跟数量相对比较多的归零或者接近归零的山寨币。 SNT (https://status.im) 这个是我接触的最早的一个以太坊ICO项目,做的去中心化IM。虽然没有参与众筹,但是在二级市场接盘在高点,当时恰逢2017年牛市,作为币圈小白完全不清楚熊市的威力,后面亏的抬不头。项目现在还在做,但是基本没人关注了,币价也可以忽略不计了。 EOS 老韭菜都懂,当时参与了ICO,最多的时候有9000多个,2017年牛市没来就跑光了。从最高差不多20美金跌到现在的0.8美金,最近热度又起来了,搞不懂。 DEW 李笑来那一伙搞的什么去中心化交易所

By Tee

直播伴侣歌词助手功能开发

平时我是不太喜欢写技术文档的,做的大部分工作也都是前端领域的搬砖,因为这次伴侣歌词助手的开发经历也实在是坎坷,最终也算把需求搞定了,就把过程简单记录下,供有兴趣了解的同学看一看。 一、需求 首先需求是希望参考抖音直播伴侣开发一个类似功能,当你打开QQ/网易云音乐的歌词功能后,可以在直播软件通过一个歌词助手的功能把桌面上的歌词同步到直播场景中。最初一看这不好办法嘛,用OBS的窗口素材在场景中添加歌词对应的窗口不就好了,当我第一次尝试的时候就发现太天真,有时候在窗口列表不一定出现歌词对应的窗口,有时候列表存在歌词窗口,但是窗口添加完毕就是黑屏,所以我开始意识到这个功能可能不是那么简单的事情。 二、chromium 踩坑 既然OBS的窗口素材展示不出来,我首先想到的就是直接用Web技术。先是发现chrome的窗口选择界面倒是能呈现缩略图,但是一样无法拿到mediaStream在网页中播放出来。然后我就去Electron中通过getSources读取,当然窗口列表中也是包含歌词窗口的,缩略图也能打印出来,但是当我尝试在getSources回调中把歌词对应的窗口资源返回给渲染进程处理

By Tee