性能
开发者经常询问优化 Electron 应用性能的策略。软件工程师、用户和框架开发者对于“性能”的定义并不总是一致。本文档概述了一些 Electron 维护者们偏爱的,在确保应用响应用户输入并尽快完成操作的同时,减少内存、CPU 和磁盘资源使用量的方法。此外,我们希望所有的性能策略都能保持您的应用安全标准足够高。
关于如何使用 JavaScript 构建高性能网站的经验和信息通常也适用于 Electron 应用。在一定程度上,讨论如何构建高性能 Node.js 应用的资源也适用,但请注意理解“性能”对于 Node.js 后端和客户端运行的应用意味着不同的东西。
本列表是为了您的方便而提供的 – 就像我们的安全清单一样 – 并不意味着详尽无遗。即便遵循下面概述的所有步骤,也可能构建出慢速的 Electron 应用。Electron 是一个强大的开发平台,它使您作为开发者,可以或多或少地做任何您想做的事情。所有这些自由意味着性能主要取决于您的责任。
测量、测量、再测量
下面的列表包含了一些相当直接且易于实现的步骤。然而,构建应用的最高性能版本需要您超越这些步骤。相反,您必须通过仔细的性能分析和测量来仔细检查应用中运行的所有代码。瓶颈在哪里?当用户点击按钮时,哪些操作占用了大部分时间?当应用只是空闲时,哪些对象占用了最多的内存?
我们一次又一次地看到,构建高性能 Electron 应用最成功的策略是对运行中的代码进行性能分析,找到其中最消耗资源的部分,然后对其进行优化。反复重复这个看似繁琐的过程将极大地提升您应用的性能。从与 Visual Studio Code 或 Slack 等主要应用合作的经验表明,这种做法是目前为止提高性能最可靠的策略。
要了解如何分析应用的代码,请熟悉 Chrome 开发者工具。对于一次查看多个进程的高级分析,可以考虑使用 Chrome Tracing 工具。
推荐阅读
清单:性能建议
如果您尝试这些步骤,您的应用很可能会更精简、更快,并且总体上更不消耗资源。
- 草率地包含模块
- 过早加载和运行代码
- 阻塞主进程
- 阻塞渲染进程
- 不必要的 polyfills
- 不必要的或阻塞的网络请求
- 打包您的代码
- 当您不需要默认菜单时,调用
Menu.setApplicationMenu(null)
1. 草率地包含模块
在向应用添加 Node.js 模块之前,请检查该模块。该模块包含多少依赖项?仅仅在 require()
语句中调用它需要什么样的资源?您可能会发现,在 NPM 包注册表上下载量最多或在 GitHub 上星标最多的模块实际上并不是最精简或最小的模块。
为什么?
这个建议背后的原因用一个实际例子最能说明。在 Electron 的早期,可靠地检测网络连接是一个问题,导致许多应用使用一个暴露了简单 isOnline()
方法的模块。
该模块通过尝试访问多个已知端点来检测您的网络连接。为了获取这些端点列表,它依赖于另一个模块,该模块也包含一个已知端口列表。这个依赖项本身又依赖于一个包含端口信息的模块,该模块的形式是一个 JSON 文件,内容超过 100,000 行。每当加载该模块时(通常在 require('module')
语句中),它会加载所有依赖项,并最终读取和解析这个 JSON 文件。解析数千行 JSON 是一项非常耗费资源的操作。在慢速机器上,这可能会占用整整几秒钟的时间。
在许多服务器环境中,启动时间几乎无关紧要。一个需要所有端口信息的 Node.js 服务器如果在服务器启动时将所有所需信息加载到内存中以加快服务请求的速度,那么它实际上可能“性能更高”。这个例子中讨论的模块并不是一个“坏”模块。然而,Electron 应用不应该加载、解析和在内存中存储实际上不需要的信息。
在这个特定例子中,正确的解决方案是完全不使用模块,而是使用 Chromium 后期版本中包含的连接性检查。
如何?
在考虑一个模块时,我们建议您检查:
- 包含的依赖项大小
- 加载 (
require()
) 它所需的资源 - 执行您感兴趣的操作所需的资源
在命令行上使用一个命令即可生成加载模块的 CPU 性能分析和堆内存分析。在下面的示例中,我们查看流行的模块 request
。
node --cpu-prof --heap-prof -e "require('request')"
执行此命令会在您执行它的目录中生成一个 .cpuprofile
文件和一个 .heapprofile
文件。这两个文件都可以使用 Chrome 开发者工具进行分析,分别使用 Performance
(性能)和 Memory
(内存)选项卡。
在这个例子中,在作者的机器上,我们看到加载 request
花了将近半秒钟,而 node-fetch
则占用了显著更少的内存,且耗时不到 50 毫秒。
2. 过早加载和运行代码
如果您有开销较大的设置操作,请考虑推迟它们。检查应用启动后立即执行的所有工作。与其立即启动所有操作,不如考虑按照更贴近用户旅程的顺序错开它们。
在传统的 Node.js 开发中,我们习惯于将所有 require()
语句放在顶部。如果您当前正在使用相同的策略 并且 使用了您不需要立即使用的体积较大的模块,请应用相同的策略,将加载推迟到更合适的时间。
为什么?
加载模块是一项令人惊讶的昂贵操作,尤其是在 Windows 上。
应用启动时,不应让用户等待当前不需要的操作。
这看似显而易见,但许多应用倾向于在启动后立即执行大量工作——例如检查更新、下载稍后流程中使用的内容或执行繁重的磁盘 I/O 操作。
如何?
让我们考虑一个例子,假设您的应用正在解析虚构的 .foo
格式文件。为了做到这一点,它依赖于同样虚构的 foo-parser
模块。在传统的 Node.js 开发中,您可能会编写急切加载依赖项的代码:
const fs = require('node:fs')
const fooParser = require('foo-parser')
class Parser {
constructor () {
this.files = fs.readdirSync('.')
}
getParsedFiles () {
return fooParser.parse(this.files)
}
}
const parser = new Parser()
module.exports = { parser }
在上面的例子中,我们正在做很多文件加载后立即执行的工作。我们需要立即获取解析后的文件吗?当 getParsedFiles()
实际被调用时,我们能否稍后做这些工作?
// "fs" is likely already being loaded, so the `require()` call is cheap
const fs = require('node:fs')
class Parser {
async getFiles () {
// Touch the disk as soon as `getFiles` is called, not sooner.
// Also, ensure that we're not blocking other operations by using
// the asynchronous version.
this.files = this.files || await fs.promises.readdir('.')
return this.files
}
async getParsedFiles () {
// Our fictitious foo-parser is a big and expensive module to load, so
// defer that work until we actually need to parse files.
// Since `require()` comes with a module cache, the `require()` call
// will only be expensive once - subsequent calls of `getParsedFiles()`
// will be faster.
const fooParser = require('foo-parser')
const files = await this.getFiles()
return fooParser.parse(files)
}
}
// This operation is now a lot cheaper than in our previous example
const parser = new Parser()
module.exports = { parser }
简而言之,"及时"分配资源,而不是在应用启动时就分配所有资源。
3. 阻塞主进程
Electron 的主进程(有时称为“浏览器进程”)是特殊的:它是应用所有其他进程的父进程,也是操作系统主要与之交互的进程。它处理窗口、交互以及应用内部各种组件之间的通信。它也包含 UI 线程。
在任何情况下,您都不应该用长时间运行的操作来阻塞此进程和 UI 线程。阻塞 UI 线程意味着您的整个应用将冻结,直到主进程准备好继续处理为止。
为什么?
主进程及其 UI 线程本质上是应用内部主要操作的控制中心。当操作系统通知您的应用关于鼠标点击时,它会先通过主进程,然后才到达您的窗口。如果您的窗口正在渲染流畅的动画,它需要与 GPU 进程通信——这也需要再次通过主进程。
Electron 和 Chromium 都很谨慎地将繁重的磁盘 I/O 和 CPU 密集型操作放在新线程上,以避免阻塞 UI 线程。您也应该这样做。
如何?
Electron 强大的多进程架构可以帮助您处理长时间运行的任务,但也包含一些性能陷阱。
-
对于长时间运行的 CPU 密集型任务,请利用 工作线程,考虑将它们移至 BrowserWindow,或者(作为最后的手段)生成一个专用进程。
-
尽可能避免使用同步 IPC 和
@electron/remote
模块。虽然存在合法的用例,但太容易无意中阻塞 UI 线程了。 -
避免在主进程中使用阻塞 I/O 操作。简而言之,无论何时核心 Node.js 模块(例如
fs
或child_process
)提供了同步或异步版本,您都应首选异步且非阻塞的版本。
4. 阻塞渲染进程
由于 Electron 附带当前版本的 Chrome,您可以利用 Web 平台提供的最新和最棒的功能来推迟或分载繁重的操作,以保持应用流畅和响应迅速。
为什么?
您的应用可能在渲染进程中有大量 JavaScript 代码需要运行。关键是在不占用保持流畅滚动、响应用户输入或 60fps 动画所需资源的情况下,尽快执行操作。
如果用户抱怨您的应用有时“卡顿”,那么编排渲染器代码中的操作流程将特别有用。
如何?
总的来说,构建现代浏览器高性能 Web 应用的所有建议也适用于 Electron 的渲染器。目前您可用的两个主要工具是用于小型操作的 requestIdleCallback()
和用于长时间运行操作的 Web Workers
。
requestIdleCallback()
允许开发者将函数排队,以便在进程进入空闲时立即执行。它使您能够在不影响用户体验的情况下执行低优先级或后台工作。有关如何使用它的更多信息,请查阅 MDN 上的文档。
Web Workers 是一个在单独线程上运行代码的强大工具。有一些需要考虑的注意事项——请查阅 Electron 的多线程文档和MDN 上关于 Web Workers 的文档。它们是任何需要长时间消耗大量 CPU 性能的操作的理想解决方案。
5. 不必要的 polyfills
Electron 的一大优势在于您确切地知道哪个引擎将解析您的 JavaScript、HTML 和 CSS。如果您正在重新利用为整个 Web 编写的代码,请确保不要对 Electron 中已包含的功能进行 polyfill。
为什么?
在为当今互联网构建 Web 应用时,最旧的环境决定了您可以和不能使用哪些功能。即使 Electron 支持性能良好的 CSS 滤镜和动画,较旧的浏览器可能不支持。在您可以使用 WebGL 的地方,您的开发者可能为了支持较旧的手机而选择了更消耗资源的解决方案。
谈到 JavaScript,您可能包含了用于 DOM 选择器的工具包库,例如 jQuery,或者像 regenerator-runtime
这样的 polyfills 来支持 async/await
。
基于 JavaScript 的 polyfill 很少会比 Electron 中的原生等效功能更快。不要通过自带标准 Web 平台功能的版本来降低 Electron 应用的速度。
如何?
假设当前版本的 Electron 中不需要 polyfills。如果您有疑问,请访问 caniuse.com 并检查您的 Electron 版本中使用的 Chromium 版本是否支持您需要的功能。
此外,请仔细检查您使用的库。它们真的必要吗?例如,jQuery
如此成功,以至于它的许多功能现在已成为标准 JavaScript 可用功能集的一部分。
如果您正在使用像 TypeScript 这样的转译器/编译器,请检查其配置,确保您正在针对 Electron 支持的最新 ECMAScript 版本。
6. 不必要的或阻塞的网络请求
如果很少更改的资源可以轻松地与您的应用捆绑在一起,请避免从互联网获取它们。
为什么?
许多 Electron 用户一开始就有一个完全基于 Web 的应用,然后将其转换为桌面应用。作为 Web 开发者,我们习惯于从各种内容分发网络加载资源。现在您正在发布一个真正的桌面应用,请尽可能“断开连接”,避免让用户等待那些永不改变且可以轻松包含在应用中的资源。
一个典型的例子是 Google Fonts。许多开发者利用 Google 令人印象深刻的免费字体库,该字体库附带内容分发网络。宣传语很直接:包含几行 CSS 代码,剩下的事情 Google 会处理。
构建 Electron 应用时,如果下载字体并将其包含在应用包中,可以更好地为用户提供服务。
如何?
在理想世界中,您的应用根本不需要网络即可运行。要做到这一点,您必须了解您的应用正在下载哪些资源——以及这些资源有多大。
为此,请打开开发者工具。导航到 Network
(网络)选项卡,并勾选 Disable cache
(禁用缓存)选项。然后,重新加载您的渲染器。除非您的应用禁止此类重新加载,否则通常可以在开发者工具处于焦点时按下 Cmd + R
或 Ctrl + R
来触发重新加载。
工具现在将详细记录所有网络请求。首先,清点所有正在下载的资源,重点关注较大的文件。其中是否有不会改变且可以包含在您的包中的图像、字体或媒体文件?如果有,请包含它们。
下一步,启用 Network Throttling
(网络限流)。找到当前显示为 Online
(在线)的下拉菜单,选择一个较慢的速度,例如 Fast 3G
(快速 3G)。重新加载渲染器,看看是否有应用不必要等待的资源。在许多情况下,应用会等待网络请求完成,即使它实际上不需要该资源。
一个技巧是,从互联网加载那些您可能想在不发布应用更新的情况下更改的资源,这是一种强大的策略。对于如何加载资源的更高级控制,可以考虑深入研究 Service Workers。
7. 打包您的代码
正如在“过早加载和运行代码”中已经指出的那样,调用 require()
是一项开销较大的操作。如果可以,请将应用的代码打包成一个单独的文件。
为什么?
现代 JavaScript 开发通常涉及许多文件和模块。虽然这对使用 Electron 进行开发来说完全没问题,但我们强烈建议您将所有代码打包到一个文件中,以确保在应用加载时调用 require()
的开销只支付一次。
如何?
市面上有许多 JavaScript 打包工具,我们知道最好不要通过推荐某个工具而惹怒社区。但我们确实建议您使用能够处理 Electron 独特环境(需要同时处理 Node.js 和浏览器环境)的打包工具。
截至撰写本文时,流行的选择包括 Webpack、Parcel 和 rollup.js。
8. 当您不需要默认菜单时,调用 Menu.setApplicationMenu(null)
Electron 在启动时会设置一个包含一些标准条目的默认菜单。但您的应用可能出于某些原因需要更改它,这样做将有利于启动性能。
为什么?
如果您构建自己的菜单或使用没有原生菜单的无边框窗口,您应该尽早告诉 Electron 不要设置默认菜单。
如何?
在 app.on("ready")
之前调用 Menu.setApplicationMenu(null)
。这将阻止 Electron 设置默认菜单。有关相关讨论,另请参阅 https://github.com/electron/electron/issues/35512。