跳转到主要内容

性能

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

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

此列表仅供您参考,并且与我们的 安全检查清单 一样,并非详尽无遗。构建一个遵循以下所有步骤的慢速 Electron 应用是完全有可能的。Electron 是一个强大的开发平台,它使您,作为开发者,可以做更多或更少您想做的事情。所有这些自由意味着性能很大程度上是您的责任。

衡量,衡量,再衡量

以下列表包含许多相当简单易于实施的步骤。但是,构建最性能化的应用版本需要您超越这些步骤。相反,您需要通过仔细分析和测量来检查应用中运行的所有代码。瓶颈在哪里?当用户点击按钮时,哪些操作占用了大部分时间?当应用只是空闲时,哪些对象占用了最多的内存?

一次又一次,我们已经看到构建高性能 Electron 应用的最成功策略是分析正在运行的代码,找到最耗资源的片段,并对其进行优化。一遍又一遍地重复这个看似繁琐的过程将极大地提高您的应用性能。从与 Visual Studio Code 或 Slack 等大型应用合作的经验表明,这种做法到目前为止是提高性能的最可靠策略。

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

检查清单:性能建议

如果您尝试这些步骤,您的应用很可能变得更精简、更快、并且通常更省资源。

  1. 随意包含模块
  2. 过早加载和运行代码
  3. 阻塞主进程
  4. 阻塞渲染进程
  5. 不必要的 polyfill
  6. 不必要或阻塞的网络请求
  7. 捆绑您的代码
  8. 当您不需要默认菜单时,调用 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 后期版本中包含的连接性检查。

如何?

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

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

使用命令行中的单个命令可以为加载模块生成 CPU 配置文件和堆内存配置文件。在下面的示例中,我们正在查看流行的模块 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 密集型任务,请使用 worker 线程,考虑将其移动到 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. 不必要的 Polyfill

Electron 的一大优势是您确切地知道哪个引擎将解析您的 JavaScript、HTML 和 CSS。如果您正在重用为大型 Web 编写的代码,请确保不要为 Electron 中包含的功能添加 Polyfill。

为什么?

在为今天的互联网构建 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 + 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 以获取相关讨论。