性能
开发者经常询问优化 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()
方法的模块。
该模块通过尝试连接一些知名端点来检测您的网络连接。为了获取这些端点的列表,它依赖于另一个模块,这个模块也包含了一个知名端口的列表。这个依赖本身又依赖于一个包含端口信息的模块,该信息以一个超过10万行内容的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 密集型任务,请使用 工作线程 (worker threads),考虑将它们移到 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 编写的代码,请确保不要为 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 用户一开始是基于一个纯网页应用,然后将其转变为桌面应用程序。作为网页开发者,我们习惯于从各种内容分发网络(CDN)加载资源。既然您正在发布一个真正的桌面应用程序,请尽可能地“剪断网线”,避免让您的用户等待那些永远不会改变、并且可以轻松包含在您应用中的资源。
一个典型的例子是 Google Fonts。许多开发者利用谷歌令人印象深刻的免费字体库,它附带了一个内容分发网络。宣传点很简单:包含几行 CSS,谷歌就会处理剩下的事情。
在构建 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 进行相关讨论。