跳转至

性能

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

关于如何使用 JavaScript 构建高性能网站的经验和信息,通常也适用于 Electron 应用。在某种程度上,讨论如何构建高性能 Node.js 应用的资源也适用,但请注意理解,“性能”一词对于 Node.js 后端和运行在客户端的应用而言含义不同。

本列表为了方便你提供——就像我们的安全检查清单一样——并非旨在详尽无遗。遵循下面所有步骤仍然可能构建出一个缓慢的 Electron 应用。Electron 是一个强大的开发平台,它允许你作为开发者几乎可以做任何想做的事。所有这些自由意味着性能在很大程度上取决于你。

测量、测量、再测量

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

一次又一次,我们看到构建高性能 Electron 应用最成功的策略是对运行中的代码进行性能分析,找到其中最消耗资源的部分,并对其进行优化。反复重复这个看似繁琐的过程会显著提升应用性能。与 Visual Studio Code 或 Slack 等大型应用合作的经验表明,这种做法是提升性能迄今为止最可靠的策略。

要了解如何对应用代码进行性能分析,请熟悉 Chrome Developer Tools。对于需要同时查看多个进程的进阶分析,可以考虑 Chrome Tracing 工具。

检查清单:性能建议

如果你避免以下常见陷阱,你的应用可能会更精简、更快,并且总体上更不消耗资源。

  1. 随意包含模块
  2. 过早加载和运行代码
  3. 阻塞主进程
  4. 阻塞渲染进程
  5. 不必要的垫片
  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 Developer Tools 进行分析,分别使用 Performance 和 Memory 选项卡。

性能 CPU 分析

性能堆内存分析

在这个示例中,在作者的机器上,我们看到加载 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 强大的多进程架构随时可以协助你处理长时间运行的任务,但也包含一些性能陷阱。

  1. 对于长时间运行的 CPU 密集型任务,请使用 worker threads,考虑将它们移到 BrowserWindow,或(作为最后手段)生成一个专用进程。

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

  3. 避免在主进程中使用阻塞 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