跳转到主要内容

性能

开发者经常询问优化 Electron 应用程序性能的策略。软件工程师、消费者和框架开发者对于“性能”的定义并不总是一致。本文档概述了一些 Electron 维护者喜欢的减少内存、CPU 和磁盘资源使用的方法,同时确保您的应用程序对用户输入响应迅速,并尽快完成操作。此外,我们希望所有性能策略都能为您的应用程序安全维护高标准。

关于如何使用 JavaScript 构建高性能网站的智慧和信息通常也适用于 Electron 应用程序。在一定程度上,讨论如何构建高性能 Node.js 应用程序的资源也适用,但请注意理解,“性能”对于 Node.js 后端与运行在客户端上的应用程序意味着不同的东西。

此列表仅供参考——与我们的安全清单一样——并非详尽无遗。即使遵循了下面概述的所有步骤,仍然有可能构建一个性能缓慢的 Electron 应用程序。Electron 是一个强大的开发平台,它允许您,开发者,几乎做任何您想做的事情。所有这些自由意味着性能在很大程度上是您的责任。

测量,测量,再测量

下表包含一些相对直接且易于实现的步骤。然而,构建应用程序性能最佳的版本将需要您超越这些步骤。您需要仔细检查应用程序中运行的所有代码,进行细致的分析和测量。瓶颈在哪里?当用户点击按钮时,哪些操作占用了大部分时间?当应用程序只是空闲时,哪些对象占用了最多的内存?

我们一次又一次地看到,构建高性能 Electron 应用程序最成功的策略是分析运行中的代码,找到资源消耗最多的部分,然后进行优化。一遍又一遍地重复这个看似繁琐的过程将极大地提高您应用程序的性能。像 Visual Studio Code 或 Slack 这样的主流应用程序的开发经验表明,这种做法是提高性能最可靠的策略。

要了解有关如何分析应用程序代码的更多信息,请熟悉 Chrome 开发者工具。对于一次分析多个进程的更高级分析,请考虑使用Chrome Tracing工具。

清单:性能建议

如果您尝试这些步骤,您的应用程序可能会变得更轻巧、更快,并且总体上消耗的资源更少。

  1. 随意引入模块
  2. 过早加载和运行代码
  3. 阻塞主进程
  4. 阻塞渲染进程
  5. 不必要的 polyfills
  6. 不必要的或阻塞的网络请求
  7. 打包您的代码
  8. 当您不需要默认菜单时,调用 Menu.setApplicationMenu(null)

1. 随意引入模块

在将 Node.js 模块添加到应用程序之前,请检查该模块。该模块包含多少依赖项?它只需要一个 require() 语句来调用,需要什么样的资源?您可能会发现,在 NPM 包注册表上下载量最多或 GitHub 上星数最多的模块,实际上并不是最轻巧或最小的可用模块。

为什么?

此建议背后的原因最好通过实际示例来说明。在 Electron 的早期,可靠地检测网络连接是一个问题,导致许多应用程序使用了一个公开简单 isOnline() 方法的模块。

该模块通过尝试连接到许多知名端点来检测您的网络连接。它依赖于另一个模块来获取这些端点的列表,该模块本身也包含一个知名端口列表。该依赖项本身又依赖于一个包含端口信息的模块,该信息以 JSON 文件的形式提供,该文件包含超过 100,000 行内容。每当加载该模块时(通常是在 require('module') 语句中),它就会加载所有依赖项,并最终读取和解析此 JSON 文件。解析数千行 JSON 是一个非常耗时的操作。在慢速机器上,可能需要几秒钟的时间。

在许多服务器环境中,启动时间几乎无关紧要。一个需要所有端口信息的 Node.js 服务器,在服务器启动时将所有所需信息加载到内存中,以实现更快的请求服务,这可能实际上“性能更好”。本例中讨论的模块不是一个“坏”模块。然而,Electron 应用程序不应该加载、解析和存储它实际上不需要的信息。

总之,一个看似优秀的、主要为 Linux 服务器编写的模块,可能对您的应用程序性能来说是个坏消息。在这个特定的例子中,正确的解决方案是根本不使用任何模块,而是使用 Chromium 后续版本中包含的连接性检查。

如何操作?

在考虑模块时,我们建议您检查

  1. 包含的依赖项大小
  2. 加载(require())它所需的资源
  3. 执行您感兴趣的操作所需的资源

通过命令行使用一个命令即可生成 CPU profile 和 heap memory profile。在下面的示例中,我们正在查看流行的模块 request

node --cpu-prof --heap-prof -e "require('request')"

执行此命令会在您执行它的目录中生成一个 .cpuprofile 文件和一个 .heapprofile 文件。这两个文件都可以使用 Chrome 开发者工具,分别在 PerformanceMemory 选项卡中进行分析。

Performance CPU Profile

Performance Heap Memory Profile

在此示例中,在作者的机器上,我们看到加载 request 花费了近半秒钟,而 node-fetch 则占用的内存少得多,且花费不到 50 毫秒。

2. 过早加载和运行代码

如果您有耗时的设置操作,请考虑推迟它们。检查应用程序启动后立即执行的所有工作。与其立即启动所有操作,不如考虑按顺序执行它们,使其更贴近用户的流程。

在传统的 Node.js 开发中,我们习惯将所有 require() 语句放在顶部。如果您目前正在使用相同的策略编写 Electron 应用程序并且正在使用您不需要立即使用的大型模块,请应用相同的策略,并将加载推迟到更合适的时间。

为什么?

加载模块是一项非常耗时的操作,尤其是在 Windows 上。当您的应用程序启动时,不应该让用户等待当前不必要的操作。

这可能看起来很明显,但许多应用程序在应用程序启动后会立即执行大量工作——例如检查更新、下载稍后流程中使用的内容或执行繁重的磁盘 I/O 操作。

让我们以 Visual Studio Code 为例。当您打开一个文件时,它会立即显示该文件,没有任何代码高亮,优先让您能够与文本进行交互。一旦完成了这项工作,它将继续进行代码高亮。

如何操作?

让我们看一个例子,假设您的应用程序正在解析虚构的 .foo 格式的文件。为了做到这一点,它依赖于同样虚构的 foo-parser 模块。在传统的 Node.js 开发中,您可能会编写急切加载依赖项的代码

parser.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() 时稍后执行这项工作?

parser.js
// "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 强大的多进程架构随时可以帮助您处理长时间运行的任务,但也包含一些性能陷阱。

  1. 对于长时间运行的 CPU 密集型任务,请使用工作线程,考虑将其移至 BrowserWindow,或者(作为最后的手段)启动一个专用进程。

  2. 尽可能避免使用同步 IPC 和 @electron/remote 模块。虽然存在合法的用例,但很容易在不知不觉中阻塞 UI 线程。

  3. 避免在主进程中使用阻塞 I/O 操作。简而言之,无论何时核心 Node.js 模块(如 fschild_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 编写的代码,请确保不要 polyfill Electron 中已包含的功能。

为什么?

在为当今互联网构建 Web 应用程序时,最古老的环境决定了您可以使用的功能以及不能使用的功能。即使 Electron 支持性能良好的 CSS 滤镜和动画,但旧浏览器可能不支持。在您可以使用 WebGL 的地方,您的开发人员可能选择了一个资源消耗更高的解决方案来支持旧手机。

就 JavaScript 而言,您可能已经包含了像 jQuery 这样的工具库用于 DOM 选择器,或者像 regenerator-runtime 这样的 polyfills 来支持 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 + RCtrl + R 来触发重新加载。

这些工具现在将一丝不苟地记录所有网络请求。在第一遍中,盘点所有正在下载的资源,首先关注较大的文件。其中是否有不随时间变化且可以包含在您的包中的图片、字体或媒体文件?如果是,请包含它们。

下一步,启用 Network Throttling(网络节流)。找到当前显示 Online(在线)的下拉菜单,然后选择一个较慢的速度,例如 Fast 3G。重新加载您的渲染器,看看是否有您的应用程序不必要地等待的资源。在许多情况下,应用程序会等待网络请求完成,而实际上并不需要涉及的资源。

作为提示,从互联网加载您可能希望在不发布应用程序更新的情况下更改的资源是一种强大的策略。为了更精细地控制资源的加载方式,请考虑投资Service Workers

7. 打包您的代码

正如在“过早加载和运行代码”中已经指出的,调用 require() 是一项耗时的操作。如果可能,请将应用程序的代码打包到一个文件中。

为什么?

现代 JavaScript 开发通常涉及许多文件和模块。虽然这对 Electron 开发来说完全没问题,但我们强烈建议您将所有代码打包到一个文件中,以确保 require() 调用带来的开销仅在应用程序加载时支付一次。

如何操作?

市面上有许多 JavaScript 打包工具,我们知道最好不要通过推荐一个工具而不是另一个工具来激怒社区。但我们确实建议您使用一个能够处理 Electron 独特环境的打包工具,因为它需要同时处理 Node.js 和浏览器环境。

在撰写本文时,流行的选择包括 WebpackParcelrollup.js

8. 当您不需要默认菜单时,调用 Menu.setApplicationMenu(null)

Electron 在启动时会设置一个包含一些标准条目的默认菜单。但您的应用程序可能需要更改它,并且这会使启动性能受益。

为什么?

如果您构建了自己的菜单或使用了无边框窗口而没有原生菜单,您应该尽早告知 Electron 不要设置默认菜单。

如何操作?

app.on("ready") 之前调用 Menu.setApplicationMenu(null)。这将阻止 Electron 设置默认菜单。另请参阅 https://github.com/electron/electron/issues/35512 获取相关讨论。