前言
在《屠龙少年终成恶龙,前端转产品的我给前端挖了个坑》这篇文章里,有讲到我是如何把我们的前端带坑里去。同时评论区有一条评论 你那个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 实现的
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>loading...</title>
<style>
body,
html {
height: 100%;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
@property --progress {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
.g-progress {
margin: auto;
width: 240px;
height: 10px;
border-radius: 25px;
background: linear-gradient(90deg, #0f0, #0ff var(--progress), transparent 0);
border: 1px solid #eee;
transition: .3s --progress;
}
</style>
</head>
<body>
<div class="box">
<div id="progress" class="g-progress progress-bar-striped" style="--progress: 10%"></div>
</div>
</body>
</html>
遇到的问题及处理方案
官方示例中给到的 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 桌面程序一样。
注:上面的整个窗口也是用 html/css 写的,不是真的 win7 窗口。
计划三:尽快完成 api 的封装和文档,让前端朋友只调用指定的 js api 即可完成托盘、进程、剪贴板、IO等系统操作。我们封装的 api 尽量向 neutralino 靠近,做到最小成本的迁移。等它成熟后,可以迁入,没成熟之前我们也能自己用着。
需要什么帮助
可以帮我们封装 api,这需要你了解 main 的语言;可以用 main 来做些小工具尝试一下,这就是最好的帮助;可以做操作系统风格皮肤,等你做好了,electron 和 tauri 他们都能用,因为他只是 css;或者可以点个 star github.com/wll8/sys-sh… 。
好了,饼画了,牛吹了,坑挖了,我要去玩了。