前言
在《屠龙少年终成恶龙,前端转产品的我给前端挖了个坑》这篇文章里,有讲到我是如何把我们的前端带坑里去。同时评论区有一条评论 你那个demo.exe是用什么实现的?可以直接套壳现有的系统? 看起来好像是在坑外徘徊不定若有所思的样子。因此我打算写本文把他踹坑里去,能踹一个是一个。
不想用 electron 和 tauri ?那我们一起来写个像 electron 的垃圾玩意吧~ 我们的目标是:前端程序员无需会三方语言就可独立完成桌面程序,创建托盘程序和服务、读写文件、处理进程、剪贴板这些都没有问题,预计整体体积不超过1M。
为什么做
我当前已经使用 nodejs 开发一个命令行程序,这个程序的工具方式是,从网络上获取动态的配置,然后读取这个配置进行启动。启动后就能去做其他额外的事情了,而不需要管这个程序。因为这个程序只是一个辅助工具。
但是目前有一些痛点:
每次启动的时候我都得先找到项目目录,然后运行 node xxx.js,然后启动一个黑框框,然后我再最小化这个框。启动步骤相当麻烦并且有一个不用管的窗口在任务栏,相当碍眼。
有很多方法可以处理启动问题,比如 pm2/快捷链接/全局安装/制作 PKG 安装包等。但各自有各自的问题,这里不一一列举。
对于有一个黑框需要最小化到任务栏问题,我尝试过使用 node child_process 的 detached=false, windowsHide=true 等参数配合 pm2 都是没有用的,黑框还是会弹出。 假设有用,我要的也不仅如此。
我觉得这个工具不错,我想要把这个工具发给别人使用,虽然这个工具是 nodejs 写的,但我不希望别人还要去学习安装 nodejs 环境。虽然这个工具是命令行启动,并支持参数配置,但我希望像常规程序一样,别人点击一个图标就能启动,可以从界面上配置参数。可以在界面上看到程序的实时日志,最小化之后,变成一个小图标在任务栏,不占空间不碍眼。
那么问题来了,因为我经常用 html/css/js 画界面,对很多前端组件库比较熟悉,所以我打算用前端写界面。但 js 是跑在浏览器里的,读取不了保存在电脑里的配置文件,更实现不了托盘图标功能,也运行不了 node 程序。
据我所知,像这种想使用前端语言开发界面,又需要与操作系统进行交互的功能,有不少方案。下面是我对他们的调研结果:
名称 | 前端 | 后端 | 体积 MB | 内存 MB | 放弃原因 | 备注 |
nodegui | chromium | nodejs | 100 | 100 | 体积大 | |
miniblink49 | Chromium | nodejs | ? | ? | 体积大 | 仅支持 window |
NW.js | Chromium | nodejs | 100 | 100 | 体积大 | |
electron | Chromium | nodejs | 100 | 100 | 体积大 | |
Wails | webview | go | 8M | ? | 需其他语言 | |
Tauri | webview | rust | 1 | ? | 需其他语言 | |
Qt | 可选 | C++ | 30 | ? | 需其他语言 | |
wpf | 可选 | C# | ? | ? | 需其他语言 | 仅支持 window |
Muon | Chromium | go | 42 | 26 | 需其他语言 | |
Sciter | Sciter | QuickJS | 5 | ? | 与普通浏览器和 nodejs 可能有差异 | |
gluon | 浏览器 | nodejs | 1 | 80 | 生态小,例如没有找到托盘图标实现方式 | |
neutralino | 浏览器 | API | 2M | 60 | api 不多 |
当前大家比较火有 electron 和 tauri。四年前我使用过 electron 做过一个桌面划词程序,由于涉及到系统操作,所以需要安装 node-gyp/pytohn/visual studio 等依赖来进行本地编译,能否操作成功与 electron/node/node-ffi 等版本兼容性有很大的关系,安装过程和 electron 的体积都给我留下了不好的印象,另外 electron 里的主进程、渲染进程、通信的一些使用上的差异,也让我觉得不那么便利,所以我放弃 electron 。
接下来就是 tauri,它由于不打包 nodejs 和 chromium ,所以体积较小。但我看他官网上的 demo,就连启动都 rust 代码。
虽然代码没几行,但我也是相当拒绝:说好的只使用前端语言就能写桌面程序呢?
所以我放弃了 tauri 。原因是我真想找一个不使用三方语言就能做桌面程序的工具。我发现 neutralino 比较贴近我的需求,但它当前还很年轻,很多 api 和示例都没有。这相当于如果遇到了操作系统层面上的问题,只要他不提供 api 我就没法操作,因为我不会写原生代码,所以又放弃了 neutralino 。
所以就自己做一个吧。
准备怎么做
准备使用当前了解的一个语言做一个基于 webview 的工具,我们暂且叫 main。它加载好前端页面,并向前端页面注入 api 并连接上 websockets 。如果前端有什么对系统操作的诉求,告诉 main 即可,由 main 完成,对于前端而言,就像调用一个普通的 js 方法一样,传参、处理结果、完事。
语言名为 aardio ,由于“各种原因”这里不做过多叙述。后面文档中统一称其为语言。
那么为什么都去搞一个语言了不搞 rust 这些?有几点考虑:
- 提供了 js/webview/nodejs 互相调用的例子
- 提供了一些常见的系统托盘、窗口操作示例等
- 我对作者维护这个语言这么多年心存敬畏
程序的整体架构是这样的:
- 配置层:常用的定制化需求,都可以通过一个 json 配置文件解决。js处理起来也简单。
- 依赖层:比如注入到 web 页的经过封装的 js 文件。
- 内核层:完成与 web 页面的通信,满足 web 页面对系统进行操作的常规诉求。
- 工具层:例如健壮性、安全性、自动升级、调试、打包、启动等。
做成了什么样
下面这个图片演示了启动程序时,有一个绿色的进度条,然后进入界面。
目前已过可行性验证阶段,给客户做了一个文件管理系统程序,类似一个网盘,页面由前端完成,然后文件的下载、预览、同步这些交给 main 提供的 api。
下面这个图片演示了在 web 中关闭程序。
对于自己的话,做了一个 ai 助手,对接的开源 ai-ui,已发给同事使用,也没有问题。做了一个文章开关提到的助手程序,自己使用。
再次演示一下透明窗口,上面的启动时的进度条也是使用透明窗口完成的。
演示自定义窗口标题和托盘。
程序启动时的进度条也是使用 html 实现的
loading... 遇到的问题及处理方案
官方示例中给到的 webview 交互示例通过 external 注入到页面的 window 上,通过此方法能让 js 中的数据和 main 进行类型转换(比如 js 里传一个 number,那么到 main 里也是 number),还提供了一些可以直接启用 main 里对象方法的操作。好用是好用,但是与 nodejs 交互的时候,没有这种自动转换的功能,而且示例中的 node 服务连接很慢。
为了让 main 支持 webview 和 nodejs,并且使用方法统一,并且加快启动速度,查了一些资料,发生像这种跨语言通信通常都是使用 rpc 协议完成的,有 json-rpc/http-rpc/rpc-ws 等,为了实时性更强,我选择了 websockets 这种方式, 我 npm 社区中发现有 www.npmjs.com/package/rpc… 这个包可用,还兼容 node 和浏览器,尝试过后选择了它,这解决了跨语言通信问题。

另一个问题是,mian 中有很多方法是现成的。比如以下代码在 main 中可以使用:
// 有一个 winform 对象winform.hitMax() // 最大化winform.show() // 显示窗口winform.hwnd // 获取窗口句柄winform.hitCaption() // 拖动窗口winform.text = "title" // 设置窗口标题// ... 上百个现在的方法和属性如果我们要为 js 提供 api, 我们是每个属性和方法都得去写吗?这又麻烦,代码又还臃肿。
经过一波挣扎,我想起了使用代理这种方式去实现,还是 js。
const obj = new Proxy({},{get: function (target, key, receiver) {console.log(`getting ${key}!`);return Reflect.get(target, key, receiver);},set: function (target, key, value, receiver) {console.log(`setting ${key}!`);return Reflect.set(target, key, value, receiver);},});根据 proxy,我们可以实现拦截到某个对象的方法调用和属性访问、设置等。再加上深层代理的话,像 winform.process.close() 这种有任何层方法属性都没有问题。
同时,在 main 中我们有这样的代码,来处理 proxy 拦截到的每个 key path:
我们把拦截到的 path ,比如在 js 里写 winform.process.close(true) 的时候,我们把拦截到的 winform.process.close 和参数 true 通过 rpc-ws 提供的 call 方法传给 main,这时候 main 根据 path 去动态调用函数并把参数传进去。我们把执行结果又丢给 call 方法返回给我们的 js 即可。
那么问题又来了,既然都实现了在 js 里调用方法和访问属性都像在写 main 中的代码一样,那真的就能不能以 js 的形式去写 main 的代码呢?看了一天的教程,发现这水很深啊,约等于创造一门语言,怕了怕了,逃。
但是思路着要有吧?好的:
如果简单一些呢,我们依然可以使用 proxy,实现操作符的拦截,从而实现一些简单的加减乘除的操作。然这没什么用啊,我们要实现的是比如用 js 里对 winform 对象进行遍历之前,我们就要做一个生成器之类的东西,在生成器的每一步里,去获取 main 里的遍历结果。感觉上好像能实现,实际我也不知道我在说什么。但是就算实现了,像这种遍历器,频繁的语言交互应该会消耗大量时间,感觉应该得不偿失。
所以在 js 里获得 main 中语言的编写体验,就不实现啦。如果我们真的要在 js 里写另一种语言,我们开放一个类似 js 的 eval 的功能。它可以向 main 传原生代码和参数。
// 创建目录const dir = `C:/my/`await ws.call(`run`, [`fsys.createDir(arg)`, dir])例如上面这段代码,直接传送目录参数 C:/my/ 到 arg,使用原生语言 fsys.createDir(arg) 去执行。
后期计划是什么
计划一:使用 main 去做更多的桌面 app,以此促进 main 的完善。
计划二:为某个当前成熟的 ui 框架制定一套 css 皮肤,例如 win7 皮肤 ,例如 element-ui 样子很 web,但应用了这个皮肤之后,整体页面风格和控件都看起来就像原生 win7 桌面程序一样。
计划三:尽快完成 api 的封装和文档,让前端朋友只调用指定的 js api 即可完成托盘、进程、剪贴板、IO等系统操作。我们封装的 api 尽量向 neutralino 靠近,做到最小成本的迁移。等它成熟后,可以迁入,没成熟之前我们也能自己用着。
需要什么帮助
可以帮我们封装 api,这需要你了解 main 的语言;可以用 main 来做些小工具尝试一下,这就是最好的帮助;可以做操作系统风格皮肤,等你做好了,electron 和 tauri 他们都能用,因为他只是 css;或者可以点个 star https://github.com/wll8/sys-shim
好了,饼画了,牛吹了,坑挖了,我要去玩了。
作者:程序媛李李李李李蕾
链接:https://juejin.cn/post/7304538151480803366