性能
开发者经常询问有关优化 Electron 应用程序性能的策略。软件工程师、消费者和框架开发者并不总是同意对“性能”的单一定义。本文档概述了 Electron 维护人员最喜欢的几种方法,这些方法可以减少内存、CPU 和磁盘资源的使用量,同时确保您的应用程序对用户输入响应迅速,并尽可能快地完成操作。此外,我们希望所有性能策略都能保持应用程序安全的高标准。
关于如何使用 JavaScript 构建高性能网站的智慧和信息通常也适用于 Electron 应用程序。在某种程度上,讨论如何构建高性能 Node.js 应用程序的资源也适用,但请务必了解,“性能”对 Node.js 后端和在客户端上运行的应用程序来说含义不同。
此列表仅供参考,与我们的 安全清单 一样,并不意味着详尽无遗。构建一个缓慢的 Electron 应用程序,同时遵循下面概述的所有步骤,这可能是可能的。Electron 是一个功能强大的开发平台,它使您(开发人员)能够做更多或更少的事情。所有这些自由意味着性能很大程度上是您的责任。
测量、测量、测量
下面的列表包含一些相当直接且易于实施的步骤。但是,构建应用程序的最高性能版本将要求您超越许多步骤。相反,您将不得不通过仔细分析和测量,仔细检查应用程序中运行的所有代码。瓶颈在哪里?当用户单击按钮时,哪些操作占据了大部分时间?当应用程序只是处于空闲状态时,哪些对象占用了最多的内存?
我们一次又一次地看到,构建高性能 Electron 应用程序最成功的策略是分析运行的代码,找到最占用资源的部分并对其进行优化。反复执行这个看似繁琐的过程将极大地提高应用程序的性能。在 Visual Studio Code 或 Slack 等主要应用程序的开发经验表明,这种做法是迄今为止提高性能最可靠的策略。
要了解有关如何分析应用程序代码的更多信息,请熟悉 Chrome 开发者工具。对于需要同时查看多个进程的高级分析,请考虑使用 Chrome 追踪 工具。
推荐读物
清单:性能建议
如果尝试这些步骤,您的应用程序可能会变得更加精简、更快,并且总体上对资源的依赖更少。
- 不小心包含模块
- 过早加载和运行代码
- 阻塞主进程
- 阻塞渲染器进程
- 不必要的 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 开发者工具分别使用 性能
和 内存
选项卡进行分析。
在这个例子中,在作者的机器上,我们看到加载 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 的 多线程文档 和 Web Workers 的 MDN 文档。它们是任何需要长时间运行大量 CPU 能力的操作的理想解决方案。
5. 不必要的 polyfills
Electron 的一大优势是您确切地知道哪个引擎将解析您的 JavaScript、HTML 和 CSS。如果您要重新利用为整个网络编写的代码,请确保不要填充 Electron 中包含的功能。
为什么?
在为当今的互联网构建 Web 应用程序时,最旧的环境决定了您可以使用哪些功能以及不能使用哪些功能。即使 Electron 支持性能良好的 CSS 过滤器和动画,旧的浏览器可能不支持。在您可以使用 WebGL 的地方,您的开发人员可能选择了更占用资源的解决方案来支持旧款手机。
在 JavaScript 方面,您可能已包含 jQuery 等工具包库用于 DOM 选择器,或包含 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 字体。许多开发人员使用 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,以获取相关讨论。