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

快应用开发工具 Dec 05, 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

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.