Electron 性能优化实践

463 阅读8分钟

本文将介绍一些常见的 Electron 性能优化的方式。Electron 应用由主进程和渲染进程两部分组成,针对Electron 的性能优化也主要是从 主进程优化渲染进程优化进程通信优化 三个方面进行。

1. 主进程优化

主进程优化主要针对 NodeJS 进行性能优化。由于 NodeJS 的异步单线程特性,程序在执行时会等待前面的任务执行完成,当任务阻塞时,会造成可感知的程序卡死情况,因此针对主进程主要优化方向有三个:减少阻塞性任务使用异步操作

1.1 延迟加载模块(减少阻塞性任务)

在命令行中执行下面的代码

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

该命令是在使用 Node.js 的高级性能分析功能,执行一个简单的第三方模块引入操作。

--cpu-prof

  • 开启 CPU 性能分析
  • 会生成一个 V8 CPU profile 文件(.cpuprofile
  • 记录代码执行时的 CPU 使用情况、调用栈和函数执行时间等信息
  • 文件可以使用 Chrome DevTools 或其他分析工具查看

--heap-prof

  • 开启内存堆分析
  • 生成一个堆快照文件(.heapprofile
  • 记录内存分配、对象创建和垃圾回收情况
  • 帮助发现内存泄漏和占用过多内存的对象

-e "require('request')"

  • -e 表示执行后面引号中的代码(而不是运行文件)
  • require('request') 加载 request HTTP 客户端库
  • 这个简单操作会触发模块的加载、解析和初始化过程

命令执行后会在当前目录生成类似这样的文件:

  • isolate-0xNNNNNNNNNNNN-NNNNN-v8.cpuprofile (CPU 分析文件)
  • isolate-0xNNNNNNNNNNNN-NNNNN-v8.heapprofile (内存分析文件)

通过 Chrome DevTools 的 Performance 和 Memory 面板导入分析,结果如下:

image.png

可以看到在 NodeJS 中初次运行 require('request') 这行代码时,模块解析、引入的耗时大概是 600ms,由于 require 操作是同步执行的,也就意味着,在主进程初次运行时,其中有 600ms 的阻塞用于加载该模块。类似的第三方模块的引入都是比较耗时的,在主进程运行时建议将相关的模块引入封装进入对应的业务逻辑之中。在衡量和评估优化效果时,可以统计主进程程序耗时来进行性能评估。

不建议的示例

const request = require('request')

function createRequest() {
    // code
}

建议的示例


function createRequest() {
    const request = require('request')
    // code 
}

1.2 IO缓冲队列(减少阻塞性任务)

在 NodeJS 中 IO 操作是比较耗时的,以下是一个对比。

方式一:对一个文件执行50次追加写入,每次写入100个字符串

const { appendFile } = require('fs')
const path = require('path')

const filePath = path.join(__dirname, 'test.txt')
const testStr = `${Array(100)
  .fill('A')
  .join('')}\n`
for (let i = 0; i < 50; i++) {
  appendFile(filePath, testStr, (err) => {
    if (err) throw err
    console.log('File has been written')
  })
}

执行

node --cpu-prof --heap-prof ./test.js

程序累计耗时 200ms

image.png

方式二:对一个文件执行一次追加写入,一次写入 5000 个字符串

const { appendFile } = require('fs')
const path = require('path')

const filePath = path.join(__dirname, 'test.txt')
const testStr = `${Array(100)
  .fill('A')
  .join('')}\n`
let text = ''
for (let i = 0; i < 50; i++) {
  text += testStr
}
appendFile(filePath, text, (err) => {
  if (err) throw err
  console.log('File has been written')
})

执行

node --cpu-prof --heap-prof ./test.js

程序累计耗时 71ms:

image.png

频繁的执行读写操作会影响NodeJS主进程的执行耗时,甚至可能会导致渲染进程的卡顿,在有频繁文件读写操作的场景中,可以使用写入缓冲队列的方式,收集写入文件内容,按一定的时间间隔刷新文件内容。

1.3 进程异步通信(使用异步操作)

在 Electron 中,渲染进程向主进程发送消息有三种方式,分别是

  • ipcRenderer.invoke: 双向通信,主进程使用 ipcMain.handle 监听
  • ipcRenderer.send: 单向通信,主进程使用ipcMain.on监听
  • ipcRenderer.sendSync: 双向通信,主进程使用 ipcMain.on 监听 其中 ipcRenderer.send 也可以实现双向通信,但是需要在渲染进程中增加对应的监听时间,如
const { ipcRenderer } = require('electron')

ipcRenderer.on('asynchronous-reply', (_event, arg) => {
  console.log(arg) // 在 DevTools 控制台中打印“pong”
})
ipcRenderer.send('asynchronous-message', 'ping')

主进程中

ipcMain.on('asynchronous-message', (event, arg) => {  
console.log(arg) // 在 Node 控制台中打印“ping”  
// 作用如同 `send`,但返回一个消息  
// 到发送原始消息的渲染器  
event.reply('asynchronous-reply', 'pong')  
})

三种通信方式对比如下:

通信方式特点阻塞情况
send/on单向非阻塞
sendSync/on双向同步阻塞
invoke/handle双向异步非阻塞

其中 sendSync/on 的方式渲染进程会等待主进程的返回,进而造成渲染进程卡死的情况,应尽量避免,如果要使用 send/on 的方式进行双向通信时,渲染进程也需要添加事件监听,相比之下invoke/handle 模式只需要在主进程添加一次监听即可,当存在大量的 ipc 通信时,建议使用 invoke/handle 模式,减少的事件监听带来的作用可以直接体现在内存的降低上。

1.4 异步读写文件(使用异步操作)

在 NodeJS 中有两种 API,一种是同步读写例如 readFileSyncwriteFileSync,另一种是异步读写如 readFilewriteFile 以下是两个同样效果的程序对比

方式一:使用同步接口写入文件

const { appendFileSync } = require('fs')
const path = require('path')

const filePath = path.join(__dirname, 'test.txt')
const testStr = `${Array(100)
  .fill('A')
  .join('')}\n`
for (let i = 0; i < 50; i++) {
  appendFileSync(filePath, testStr)
}

image.png

方式二:使用异步接口写入文件

const { appendFile } = require('fs').promises
const path = require('path')

const filePath = path.join(__dirname, 'test.txt')
const testStr = `${Array(100)
  .fill('A')
  .join('')}\n`

async function write() {
  for (let i = 0; i < 50; i++) {
    await appendFile(filePath, testStr)
  }
}

write()

image.png

大量的使用同步读写的的接口会导致主进程资源无法释放,涉及进程通信时,会导致渲染进程长时间等待,页面阻塞,当数据量过大,主进程资源无法释放时,会导致主进程卡死,进而导致程序卡死情况。

2. 渲染进程优化(Vue)

针对渲染进程的优化主要集中在 Web 端页面上,针对Web页面的性能评估有两种方式,一种是 lighthouse 的方式进行页面性能评分,该方式主要使用于Web端、SEO及首屏渲染等指标,重在用户体验一次。另一种是使用Performance Monitor 面板的性能评估,该方法主要用于页面js性能、页面实时交互的性能优化上,在Electron 的渲染进程优化中,更推荐使用 Performance Monitor 的方式。 image.png

Performance Monitor 面板针对不同的场景和性能瓶颈的现象关注点可能会存在不同,其中各项指标主要用途如下:

  • CPU 占用率:反映页面的计算情况,密集的计算可能会导致页面卡死,一般 3D 应用的该项指标会较高
  • DOM 节点数:反映页面中实际节点和虚拟节点的总量,常规的高性能页面DOM节点数一般在几千个左右,DOM数量过大会导致页面内存偏高
  • 事件监听数:反映页面中监听事件数目,如果持续升高,可能是由于页面销毁后部分资源未回收,造成内存泄露
  • JS heap size: 反映了页面中引用类型如函数、数组、对象等的使用情况,该指标可以用于检测页面内存泄漏

最直观的方式可以考虑减少页面DOM节点总数,先降低页面复杂度,再考虑具体的数据结构的性能优化。

2.1 使用 v-if 替换 v-show - 减少 DOM Node

在vue的单页应用中,在不使用 vue-router 的情况下,单个页面组件挂载的DOM节点数目会非常庞大,常见的一种优化方式是根据组件的可见性,使用 v-if 来切换 DOM 的挂载和卸载。
例如 vue 官网中 顶部下来菜单,在下拉菜单不可见时,其节点依然挂载在 DOM 树上。在这些视图不可见时,可以考虑使用 v-if 的方式,将其从 DOM 树中移除,减少的 DOM 节点和对应的组件内的数据监听事件。
这个例子也许不是很恰当,当前页面本身页面DOM节点并不庞大,并且下拉菜单常驻DOM树可以使交互更加流畅。
但是在 DOM 节点庞大例如 10000+ 的页面中,减少实际挂载的 DOM 节点,可以显著的降低页面内存占用。 image.png

2.2 移除不必要的响应式数据 - 减少 Event Listener

在 Vue 中,每一个组件的 data 中的属性(vue2) 或 ref() 数据都是响应式的,在组件创建的时候,会添加相应的拦截器去监听这些数据的变更,同时消息中心也会订阅对应的数据变更的事件通知。
因此,当一些数据并非刚性的需要做成响应式的情况下,可以考虑使用一般变量或者直接挂载在组件 this 上的方式,避免这些常量数据被处理成响应式数据,进而造成额外的监听事件的开销。

2.3 使用keep-alive/component 动态组件方案 - 减少DOM Node

在单一页面内,当存在多个组件切换的场景中,如果组件较多,且需要保留组件状态的话,动态组件 component + keep-alive 的方式可以实现在不显著增加 DOM 节点的情况下,组件的带状态切换。

2.4 使用定时器移除不活跃节点

针对具体的业务中,有一些页面仅间歇性的打开一次,之后可能长时间的隐藏,但是这些页面中的事件监听、DOM 树依然常驻内存,针对这些页面可以使用定时器的方式,设定好销毁时间和活跃状态统计,在失去活跃状态的一段时间后,主动的将该组件使用 v-if 的方式销毁。

3. 借助 AI 工具进行性能分析

AI 工具在日常工作中起到的作用越来越大,针对一些页面的性能优化,可以结合 Chrome Devtool 中性能面板的截图 + AI 分析的方式对当前的页面性能进行一些评估,并给出一些潜在的优化方向。

4. 参考文档

  1. www.electronjs.org/zh/docs/lat…
  2. www.electronjs.org/zh/docs/lat…
  3. www.electronjs.org/docs/latest…