性能¶
开发者经常询问优化 Electron 应用性能的策略。软件工程师、用户和框架开发者并不总是对“性能”的含义有统一的理解。本文档概述了一些 Electron 维护者最喜欢的方式,用于减少内存、CPU 和磁盘资源的使用,同时确保应用能够响应用户输入,并尽可能快地完成操作。此外,我们希望所有性能策略都能保持应用的高安全标准。
关于如何使用 JavaScript 构建高性能网站的经验和信息,通常也适用于 Electron 应用。在某种程度上,讨论如何构建高性能 Node.js 应用的资源也适用,但请注意理解,“性能”一词对于 Node.js 后端和运行在客户端的应用而言含义不同。
本列表为了方便你提供——就像我们的安全检查清单一样——并非旨在详尽无遗。遵循下面所有步骤仍然可能构建出一个缓慢的 Electron 应用。Electron 是一个强大的开发平台,它允许你作为开发者几乎可以做任何想做的事。所有这些自由意味着性能在很大程度上取决于你。
测量、测量、再测量¶
下面的列表包含一些相当直接且易于实施的步骤。然而,要构建性能最佳的版本,你需要超越这些步骤。相反,你必须通过仔细的性能分析和测量,来检查应用中运行的所有代码。瓶颈在哪里?当用户点击按钮时,哪些操作占用了大部分时间?当应用只是空闲时,哪些对象占用了最多内存?
一次又一次,我们看到构建高性能 Electron 应用最成功的策略是对运行中的代码进行性能分析,找到其中最消耗资源的部分,并对其进行优化。反复重复这个看似繁琐的过程会显著提升应用性能。与 Visual Studio Code 或 Slack 等大型应用合作的经验表明,这种做法是提升性能迄今为止最可靠的策略。
要了解如何对应用代码进行性能分析,请熟悉 Chrome Developer Tools。对于需要同时查看多个进程的进阶分析,可以考虑 Chrome Tracing 工具。
推荐阅读¶
检查清单:性能建议¶
如果你避免以下常见陷阱,你的应用可能会更精简、更快,并且总体上更不消耗资源。
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。
执行此命令会在你执行该命令的目录中生成一个 .cpuprofile 文件和一个 .heapprofile 文件。这两个文件都可以使用 Chrome Developer Tools 进行分析,分别使用 Performance 和 Memory 选项卡。


在这个示例中,在作者的机器上,我们看到加载 request 花了将近半秒,而 node-fetch 使用的内存显著更少,且耗时不到 50ms。
2. 过早加载和运行代码¶
如果你有开销高昂的初始化操作,请考虑推迟执行。检查应用程序启动后立即执行的所有工作。不要立即触发所有操作,而是考虑将它们错开,按一个更贴近用户旅程的顺序执行。
在传统 Node.js 开发中,我们习惯于将所有 require() 语句放在顶部。如果你目前使用相同策略编写 Electron 应用程序,并且 使用了体积较大但并非立即需要的模块,请采用相同策略,将加载推迟到更合适的时间。
为什么?¶
加载模块是一项出乎意料地开销高昂的操作,尤其是在 Windows 上。应用启动时,不应让用户等待当前并非必要的操作。
这看似显而易见,但许多应用往往在应用启动后立即执行大量工作——例如检查更新、下载后续流程中使用的内容,或执行大量磁盘 I/O 操作。
以 Visual Studio Code 为例。当你打开一个文件时,它会立即向你显示文件,而不进行任何代码高亮,优先保证你能与文本交互。完成该工作后,它才会继续执行代码高亮。
如何?¶
让我们考虑一个示例,并假设你的应用程序正在解析虚构的 .foo 格式的文件。为此,它依赖于同样虚构的 foo-parser 模块。在传统 Node.js 开发中,你可能会编写立即加载依赖项的代码:
```js title='parser.js' @ts-expect-error=[2] 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()` 实际被调用时?
```js title='parser.js' @ts-expect-error=[20]
// "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 密集型任务,请使用 worker threads,考虑将它们移到 BrowserWindow,或(作为最后手段)生成一个专用进程。
-
尽可能避免使用同步 IPC 和
@electron/remote模块。虽然存在合理的使用场景,但很容易在不知不觉中阻塞 UI 线程。 -
避免在主进程中使用阻塞 I/O 操作。简而言之,每当核心 Node.js 模块(如
fs或child_process)提供同步或异步版本时,你应优先选择异步且非阻塞的版本。
4. 阻塞渲染进程¶
由于 Electron 附带当前版本的 Chromium,你可以利用 Web 平台提供的最新功能,以推迟或转移繁重操作的方式,保持应用流畅且响应迅速。
为什么?¶
你的应用可能在渲染进程中运行大量 JavaScript。诀窍是尽可能快地执行操作,同时不要占用保持滚动流畅、响应用户输入或维持 60fps 动画所需的资源。
如果你的用户抱怨应用有时会“卡顿”,那么编排渲染进程代码中的操作流会特别有用。
如何?¶
一般来说,所有针对现代浏览器构建高性能 Web 应用的建议,同样适用于 Electron 的渲染进程。目前你可用的两个主要工具是:用于小型操作的 requestIdleCallback(),以及用于长时间运行操作的 Web Workers。
requestIdleCallback() 允许开发者将一个函数排队,以便进程进入空闲期时尽快执行。它让你可以执行低优先级或后台工作,而不影响用户体验。有关如何使用它的更多信息,请查看 MDN 上的文档。
Web Workers 是一个强大的工具,可在独立线程上运行代码。有一些注意事项需要考虑——请参阅 Electron 的多线程文档和Web Workers 的 MDN 文档。对于任何需要较长时间占用大量 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 + R 或 Ctrl + R 触发重新加载。
现在,这些工具会详细记录所有网络请求。在初步检查中,清点所有正在下载的资源,优先关注较大的文件。其中是否有不会变化且可以包含在你的包中的图像、字体或媒体文件?如果有,请包含它们。
作为下一步,启用 Network Throttling。找到当前显示为 Online 的下拉菜单,并选择较慢的速度,例如 Fast 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 中的相关讨论。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 el/electron