Tauri 还是 Electron:桌面端选型的三个判断维度与实测数据

桌面端技术选型,我现在的习惯是先不看框架本身,先回答三个问题:这个应用装在谁的机器上、包体和内存有多敏感、团队里有没有人能维护 Rust 代码。这三个问题的答案基本决定了选型结果,框架的功能列表反而是最后才需要看的东西

背景一句话带过。两年前我用 Electron 给运营团队做过一个内部工具——图片批量处理加素材上传——一直跑到现在,Electron 那套主进程、渲染进程、IPC 的基础当年写过一篇,这篇不重复。最近要做一个新工具:给仓库同事用的扫码核单客户端,扫快递单条码、对订单、标记异常件。立项时我提了一句"要不要试试 Tauri",于是有了这轮认真的对比评估。

先把三个问题在这两个项目上的答案摆出来,后面所有的实测都是为这三个答案服务的。

目标机器:运营的电脑是公司统一采购的 Windows 10/11,环境可控;仓库的机器五花八门,有几台还是 Windows 7 的老工控机,这一点后面会变成关键变量。

包体敏感度:内部分发走内网下载,安装包体积不是硬约束;但仓库那批机器内存只有 4G,还要同时挂着 ERP 客户端,运行时内存占用是硬约束。

团队 Rust 能力:我自己去年断断续续学过一阵 Rust,能写但不快;团队里其他前端没有碰过。

版本基线

评估用的版本先钉死,免得对比失去意义。Tauri 用 1.5,去年十月发的稳定版;Electron 用 28,去年十二月发布,Chromium 120。Electron 29 应该快发了,但评估只认手上装得到的稳定版。

Tauri 那边还有个绕不开的话题是 2.0:官方的路线图在往移动端走,iOS 和 Android 复用同一套代码,目前还在 beta 阶段。听起来很有想象力,但对这次选型没有影响——评估只认已经发布的稳定版,路线图不能当选型依据,2.0 等它正式发了再看。

体积与内存:差距是数量级的

评估方式很直接:把现有那个 Electron 工具的核心页面(一个 Vue 3 的 SPA,Element Plus,业务逻辑抽掉换成假数据)分别打成 Electron 28 和 Tauri 1.5 的包,同一台 Windows 11 的机器上对比。

安装包体积:Electron 打出来 87MB(NSIS 安装包,压缩后),Tauri 打出来 4.8MB(MSI)。

这个差距的原因不神秘。Electron 把整个 Chromium 和 Node.js 运行时塞进了每一个应用,你装十个 Electron 应用,硬盘上就躺着十份 Chromium;Tauri 用的是系统自带的 WebView,安装包里只有 Rust 编译出来的那个二进制加前端静态资源,我们这个 demo 的二进制本体不到 3MB。

内存占用的差距同样明显。应用启动后停在主界面不动,等一分钟稳定,任务管理器里把所有相关进程加总:Electron 版大约 210MB——主进程、渲染进程、GPU 进程三块;Tauri 版大约 80MB。

在 4G 内存还要同时跑 ERP 的仓库机器上,130MB 的差距不是优化,是能不能用的问题。这一条基本决定了扫码客户端的走向。

不过这里要泼一盆自己头上的冷水:两边的计量口径不完全公平。Tauri 的数字里,WebView2 的浏览器进程和系统里其他 WebView2 应用是部分共享的,单看任务管理器会低估它的真实成本;Electron 的数字则是完全独立、一分不少的。我又做了一组"系统里没有任何其他 WebView2 应用在跑"的对照,Tauri 的总占用上浮到 110MB 左右。口径掰平之后,差距仍然是接近一倍,结论不变,但写报告时把这个口径问题标出来了——实测数据如果不交代计量口径,就是给未来的决策埋雷。

启动速度顺带记一笔:冷启动 Tauri 约 1.2 秒出主界面,Electron 约 2.8 秒。仓库场景一天开一次,这条差异不关键,就不展开了。

系统 WebView:省下的体积在哪里还回来

Tauri 的体积优势来自"不带浏览器",而它最大的风险也来自"不带浏览器"。这一节是整个评估里我花时间最多的部分。

Tauri 1.x 在 Windows 上用 WebView2(Edge 的 Chromium 内核),macOS 上用 WKWebView,Linux 上用 WebKitGTK。三个平台三个内核,这意味着两件事。

第一件:兼容性测试的负担回来了。Electron 时代有个隐性福利——打包时锁定了 Chromium 版本,用户跑的浏览器内核和你开发时一字不差,前端代码永远不需要考虑"这个 CSS 特性目标环境支不支持",browserslist 直接写死一个 Chrome 版本完事。

Tauri 把这个福利收回去了。WKWebView 和 Chromium 在不少细节上有差异,我在公司的 MacBook 上跑同一套 demo 就撞到两个:拖拽事件里 dataTransfer 的行为和 Chrome 不一致;日期输入控件的原生样式完全是两套东西。WebKitGTK 那边更麻烦,不同 Linux 发行版带的版本参差,新特性支持要按最老的算。我们的场景只发 Windows,实际影响有限,但如果要三端分发,Tauri 等于把"跨浏览器兼容"这个前端最古老的问题重新请回了桌面端

第二件更实际:WebView2 的分发问题。

Windows 11 和近两年更新过的 Windows 10 都自带 WebView2 Runtime,但老机器不一定有。Tauri 的打包配置里给了几种策略,在 tauri.conf.json 里选:

1{
2  "tauri": {
3    "bundle": {
4      "windows": {
5        "webviewInstallMode": {
6          "type": "offlineInstaller"
7        }
8      }
9    }
10  }
11}

downloadBootstrapper 是安装时联网下载 Runtime,安装包最小,但要求目标机器能出网;offlineInstaller 把 127MB 的 Runtime 离线安装器打进包里,体积膨胀但完全离线可用;还有 fixedRuntime 固定版本捆绑,连 Runtime 版本都锁死,最重但最可控。

仓库那几台机器不联网,只能选离线捆绑,于是 4.8MB 的安装包变成了 130MB 出头。体积优势在需要捆绑 Runtime 的场景下直接归零,选型前必须先查清楚目标机器的系统版本分布和网络条件,这一步偷懒,上线当天就会还回来

至于那几台 Windows 7 的老工控机,查下来 WebView2 对 Win7 的支持去年就终止了,新版本 Runtime 干脆装不上。最后的结论是这批机器保留现在的网页版旧方案,不纳入新客户端的覆盖范围——这是拿产品覆盖范围换技术选型的取舍,提前跟仓库主管当面对齐了,他的原话是那几台机器本来就该报废了。

Rust 侧:invoke 的开发体验比想象中平滑

Tauri 的架构是前端跑在 WebView 里,系统能力——文件、串口、子进程——都在 Rust 侧,前端通过 invoke 调用 Rust 暴露的命令。评估前我最担心的就是这一层:如果每个系统调用都要写一坨陌生的 Rust,对团队就是持续交税。

实际写下来比预期平滑。Rust 侧一个命令就是一个带宏标注的函数,参数和返回值靠 serde 自动做序列化:

1use serde::Serialize;
2
3#[derive(Serialize)]
4struct ScanResult {
5    code: String,
6    matched: bool,
7    order_status: String,
8}
9
10#[tauri::command]
11async fn verify_barcode(code: String, order_id: String) -> Result<ScanResult, String> {
12    let order = query_order(&order_id)
13        .await
14        .map_err(|e| e.to_string())?;
15    Ok(ScanResult {
16        matched: order.contains_sku(&code),
17        order_status: order.status,
18        code,
19    })
20}
21
22fn main() {
23    tauri::Builder::default()
24        .invoke_handler(tauri::generate_handler![verify_barcode])
25        .run(tauri::generate_context!())
26        .expect("error while running tauri application");
27}

前端这边就是一个类型化的异步调用:

1import { invoke } from '@tauri-apps/api/tauri';
2
3const result = await invoke<ScanResult>('verify_barcode', {
4  code: scannedText,
5  orderId: currentOrder.id,
6});
7
8if (!result.matched) {
9  showMismatchAlert(result);
10}

几个第一天就撞上的小坑记一下。命名转换:Rust 侧的 snake_case 参数名,前端要用 camelCase 传,Tauri 会自动转换——order_id 对应 orderId,第一次没对上的时候我盯着"missing required key"的报错查了半天。错误通道:Rust 的 Result::Err 会映射成前端 promise 的 reject,习惯之后比 Electron 里 ipcRenderer.invoke 手动约定错误结构要整洁。还有 Rust 侧的编译速度:改一行命令代码,增量编译也要十几秒,跟前端这边 Vite 毫秒级热更新放在一起,落差相当明显——好在前端改动不触发 Rust 编译,日常开发大部分时间感知不到。

除了 invoke 这种前端主动调用,反方向的推送用事件系统。扫码枪在 Rust 侧监听,扫到码往前端 emit:

1app_handle.emit_all("barcode-scanned", payload)?;
1import { listen } from '@tauri-apps/api/event';
2
3await listen<BarcodePayload>('barcode-scanned', (event) => {
4  handleScan(event.payload);
5});

这套模型和 Electron 的 webContents.sendipcRenderer.on 是同构的,前端同事理解起来没有门槛。

真正的成本不在写命令,在生态之外的部分。Electron 里遇到需求,npm 上大概率有维护多年的现成包;Tauri 的 Rust 侧遇到需求要去 crates.io 找,找到的库文档和成熟度参差。我们要接 USB 口的扫码枪,Electron 生态有 node-hid 这种老牌库,Rust 侧对应的 hidapi 绑定能用,但 Windows 下设备热插拔有个已知问题,得自己在上层绕。团队里如果只有一个人能写 Rust,这个人就是整个项目的单点——这一条比任何性能数字都更应该写进决策文档

顺带说一句 Tauri 的权限模型,这是评估里的意外加分项。1.x 的 allowlist 机制要求在配置里显式声明前端能碰哪些 API:

1{
2  "tauri": {
3    "allowlist": {
4      "fs": {
5        "scope": ["$APPDATA/scan-logs/*"]
6      },
7      "shell": { "open": false }
8    }
9  }
10}

默认全关、按需放开,文件访问还能限定路径范围。对比 Electron 里 nodeIntegration 一开就是整个 Node 能力裸奔(当然现在的最佳实践是 contextBridge,但那是"你要自己做对",不是"默认就是对的"),Tauri 这个默认安全的姿态确实是后发的优势。

开发与调试体验:日常的每一个小时

选型评估容易只盯着运行时指标,但开发体验是团队每天要泡在里面的东西,单独跑了几天真实开发流程。

前端侧两边几乎没有差别。Tauri 的 tauri dev 和 Electron 加 vite 插件的方案一样,前端改动走 Vite 的热更新,毫秒级;WebView2 里按 F12 就是完整的 Edge DevTools,和 Chrome 的 DevTools 体验一致,断点、网络面板、性能分析都在。这一层对团队其他前端是零迁移成本。

差别在跨语言调试的接缝上。Electron 里主进程是 Node,前端出身的人打 console.log 也好、挂 VS Code 的 Node 调试器也好,都是熟门熟路;Tauri 的 Rust 侧要靠 println! 或者上 LLDB,VS Code 里配 CodeLLDB 插件能打断点,但异步代码的调用栈读起来对新手不算友好。我的实际做法是给每个 command 的入口和出口都挂上 tracing 的日志宏,大部分问题靠日志就能圈出来,真上调试器的场景一周也就一两次。

另一个日常成本是类型的两份维护。invoke 的参数和返回值,Rust 侧一份 struct、TypeScript 侧一份 interface,纯手工保持同步。demo 阶段五六个命令还能靠自觉,命令多了肯定要出事。社区有从 Rust 类型生成 TS 声明的工具(ts-rs 这类),evaluate 阶段先记下,正式开发时引入——跨语言边界上凡是靠人肉同步的东西,迟早会在某次改动里裂开。

构建、签名与分发:CI 上的账

打包链路也各跑了一遍,差异比预想的大。

构建时长:Electron 打一个 Windows 安装包,在 CI 上大约 3 分钟,大头是下载和拷贝预编译的 Electron 二进制;Tauri 首次构建要完整编译 Rust 依赖树,CI 冷缓存下走了 11 分钟,配好 cargo 的缓存之后增量构建降到 4 分钟左右。Tauri 的 CI 一定要把 ~/.cargotarget 目录缓存起来,否则每次发版的等待时间不太好向围观的同事解释。

跨平台打包:Electron 可以在一台 Linux CI 机上打出三个平台的包(Windows 包借 wine),流水线简单;Tauri 因为要动本地的系统 WebView 依赖和链接器,基本要求"在什么平台打什么包",Windows 包得有 Windows runner。我们公司的 CI 有现成的 Windows 节点,这条没成为阻碍,但流水线配置确实多花了半天。

签名和分发:内网分发对签名要求不高,但 Tauri 的 updater 强制要求更新包带签名——tauri signer generate 生成一对密钥,公钥写进配置,CI 里用私钥给包签名,客户端校验通过才应用更新:

1{
2  "tauri": {
3    "updater": {
4      "active": true,
5      "endpoints": ["https://tools.internal.example.com/scan-client/latest.json"],
6      "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6..."
7    }
8  }
9}

latest.json 里是版本号、签名和包地址,客户端启动时比对。这套强制签名的设计初看麻烦,细想是对的:更新通道是桌面应用最大的攻击面,内网也不该裸奔。Electron 的 electron-updater 也支持签名校验,但默认不强制,又是一个"你要自己做对"和"默认就是对的"的差别。

生态成熟度:自动更新、多窗口、托盘

桌面端三个刚需逐项过一遍。

自动更新。Electron 有 electron-updater,配合内网一个静态文件服务器就能跑差量更新,这套我两年前搭过,非常稳。Tauri 1.5 内置 updater,更新包的签名校验是强制的——不签名连包都打不出来,这点比 Electron 的默认配置安全;但它只支持全量包更新,没有差量。我们内网分发、包也不大,全量可以接受;如果是公网分发一百多兆的捆绑包,这条要重新掂量。

多窗口。两边都支持,模式不同:Electron 的多窗口是多个渲染进程,窗口间通信走主进程中转,模式成熟、案例遍地;Tauri 的多窗口共享一个 Rust 进程,窗口既可以在配置里静态声明,也可以在 Rust 或前端动态创建:

1import { WebviewWindow } from '@tauri-apps/api/window';
2
3const floatWin = new WebviewWindow('scan-float', {
4  url: '/float',
5  width: 320,
6  height: 180,
7  decorations: false,   // 无边框
8  alwaysOnTop: true,
9  skipTaskbar: true,
10});
11
12await floatWin.once('tauri://created', () => {
13  floatWin.emit('init', { orderId: currentOrder.id });
14});

窗口管理 API 在 1.x 里够用但偏基础。我在做扫码悬浮窗——无边框、置顶、自定义拖拽区域——的时候,文档没覆盖到的细节靠翻 GitHub issue 解决了两个:一个是 data-tauri-drag-region 在嵌套元素上的事件穿透,一个是置顶窗口失焦后的层级问题。能解决,但检索成本明显高于 Electron,生态成熟度的差距不体现在功能清单上,体现在踩到坑之后多久能找到答案。

托盘。都有原生支持,Tauri 1.5 的托盘菜单 API 是够用的水平。倒是开机自启这种边角需求,Electron 一个 app.setLoginItemSettings 调用搞定,Tauri 1.x 得自己操作注册表或者拉社区插件。这类"边角能力的完整度"上,Electron 八年的积累是实打实的。

评估时还专门列了一栏"我们暂时不用、但业务可能长出来的能力",逐项查了两边的支持度:打印(仓库要打异常件标签,两边都能走 WebView 自带的打印,Tauri 侧要自己调系统对话框,能用)、串口(电子秤对接的远期需求,Rust 有 serialport 库,Node 有 serialport 包,打平)、离线数据库(本地暂存扫码记录,两边都能上 SQLite,Tauri 有官方插件)。这一栏没有决定性差异,但把它查完,选型报告才算闭环——选型要对着业务未来两三年可能长出的能力查,不能只对着眼前需求的最小集合。

进程模型与崩溃面:挂法不一样

还有一个维度在功能对比表上看不到,但值得写:两个框架的挂法不一样。

Electron 的进程模型继承自 Chromium:主进程挂了整个应用没了,渲染进程挂了窗口白屏但主进程还能兜底重建,render-process-gone 事件里可以做自动恢复。运营那个工具两年里遇到过两次渲染进程崩溃(都是处理超大图片时内存爆了),主进程捕获事件后重建窗口加弹提示,用户体感是"闪了一下",不是"应用没了"。

Tauri 这边,Rust 侧的代码只要不用 unsafe,内存安全由编译器背书,panic 也可以在框架层捕获,这一侧的稳定性预期比 Node 主进程只高不低。但 WebView2 进程挂掉的场景,Tauri 1.x 的恢复钩子比 Electron 原始——WebView 崩了窗口就冻在那里,应用层能做的处理有限。另外 WebView2 Runtime 是 Edge 团队随系统更新的,你的应用跑在一个你不控制版本的浏览器内核上,这既是省内存的来源,也是一类新的线上变量:用户机器上的 Runtime 悄悄升级后行为变了,这种问题 Electron 世界里不存在。仓库机器不联网,Runtime 版本实际被冻结了,这个风险对我们反而收敛——但公网分发的产品要把它记进风险清单。

前端资源的加载方式也顺带对比了一下。Electron 常见做法是 loadFile 直接加载本地文件或者起个内嵌服务;Tauri 用自定义协议 tauri:// 把打包进二进制的静态资源喂给 WebView,配置里能声明 CSP,框架会自动给内联脚本算 hash:

1{
2  "tauri": {
3    "security": {
4      "csp": "default-src 'self'; connect-src 'self' https://api.example.com"
5    }
6  }
7}

CSP 在桌面容器里不是摆设——WebView 里一旦被注入脚本,invoke 就是通往系统能力的桥,收紧 connect-srcscript-src 等于给这座桥设了闸。这部分 Electron 也能做,但同样是"要自己记得做"。

结论:按人和环境选,不按热度选

这次的最终决定:仓库扫码客户端用 Tauri 1.5。

理由回到开头三个问题。目标机器统一是 Windows,不用担三内核的兼容税,WebView2 离线捆绑的方案验证可行;4G 内存的硬约束下,Tauri 的运行时内存优势是决定性的;Rust 只有我一个人会,但这个工具体量小,Rust 侧命令预计不到十个,单点风险可控——而且我确实想借一个真实项目把 Rust 练起来,这点私心不必藏着,写进评估文档里了。

但如果把题目换成"重写运营那个图片处理工具",我的答案会是继续 Electron:它重度依赖 Node 生态的图像处理库,迁到 Rust 侧等于把生态重新踩一遍;运营的机器不缺那点内存;而且那个工具未来大概率交给团队里其他前端维护,不能留一个只有我能动的 Rust 层。

所以这篇没有"Tauri 赢了"或者"Electron 老了"的结论。同一个团队、同一个月份、两个工具、两个不同的答案——选型的输出如果不随目标环境和团队能力变化,那说明根本没在做选型,只是在追新

扫码客户端计划三月上线。Tauri 在生产环境里的真实表现——崩溃率、WebView2 在那批杂牌机器上的脾气、更新链路顺不顺——攒够了再写一篇。