性能
开发者经常会问到优化 Electron 应用程序性能的策略。软件工程师、消费者和框架开发者对于“性能”的定义并非总是统一的。本文档概述了一些 Electron 维护者最喜欢的降低内存、CPU 和磁盘资源使用量的方法,同时确保你的应用程序能够响应用户输入并尽快完成操作。此外,我们希望所有性能策略都能够保持应用程序的高安全性标准。
关于如何使用 JavaScript 构建高性能网站的经验和信息通常也适用于 Electron 应用程序。在某种程度上,讨论如何构建高性能 Node.js 应用程序的资源也适用,但请注意,“性能”对于 Node.js 后端和客户端运行的应用程序来说意味着不同的含义。
提供此列表是为了方便你 - 并且像我们的 安全检查表 一样 - 并非旨在详尽无遗。构建一个遵循以下所有步骤的慢速 Electron 应用程序可能也是可行的。Electron 是一个强大的开发平台,使你(开发者)能够或多或少地做任何你想做的事情。所有这些自由意味着性能在很大程度上是你的责任。
测量、测量、测量
下面的列表包含许多相当直接且易于实现的步骤。然而,构建应用程序的最佳性能版本需要你超越一些步骤。相反,你必须通过仔细的分析和测量来仔细检查应用程序中运行的所有代码。瓶颈在哪里?当用户单击按钮时,哪些操作占用了大部分时间?当应用程序只是空闲时,哪些对象占用了最多的内存?
我们一次又一次地看到,构建高性能 Electron 应用程序最成功的策略是分析正在运行的代码,找到其中最消耗资源的片段,并对其进行优化。一遍又一遍地重复这个看似费力的过程将大大提高你的应用程序的性能。使用诸如 Visual Studio Code 或 Slack 之类的大型应用程序的经验表明,这种做法是迄今为止提高性能最可靠的策略。
要了解有关如何分析应用程序代码的更多信息,请熟悉 Chrome 开发者工具。对于一次查看多个进程的高级分析,请考虑使用 Chrome Tracing 工具。
推荐阅读
检查表:性能建议
如果尝试执行这些步骤,你的应用程序可能会更精简、更快,并且总体上更少消耗资源。
- 随意包含模块
- 过早加载和运行代码
- 阻塞主进程
- 阻塞渲染器进程
- 不必要的 polyfill
- 不必要或阻塞的网络请求
- 打包你的代码
- 当你不需要默认菜单时,调用
Menu.setApplicationMenu(null)
1. 随意包含模块
在将 Node.js 模块添加到你的应用程序之前,请检查该模块。该模块包含多少个依赖项?它需要在 require()
语句中简单地调用什么类型的资源?你可能会发现,在 NPM 包注册表上下载次数最多或在 GitHub 上星标最多的模块实际上并非最精简或最小的模块。
为什么?
这个建议背后的原因最好用一个真实的例子来说明。在 Electron 的早期,可靠的网络连接检测是一个问题,导致许多应用程序使用一个暴露简单 isOnline()
方法的模块。
该模块通过尝试访问许多众所周知的端点来检测你的网络连接。对于这些端点的列表,它依赖于另一个模块,该模块也包含一个众所周知的端口列表。此依赖项本身依赖于一个包含端口信息的模块,该模块以包含 100,000 多行内容的 JSON 文件形式出现。每当加载模块(通常在 require('module')
语句中)时,它都会加载所有依赖项,并最终读取和解析此 JSON 文件。解析数千行 JSON 是一项非常昂贵的操作。在慢速机器上,它可能会占用几秒钟的时间。
在许多服务器环境中,启动时间实际上是无关紧要的。如果 Node.js 服务器在服务器启动时将所有必需的信息加载到内存中,从而更快地为请求提供服务,则它可能实际上“性能更高”,从而需要有关所有端口的信息。本例中讨论的模块不是一个“坏”模块。但是,Electron 应用程序不应该加载、解析和存储它实际上不需要的信息到内存中。
简而言之,主要为运行 Linux 的 Node.js 服务器编写的看似优秀的模块可能会对你的应用程序的性能不利。在这个特定的示例中,正确的解决方案是不使用任何模块,而是使用 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()
语句放在顶部。如果你当前使用相同的策略编写你的 Electron 应用程序,并且正在使用你不需要立即使用的大型模块,请应用相同的策略并将加载延迟到更合适的时机。
为什么?
加载模块是一项非常昂贵的操作,尤其是在 Windows 上。当你的应用程序启动时,它不应该让用户等待当前不需要的操作。
这似乎是显而易见的,但许多应用程序往往在应用程序启动后立即完成大量工作 - 例如检查更新、下载在后续流程中使用的内容或执行繁重的磁盘 I/O 操作。
让我们以 Visual Studio Code 为例。当你打开一个文件时,它会立即向你显示该文件,而没有任何代码高亮显示,从而优先考虑你与文本交互的能力。一旦它完成了这项工作,它就会继续进行代码高亮显示。
如何?
让我们考虑一个示例,并假设你的应用程序正在解析虚构的 .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. 不必要的 polyfill
Electron 的一大优点是,你确切地知道哪个引擎将解析你的 JavaScript、HTML 和 CSS。如果你正在重新使用为大型 Web 编写的代码,请确保不要 polyfill Electron 中包含的功能。
为什么?
当为今天的互联网构建 Web 应用程序时,最旧的环境决定了你可以使用和不能使用哪些功能。即使 Electron 支持高性能的 CSS 过滤器和动画,较旧的浏览器也可能不支持。在你可以使用 WebGL 的地方,你的开发人员可能选择了更耗费资源的解决方案来支持较旧的手机。
当涉及到 JavaScript 时,你可能包含了像 jQuery 这样的工具库来进行 DOM 选择,或者像 regenerator-runtime
这样的 polyfill 来支持 async/await
。
对于基于 JavaScript 的 polyfill 来说,它比 Electron 中等效的本机功能更快的情况很少见。请勿通过运送你自己版本的标准 Web 平台功能来减慢 Electron 应用程序的速度。
如何做?
假设当前版本的 Electron 中的 polyfill 是不必要的。如果你有疑问,请查看 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
。重新加载你的渲染器,看看你的应用程序是否不必要地等待任何资源。在许多情况下,应用程序会等待网络请求完成,尽管实际上并不需要所涉及的资源。
作为提示,从 Internet 加载你可能希望在不发布应用程序更新的情况下更改的资源是一种强大的策略。为了更好地控制资源的加载方式,请考虑投资 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 以获取相关讨论。