如何提升 electron 应用的启动速度

快应用开发工具 Dec 5, 2020

近期,我们优化了快应用开发工具的冷启动性能。快应用开发工具是 electron 应用,本文将结合学习内容和优化经验,分享一下 electron 应用的启动速度优化。

electron 应用的启动速度优化可以分为以下几个步骤:

  • 性能分析,确定瓶颈
  • 提升代码加载速度
  • 在正确的时间执行任务
  • 持续优化代码

1. 性能分析,确定瓶颈

1.1 性能监测和分析

electron 可分为主进程和渲染进程,其性能分析有所不同。

1.1.1 渲染进程

  • 渲染进程和一般的 web 工程一样,可以直接用 devtools 的性能面板进行分析。性能面板的使用已有不少教程,此处不再赘述。

  • 渲染进程的 js 性能,也可以用 devtools 的 Javascript Profiler 面板进行分析。

    选择 More tools - Javascript Profiler,即可打开面板,然后点击 Start 开始监测,点击 Stop 停止监测。

    得到的结果以列表形式展示,耗时越长的位置越靠上,可以方便地查看耗时最多的函数。另外,一条数据可以看到每个函数的自身时间和总时间,点击后面的链接可以跳转到函数对应的文件位置。

    监测结果也可以按火焰图的形式展示,鼠标悬浮时,可以展示自身时间和全部时间,点击可以跳转到函数对应的文件位置。

1.1.2 主进程和其他子进程

  • 主进程,可以用 v8-inspect-profiler 进行性能监测。生成的 .cpuprofile 文件,可以用 devtools 上的 Javascript Profiler 进行分析。如果用 fork 等方法启动了子进程,也可以用相同的方法监测,只需要设置不同的监测端口。

  • v8-inspect-profiler 在 electron 中的使用示例

    设置启动命令,添加参数 --inspect=${port},设置主进程的 v8 调试端口。

    // package.json
    {
        "name": "test",
        "version": "1.0.0",
        "main": "main.js",
        "devDependencies": {
            "electron": "9.2.1"
        },
        "scripts": {
            "start": "electron . --inspect=5222"
        },
        "dependencies": {
            "v8-inspect-profiler": "^0.0.20"
        }
    }
    

    监测主进程和通过 fork 启动的子进程,分别设置端口号为 5222 和 5223,输出到 prof-test.main.cpuprofileprof-test.fork.cpuprofile 文件中。

    // main.js
    
    const { app, BrowserWindow } = require('electron');
    const path = require('path');
    const fs = require('fs');
    const os = require('os');
    const { fork } = require('child_process');
    
    app.on('ready', async() => {
        // 监测主进程,传入名称和端口
        const mainProfiler = await startProfiler('main', 5222);
        
        const mainWindow = new BrowserWindow({width: 800,height: 800});
        const mainWindow.loadURL(`file://${__dirname}/index.html`);
    
        // 启动子进程
        startChildProcess();
    
        // 更多代码
        ...
    
        // 停止主进程监测
        const mainProfiler.stop();
    });
    
    ...
    
    async function startProfiler(name, port) {
        const profiler = require('v8-inspect-profiler');
        // 监测对应端口
        const profiling = await profiler.startProfiling({port});
        // 返回 stop 方法,以便停止监测
        return {
            async stop() {
                const profile = await profiling.stop();
                const prefix = path.join(os.homedir(), 'prof-test');
                // 输出性能文件
                await profiler.writeProfile(profile, `${prefix}.${name}.cpuprofile`);
            }
        }
    }
    
    // 启动子进程
    async function startChildProcess() {
        const forkProcess = fork(
            path.join(__dirname, `child-process.js`),
            // 设置监测端口,也可以设置为 --inspect-brk=5223,但 package.json 如果这样设置主进程会无法执行下去。
            { execArgv: ['--inspect=5223'] }
        );
        // 监测子进程
        const forkProfiler = await startProfiler('fork', 5223);
        setTimeout(async () => {
            // 停止子进程监测
            await forkProfiler.stop();
        }, 60000);
    }
    

1.1.3 单个依赖模块

  • 我们需要谨慎加载 npm 模块,因为一个模块可能包含了超出实际所需的功能,而 require 模块消耗的时间相当可观。可以运行以下命令,监测单个模块的加载时间:

    node --cpu-prof --heap-prof -e "require('request')"
    

    执行命令会生成 .cpuprofile 和 .heapprofile 文件,可以通过 devtools 的性能面板、内存面板进行分析。

    node 性能调优的例子还可以参考 Easy profiling for Node.js Applications

1.2. 性能钩子计时

除了使用上述性能监测工具,还可以测量启动过程中主要步骤的耗时,大致确认性能瓶颈在哪里。

  • 可以使用 node 的 perf_hook 进行打点计时,生成性能时间轴。示例如下:

    // app.js: 渲染进程的 js 文件
    
    // 引入性能钩子
    const { PerformanceObserver, performance } = require('perf_hooks');
    
    // 新建性能观察者
    const obs = new PerformanceObserver((items) => {
        const measurements = items.getEntriesByType('measure');
        measurements.forEach(measurement => {
            console.log(measurement.name, measurement.duration);
        });
    });
    // 观察条目为 'measure',可以观察多种类型的条目
    obs.observe({ entryTypes: ['measure'] });
    
    // 性能打点:渲染进程启动
    performance.mark('renderer-start');
    
    window.onload = main;
    
    function main() {
        // 性能打点,窗口加载
        performance.mark('renderer-window.onload');
    
        // 执行代码
        ...
    
        const webview = document.querySelector('webview');
        // 性能打点,设置 webview 的 src
        performance.mark('renderer-webview.create');
        webview.src = 'xxxxx';
        webview.addEventListener("dom-ready", () => {
            // 性能打点,webview 加载完成
            performance.mark('renderer-webview.ready');
            // 性能时间轴测量
            performaceTimeline();
        });
    }
    
    function performaceTimeline() {
        performance.measure('renderer:start up', 'renderer-start', 'renderer-window.onload');
        performance.measure('renderer:create webview', 'renderer-webview.create', 'renderer-webview.ready');
    }
    

    打印结果如下:

    renderer:start up 13.842721
    renderer:create webview 208.9726
    
  • node 的性能钩子简单易用,但是无法测量跨进程的时间节点。如果有这种需求,可以考虑用 Date.now() 的方式自行测量时间点,再用 ipc 通信的方法,将测量数据传到同一侧(如渲染进程获取主进程的数据),进行计算。

  • 快应用开发工具对启动流程的各步骤进行计时,确认性能瓶颈主要在:加载编译插件、require 编译模块、编译速度本身。后续优化,主要也是提升编译插件加载速度、缓存编译模块、更新编译模块的 webpack 提升编译速度。

2. 提升代码加载速度

2.1 打包和压缩代码

  • 使用 webpack 或 rollup 等打包工具压缩代码,代码体积越小,加载速度越快。另外,采用最新版本的打包工具,一般效果更佳。

  • 以快应用开发工具为例,压缩内置插件后,插件加载速度提升约 9%(以 20+ 页面快应用评测)。

2.2 tree-shaking

  • 一般情况下,项目有入口文件,入口文件有依赖模块,依赖模块又有下一层依赖模块,所以可以将其看成代码树。依赖中有的代码是没用的(比如永远没有用到的常量、函数),相当于树上枯萎的树叶。tree-shaking 就是摇动代码树,删除无用的代码。

  • webpack 已经支持 tree-shaking 的功能,只需要将配置文件中将 mode 设置为 'production' 即可。

    // webpack.config.js
    const path = require('path');
    
    module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist'),
    },
    mode: 'production', // 设置为 production
    };
    
  • 但是,tree-shaking 有时候效果并不好。因为它只能对代码进行静态分析,以确认模块是否有用。而有的代码有副作用(副作用,一般是指函数除了返回值,还进行了其他导致程序变化的操作,比如修改外部变量、写入文件等), tree-shaking 无法判断是否可以消除。这种情况下,如果你确认代码无副作用,可以通过标记文件无副作用等方法来解决。具体操作,请参考 webpack 文档

2.3 使用 V8 缓存数据

  • 前面两个方法,都是通过减少代码体积进行优化。但有时候,代码体积已经难以压缩。要进一步优化启动速度,还可以用空间换时间,将体积较大且重要的代码缓存到磁盘(但不应该滥用缓存)。

    electorn 使用 V8 引擎运行 js,V8 运行 js 时,需要先进行解析和编译,再执行代码。其中,解析和编译过程消耗时间多,经常导致性能瓶颈。而 V8 缓存功能,可以将编译后的字节码缓存起来,省去下一次解析、编译的时间。

  • 具体来说,在快应用开发工具中,我们使用 v8-compile-cache 缓存编译插件的代码。

    v8-compile-cache 的使用非常简单,在需要缓存的代码中,添加一行代码即可:

    require('v8-compile-cache')
    

    v8-compile-cache 默认缓存到临时文件夹 <os.tmpdir()>/v8-compile-cache-<V8_VERSION> 下,电脑重启后,该文件会被清除掉。

    如果希望缓存永久化,可以通过环境变量 process.env.V8_COMPILE_CACHE_CACHE_DIR 来指定缓存文件夹,避免电脑重启后删除。另外,如果希望项目的不同版本对应的缓存不同,可以在文件夹名中加入代码版本号(或其他唯一标识),以此保证缓存和项目版本完全对应。当然,这也意味着项目的多个版本有多份缓存。为了不占用过多磁盘空间,在程序退出时,我们需要删除其他版本的缓存。

  • 快应用工具,缓存编译插件代码后,启动速度提升约 15%(以 20+ 页面快应用评测)。

2.4 拆分代码

  • 该操作和打包相反,是将代码分为几个部分。文件体积过大时,会导致加载时间太长。但有的代码不是启动时就需要执行的,所以可以拆分代码,先加载需要的代码文件。

  • vscode 开发者在分享视频中提到,vscode 目前没有做代码拆分。但个人认为,vscode 也可以说做了代码拆分的。它将核心代码(如生命周期、页面渲染、编辑区等)放在主进程和渲染进程,而相关度较低的代码(如主题颜色、git 管理)放在插件进程。主进程启动 app 后,先加载渲染进程的入口文件,再同时渲染 IDE 和加载插件进程(加载插件的文件)。

3. 在正确的时间执行任务

3.1 定义任务优先级

  • 不是直接提高任务的执行速度,而是通过安排优先级提高性能。

  • 比如 vscode,定义了项目的生命周期,在不同阶段执行不同任务。又比如快应用开发工具,在启动后需要激活多个内置插件,为了尽快开始编译,优先激活编译插件。

3.2 idleCallback

  • 对于页面中不是必须马上执行代码,可以调用 window.requestIdleCallback() 方法,在浏览器的空闲时段内执行。

  • 比如快应用开发工具,消息通知并不是开发者急需的功能,就可以在空闲时间创建。

3.3 延迟加载模块

  • 项目的一些依赖模块,是在特定功能触发时才需要使用。所以,没有必要在应用启动时立刻加载,可以在方法调用时再加载。

  • 比如快应用开发工具的分享功能,使用了某个模块。该模块不是在启动时就需要使用的,只有用户点击了分享按键,触发分享功能,才需要使用。因此,可以进行以下调整。

    优化前的代码:

    // 导入模块
    const xxx = require('xxx');
    
    export function share() {
        ...
        // 执行依赖的方法
        xxx()
    }
    

    优化后的代码:

    
    export function share() {
        // 导入模块
        const xxx = require('xxx');
    
        ...
        // 执行依赖的方法
        xxx()
    }
    

3.4 提升感知速度

  • 一个项目优化到一定程度时,总会面临无法大幅提高代码速度的问题。此时,除了努力提高硬性加载速度,还可以尝试提高感知速度。也就是说,虽然实际加载速度不变,但用户体验更流畅、更友好。

  • 比如 vscode,点击文件 tab 切换文件时,监听 mousedown 而不是 mouseup(可以更快监听到事件)。另外,它会先更新面包屑,再更新文件内容,最后渲染颜色。虽然整体加载速度没有变化,但给用户的感受是不同的。

    再比如快应用开发工具,将编译过程进行细化(激活编译插件-编译-加载页面),并增加编译的百分比进度。也是相同的逻辑,将大流程拆分成小流程,逐步展示,提升感知速度。

  • 此外,使用骨架屏也是提升感知速度的一个方法。

4. 持续优化代码

前面的方法,特别是打包和缓存,对启动速度的优化效果明显。而代码优化,有时候对启动速度的提升效果并不明显(一个小优化可能只有几十毫秒的提升),但积少成多,最终也会有一定的效果。而且,代码编写不合理,也容易出现运行过程中的性能问题。所以,代码优化是最小但最需要持续做的工作。

4.1 减少不必要的依赖模块

  • electron 建议谨慎地加载模块,因为有的模块包含的依赖很多,require 模块时会加载所有的依赖关系,在一些情况下耗时很多。

  • 快应用开发工具的优化,减少了一些不必要的依赖模块。比如有的功能,用内部方法可以实现,就没有必要引入额外的依赖。而对于无法避免的大型依赖模块,我们采用 v8-compile-cache 进行磁盘缓存。

4.2 减少磁盘 IO

  • 大量的磁盘 IO,会延长应用程序执行时间,我们应该减少一些不必要的磁盘 IO。

  • 比如快应用开发工具,之前一些固定内容是保存到多个文件中的(主要是为了方便开发)。在启动时,先调用 fs.readFileSync 读取文件,再对读取的内容进行操作。现在,将这种内容直接用常量保存到同一个文件中,可以减少磁盘 IO 次数。

4.3 减少同步 ipc 和 remote

  • 使用同步 ipc ,意味着发送消息后,需要等待消息返回才能进行下一步操作。这样很可能阻塞主进程或渲染进程。所以,一般情况下应该使用异步 ipc 通信。

  • remote 为主进程和渲染进程之间通信提供一种简单的方法,我们可以方便地在渲染进程调用主进程的接口。remote 模块返回主进程的远程对象,调用远程对象的方法时,实际上是调用 ipc 同步通信。这种方式虽然方便,但可能导致同步通信过多的问题。另外,使用 remote 时还要注意避免远程对象泄漏。由于 remote 可能带来这些问题,我们应该尽量减少 remote 的使用。

    下面是一个 remote 导致通信次数过多的例子(来自 Electron’s ‘remote’ module considered harmful):

    // Main process
    global.thing = {
      rectangle: {
        getBounds() { return { x: 0, y: 0, width: 100, height: 100 } }
        setBounds(bounds) { /* ... */ }
      }
    }
    // Renderer process
    const thing = remote.getGlobal('thing')
    const { x, y, width, height } = thing.rectangle.getBounds()
    thing.rectangle.setBounds({ x, y, width, height: height + 100 })
    

    在渲染进程中执行这段代码,会调用 9 次 ipc 同步通信:

    • getGlobal()
    • thing.rectangle
    • getBounds()
    • 获取 bounds.x
    • 获取 bounds.y
    • 获取 bounds.width
    • 获取 bounds.height
    • 再次获取 thing.rectangle
    • 执行 setBounds

4.4 使用更高效的 dom 访问接口,提升页面渲染速度

  • 在 electron 的渲染进程中,快应用开发工具经常需要访问、操作 dom,而不同接口的效率差异很大。

    比如 document.getElementsByClassName('xxx')document.querySelectorAll('.xxx') 更快,运行前者 2900 万次的时间,后者只能运行不到 40 万次(数据来自 JavaScript on the Desktop, Fast and Slow)。

    同样的,document.getElementById('xxx') 也比 document.querySelector('#xxx') 更快,前者的速度是后者的 2 倍(数据来自 How to make your Electron app faster)。

参考资料

Electron 文档 - performance

Electron 文档 - remote

Visual Studio Code – The First Second

Performance measurement APIs

Easy profiling for Node.js Applications

webpack 文档

Tree-Shaking性能优化实践 - 原理篇

你的Tree-Shaking并没什么卵用

Master the JavaScript Interview: What is Functional Programming?

Electron’s ‘remote’ module considered harmful

JavaScript on the Desktop, Fast and Slow

How to make your Electron app faster

Tags