如何提升 electron 应用的启动速度
近期,我们优化了快应用开发工具的冷启动性能。快应用开发工具是 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.cpuprofile
和prof-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)。
参考资料
Visual Studio Code – The First Second
Easy profiling for Node.js Applications
Master the JavaScript Interview: What is Functional Programming?
Electron’s ‘remote’ module considered harmful