谈谈我做 Electron 桌面端应用的这一两年
大家好,我是徐徐。今天和大家谈谈我做 Electron 桌面端应用的这一两年,把一些经验和想法分享给大家。
前言
入职现在这家公司三年了,刚进公司的时候是 21 年年初,那时候会做一些稍微复杂的后台管理系统以及一些简单的 C 端 SDK。准备开始做 Electron 项目是因为我所在的是安全部门,急需一款桌面管控软件来管理(监控)员工的电脑安全以及入网准入,可以理解为一款零信任的桌面软件。其实之前公司也有一款安全管控的软件,但是Windows 和 Mac是分端构建的,而且维护成本极高,Windows 是使用的 C#, Mac 是用的 Objective-C,开发和发版效率低下,最后在研发老大的同意下,我和另外一个同事开始研究如何用 Electron 这个框架来做一款桌面端软件。
我们发起这个项目大概是在 21 年年底,Windows 版本上线是在上海疫情封城期间,2022年4月份的时候,疫情结束后由于事业部业务方向的调整,又被抽调到了另外一个组去做一个 C 端的创业项目,后面项目结束了,又回来做 Electron 相关的工作直到现在,之所以是一两年,其实就是这个时间线。
对桌面端开发的一些看法
如果你是前端的话,多一门桌面端开发的技能也不是坏事,相当是你的一个亮点,进可攻,退可守。因为桌面端开发到后期的架构可以非常的复杂,不亚于服务端(chromium 就是一个例子),当然也取决于你所应对的场景的挑战,如果所做的产品跟普通前端无异,那也不能说是一个亮点,但是如果你的工作已经触及到一些操作系统的底层,那肯定是一个亮点。
当然也有人说,做桌面端可能就路越走越窄了,但是我想说的是深度和广度其实也可以理解为一个维度,对于技术人来说,知道得越多就行,因为到后期你要成为某个方面的专家,就是可能会非常深入某一块,换一种思路其实也是叫知道得越多。所以,我觉得前端能有做桌面端的机会也是非常好的,即拓展了自己的技能,还能深入底层,因为现阶段由于业务方向的需要,我已经开始看 chromium 源码了,前端的老祖宗。当然,以上这些只代表自己的观点,大家自行斟酌。
谈谈 Electron
其实刚刚工作前两年我就知道这个框架了,当时也做过小 demo,而且还在当时的团队里面分享过这个技术,但是当时对这门技术的认知是非常浅薄的,就知道前端可以做桌面端了,好厉害,大概就停留在这个层面。后面在真正需要用到这门技术去做一个企业级的桌面应用的时候才去真正调研了一下这个框架,然后才发现它真的非常强大,强大到几乎可以实现你桌面端的任何需求。网上关于 Electron 与其他框架的对比实在是太多了,Google 或者 Baidu 都能找到你想要的答案,好与不好完全看自己的业务场景以及自己所在团队的情况。
谈谈自己的感受,什么情况下可以用这门框架
- 追求效率,节省人力财力
- 团队前端居多
- UI交互多
什么情况下不适合这门框架呢?
- 包体积限制
- 性能消耗较高的应用
- 多窗口应用
我们当时的情况就是要追求效率,双端齐头并进,所以最后经过综合对比,选择了 Electron。毕竟 Vscode 就是用它做的,给了我们十足的信心和勇气,一点都不虚。
一图抵千字,我拿出这张图你自己就有所判断了,还是那句话,仁者见仁,智者见智,完全看自己情况。
图片来源:https://www.electronjs.org/apps
技术整体架构
这里我画了一张我所从事 Electron 产品的整体技术架构图。 整个项目基于 Vite 开发构建的,基础设施就是常见的安全策略,然后加上一些本地存储方案,外加一个外部插件,这个插件是用 Tauri 做的 Webview,至于为什么要做这个插件我会在后面的段落说明。应用层面的框架主要是分三个大块,下面主要是为了构建一些基础底座,然后将架构进行分层设计,添加一些原生扩展,上面就是基础的应用管理和 GUI 相关的东西,有了这个整体的框架在后面实现一些业务场景的时候就会变得易如反掌(夸张了一点,因为有的技术细节真的很磨人😐)。
当然这里只是一个整体的架构图,其实还有很多技术细节的流程图以及业务场景图并没有在这里体现出来,不过我也会挑选一些方案在后面的篇幅里面做出相应的讲解。
挑战和方案
桌面端开发会遇到一些挑战,这些挑战大部分来源于特殊的业务场景,框架只能解决一些比较常见的应用场景,当然不仅仅是桌面端,其实移动端或者是 Web 端我相信大家都会遇到或多或少的挑战,我这里遇到的一些挑战和响应的方案不一定适合你,只是做单纯的记录分享,如果有帮助到你,我很开心。下面我挑选软件升级更新,任务队列设计,性能检测优化以及一些特殊的需求这几个方面来聊聊相应的挑战和方案。
软件升级更新
桌面端的软件更新升级是桌面端开发中非常重要的一环,一个好的商业产品必须有稳定好用的解决方案。桌面端的升级跟 C 端 App 的升级其实也是差不多的思路,虽然我所做的产品是公司内部人使用,但是用户也是你面向公司所有用户的,所以跟 C 端产品的解决思路其实是无异的。
升级更新主要是需要做到定向灰度。这个功能是非常重要的,应该大部分的应用都有定向灰度的功能,所以我们为了让软件能够平滑升级,第一步就是实现定向灰度,达到效果可回收,性能可监控,错误可告警的目的。定向更新的功能实现了之后,后面有再多的功能需要实现都有基础保障了,下面是更新相关的能力图。
整个更新模块的设计是分为两大块,一块是后台管理系统,一块是客户端。后台管理系统主要是维护相应的策略配置,比如哪些设备需要定向更新,哪些需要自动更新,不需要更新的白名单以及更新后是需要提醒用户相应的更新功能还是就静默更新。客户端主要就是拉取相应的策略,然后执行相应的更新动作。
由于我们的软件是比较特殊的一个产品,他是需要长期保活的,Mac 端上了文件锁是无法删除的。所以我们在执行更新的时候和常规的软件更新是不一样的,软件的更新下载是利用了 electron-update 相应的钩子,但是安装的时候并没有使用相应的钩子函数,而是自己研究了 electron 的更新源码后做了自己的更新脚本。 因为electron 的更新它自己也会注册一个保活的更新任务的服务,但是这个和我们的文件锁和保活是冲突的,所以是需要禁用掉它的保活服务,完成自己的更新。
整体来说,这一块是花了很多时间去研究的,windows 还好,没有破坏其整个生命周期,傻瓜式的配置一下electron-update 相关的函数钩子就可以了。Mac 的更新花了很多时间,因为破坏了文件的生命周期,再加上保活任务,所以会对 electron-update 的更新钩子进行毁灭性的破坏,最后也只能研究其源码然后自己去实现特殊场景下的更新了。
任务队列设计
任务模块的实现在我们这个软件里面也是非常重要的一环,因为客户端会跑非常多的定时任务。刚开始研发这个产品的时候其实还好,定时任务屈指可数,但是随着长时间的迭代,端上要执行的任务越来越多,每个任务的触发时间,触发条件都不一样,以及还要考虑任务的并发情况和对性能的影响,所以在中后期我们对整个任务模块都做了相应的改造。
下面是整个任务模块的核心能力图。
业界也有一些任务相关的开源工具包,比如 node-schedule、node-cron、cron,这些都是很优秀的库,但是我在使用过程中发现他们好像不具备并发限制的场景,比如有很多任务我们在开始设置的时候都会有个时间间隔,这些任务的时间间隔都是可以在后台随意配置的,如果端上不做并发限制会导致一个问题,就是用户某一瞬间会觉得电脑非常卡。
比如你有 4 个 10 分钟间隔的任务 和 2 个五分钟间隔的任务,那么到某一个时间段,他最大并发可能就是 6,如果刚好这 6 个任务都是非常耗费 CPU 的任务,那他们一起执行的时候就会导致整个终端CPU 飙升,导致用户感觉卡顿,这样就会收到相应的 Diss。
安全类的软件产品其实有的时候不需要太过醒目,后台默默运行就行,所以我们的宗旨就是稳定运行,不超载。为此我们就自己实现了相应的任务队列模式,然后去控制任务并发。其实底层逻辑也不难,就是一个 setInterval 的函数,然后不断的创建销毁,读取队列的函数,执行相应的函数。
性能优化
Electron相关的性能优化其实网上也有非常多的文章,我这里说说我的实践和感受。
首先,性能优化你需要优化什么?这个就是你的出发点了,我们要解决一个问题,首先得知道问题的现状,如果你都不知道现在的性能是什么样子,如何去优化呢?所以发现问题是性能优化的最重要的一步。
这里就推荐两个工具,一个是chrome dev-tool,一个是electron 的 inspector,第一个可以观测渲染进程相关的性能情况,第二个可以观测主进程相关的性能情况。
具体可参考以下网址:
- https://developer.chrome.com/docs/devtools/overview?hl=zh-cn
- https://www.electronjs.org/zh/docs/latest/tutorial/debugging-main-process
有了工具之后我们就需要用工具去分析一些数据和问题,这里面最重要的就是内存相关的分析,你通过内存相关的分析可以看到 CPU 占用高的动作,以及提前检测出内存泄漏的风险。只要把这两个关键的东西抓住了,应用的稳定性就可以得到保障了,我的经验就是每次发布之前都会跑一遍内存快照,内存没有异常才进行发布动作,内存泄漏是最后的底线。
我说说我大概的操作步骤。
- 通过Performance确认大体的溢出位置
- 使用Memory进行细粒度的问题分析
- 根据heap snapshot,判断内存溢出的代码位置
- 调试相应的代码块
- 循环往复上面的步骤
上面的步骤在主进程和渲染进程都适用,每一步实际操作在这里就不详细展开了,主要是提供一个思路和方法,因为 dev-tool 的面板东西非常多,扩展开来都可以当一个专题了。
然后我再说说桌面端什么地方可能会内存泄漏或者溢出,下面这些都是我血和泪的教训。
- 创建的子进程没有及时销毁:
如果子进程在完成任务后未被正确终止,这些进程会继续运行并占用系统资源,导致内存泄漏和资源浪费。
假设你的 Electron 应用启动了一个子进程来执行某些计算任务,但在计算完成后未调用 childProcess.kill()
或者未确保子进程已正常退出,那么这些子进程会一直存在,占用系统内存。
const { spawn } = require('child_process');
const child = spawn('someCommand');
child.on('exit', () => {
console.log('Child process exited');
});
// 未正确终止子进程可能导致内存泄漏
- HTTP 请求时间过长没有正确处理:
长时间未响应的 HTTP 请求如果没有设定超时机制,会使得这些请求占用内存资源,导致内存泄漏。
在使用 fetch
或 axios
进行 HTTP 请求时,如果服务器长时间不响应且没有设置超时处理,内存会被这些未完成的请求占用。
const fetch = require('node-fetch');
fetch('https://example.com/long-request')
.then(response => response.json())
.catch(error => console.error('Error:', error));
// 应该设置请求超时
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 5000); // 5秒超时
fetch('https://example.com/long-request', { signal: controller.signal })
.then(response => response.json())
.catch(error => console.error('Error:', error));
- 事件处理器没有移除
未正确移除不再需要的事件处理器会导致内存一直被占用,因为这些处理器仍然存在并监听事件。
在添加事件监听器后,未在适当时机移除它们会导致内存泄漏。
const handleEvent = () => {
console.log('Event triggered');
};
window.addEventListener('resize', handleEvent);
// 在不再需要时移除事件监听器
window.removeEventListener('resize', handleEvent);
- 定时任务未被正确销毁
未在适当时候清除不再需要的定时任务(如 setInterval
)会导致内存持续占用。
使用 setInterval
创建的定时任务,如果未在不需要时清除,会导致内存泄漏。
const intervalId = setInterval(() => {
console.log('Interval task running');
}, 1000);
// 在适当时机清除定时任务
clearInterval(intervalId);
- JavaScript 对象未正确释放
长时间保留不再使用的 JavaScript 对象会导致内存占用无法释放,特别是当这些对象被全局变量或闭包引用时。
创建了大量对象但未在适当时机将它们置为 null
或解除引用。
let bigArray = new Array(1000000).fill('data');
// 当不再需要时,应释放内存
bigArray = null;
- 窗口实例未被正确销毁
未关闭或销毁不再使用的窗口实例会继续占用内存资源,即使用户已经关闭了窗口界面。
创建了一个新的 BrowserWindow 实例,但在窗口关闭后未销毁它。
const { BrowserWindow } = require('electron');
let win = new BrowserWindow({ width: 800, height: 600 });
win.on('closed', () => {
win = null;
});
// 应确保在窗口关闭时正确释放资源
- 大文件或大数据量的处理
处理大文件或大量数据时,如果没有进行内存优化和分批处理,会导致内存溢出和性能问题。
在读取一个大文件时,未采用流式处理,而是一次性加载整个文件到内存中。
const fs = require('fs');
// 不推荐的方式:一次性读取大文件
fs.readFile('largeFile.txt', (err, data) => {
if (err) throw err;
console.log(data);
});
// 推荐的方式:流式读取大文件
const readStream = fs.createReadStream('largeFile.txt');
readStream.on('data', (chunk) => {
console.log(chunk);
});
一些特殊的需求
做这个产品也遇到一些特殊的需求,有的需求还挺磨人的,这里也和大家分享一下。
- 保活和文件锁
作为一个前端,桌面端的保活和文件锁这种需求基本是之前不可能接触到的,为了做这个需求也去了解了一下业界的实现,其实实现都还好,主要是它会带来一些问题,诸如打包构建需要自定义前置脚本和后置脚本,root 用户环境下 mac 端无法输入中文,上面提到的用 Tauri 构建一个 webview 组件就是为了解决 root 用户无法输入中文的场景。
- 静默安装应用
这个需求也是很绝的一个需求。我想如果是做常规的前端开发,估计一般都不会遇到这种需求,你需要从头到尾实现一个下载器,一个软件安装器,而且还要双端适配,不仅如此,还需要实现 exe、zip、dmg、pkg 等各种软件格式的安装,里面包含重试机制,断点下载,队列下载等各种技术细节。当时接到这个需求头也特别大,不过技术方案做出来后感觉也还好,再复杂的需求只要能理清思路,其实都可以慢慢解决。
- VPN 和 访问记录监控
这种需求对一个前端来说更是无从入手,但是好在之前有老版本的 VPN 做参考,就是根据相应的代码翻译一遍也能实现,大部分可以用命令行解决。至于访问记录监控这个玩意咋说呢,客户端做其实也挺费神的,如果不借助第三方的开源框架,自己是非常难实现的,所以这种就是需要疯狂的翻国外的网站,就GitHub,Stackoverflow啰,总有一款适合你,这里就不具体说明了。
- 进程禁用
违规进程禁用其实在安全软件的应用场景是非常常见的,它需要实时性,而且对性能要求很高,一个是不能影响用户正常使用,还要精准杀掉后台配置的违规进程,这个地方其实也是做了很多版优化,但是最后的感觉还是觉得任务队列有性能瓶颈,无法达到要求,现阶段我们也在想用另外的方式去改造,要么就是上全局钩子,要么就是直接把相应的进程文件上锁或者改文件权限。
上面所提到的需求只是一小部例子,还好很多奇奇怪怪的需求没有举例,这些奇怪的需求就像小怪物,不断挑战我的边界,让我也了解和学习和很多奇奇怪怪的知识,有的时候我就会发出这样的感叹:我去,还能这样?
结语
洋洋洒洒,不知不觉已经写了 5000 字了,其实做 Electron 桌面端应用的这一两年自我感觉还是成长了不少,不管是技术方面还是产品设计方面,自己的能力都有所提升。但是同样会遇到瓶颈,就是一个东西一直做一直做,到后面创新会比较难,取得的成就也会慢慢变少。
另外就是安全类的桌面端产品在整个软件开发的里其实是非常冷门的一个领域,他有他的独特性,也有相应的价值,他需要默默的运行,稳定的运行,出问题可以监控到,该提醒的时候提醒用户。你说他低调吧,有时候也挺高调的,真的不好定论,你说没影响力吧,有的时候没他还真不行。让用户不反感这种软件,拥抱这种软件其实挺难的。从一个前端开发的视角来看,桌面端的体验的确很重要,不管是流畅度还是美观度,都不能太差,这也是我们现阶段追求的一个点,就是不断提升用户体验。
路漫漫其修远兮,吾将上下而求索。前端开发这条路的确很长,如果你想朝某个方面深度发展,你会发现边界是非常难触达的,当然也看所处的环境和对应的机遇,就从技术来说的话,前端的天花板也可以很高,不管是桌面端,服务端,移动端,Web端,每个方向前端的天花板都非常难触摸到。
最后,祝大家在自己的领域越来越深,早日触摸到天花板。