有人第一次向我描述 Tauri 时,他说你用 HTML、CSS 和 JavaScript 构建桌面应用。我的反应是怀疑。那听起来像是窗口里的网站,不是真正的应用。然后他指出 VS Code、Slack 和 Discord 都是用同样的方式构建的,用的是 Electron,这个方法更早也更流行。那些是真正的应用,每天有几百万人用。这种架构有个名字:混合桌面应用。它不是 hack,也不是新东西。
它的工作方式是这样的:
UI 层(Web 技术)
↓
原生桥接
↓
OS 能力你的界面用 Web 平台构建。一个桥接把它连到操作系统。桥接是把网页变成桌面应用的东西。
Electron 证明了这可行
你可能用过 Electron 应用而自己不知道。VS Code、Slack、Discord 以及一长串其他桌面工具都是用它构建的。Electron 的立场是:在每个应用里打包一个完整的浏览器引擎,是让 Web 开发者不必学新栈就能交付桌面软件的合理代价。
HTML / CSS / JS
↓
Chromium + Node 桥接
↓
OS代价是体积。每个 Electron 应用都带上自己的一份 Chromium 和 Node。一个小的 Electron 应用在磁盘上可能有 150 兆,因为它包含一整个浏览器。内存使用遵循同样的模式:每个 Electron 窗口带来自己的 Chromium 进程。
Tauri 持同样的理念,但做了不同的权衡:
HTML / CSS / JS
↓
Tauri IPC
↓
Rust
↓
OSTauri 不打包浏览器,而是用用户系统上已经装好的 WebView。在 Windows 上是 WebView2,在 macOS 上是 WKWebView,在 Linux 上是 WebKitGTK。后端不用 Node,而是 Rust。
| Electron | Tauri | |
|---|---|---|
| 浏览器引擎 | 内置 Chromium | 系统 WebView |
| 后端 | Node.js | Rust |
| 应用体积 | 大(100+ MB) | 小(典型 2-10 MB) |
| 安全模型 | Node 有完整系统访问 | 前端默认不受信任 |
| 内存 | 较高(每个应用一个完整 Chromium) | 较低(共享系统 WebView) |
体积差异是大多数人最先注意到的。安全模型是更有意思的区别,我后面会讲到。
一个进程,不是两个
当我开始读 Tauri 的资料时,我一直在找前端连到后端的那部分。我脑子里带着 Web 模型,浏览器通过 HTTP 跟服务器对话,我假设 Tauri 也有类似的东西。也许是一个 localhost API,或者某种嵌在 WebView 里的服务器。
实际不是那样。
当你运行:
pnpm tauri dev一个 Rust 可执行文件启动。这个可执行文件做四件事:
- 创建一个原生 OS 窗口
- 在这个窗口里创建一个 WebView
- 把你的前端加载进 WebView
- 在 WebView 和 Rust 代码之间创建一个 IPC 桥接
Rust 可执行文件
├── 原生窗口
├── WebView(你的 React/Vue/Svelte 应用)
└── IPC 桥接没有独立的前端服务器。没有 localhost API。没有独立的后端进程。Rust 进程是协调者。它创建窗口,把 Web UI 加载进去,然后待命处理来自它的消息。
这是需要我坐下来想一会的部分。在 Web 应用里,浏览器和后端是两个独立的程序,跑在不同的机器上。浏览器通过 URL 找到后端。在 Tauri 里它们是一个程序。"后端"只是跑在同一个进程里的 Rust 代码,这个进程本身创建了 WebView。如果你用过 Next.js,这更接近那个。你的前端和 API 路由是一个项目、一个进程,由框架处理路由。你不会部署一个独立的前端和后端。Tauri 的形状是一样的。
JavaScript 如何跟 Rust 对话
从 Web 开发的背景来,你会期望直接 import 一个东西然后调用它。JavaScript 里一切都是这样工作的。你装一个包,import 它,用它。
Tauri 给你一个看起来一模一样的辅助函数:
import { invoke } from "@tauri-apps/api/core";
const result = await invoke("greet", { name: "John" });表面上看,就是这样。你调用一个东西,拿到一个结果。但底下,这一切都不是你在 Web 上能认出的方式。没有共享模块。没有 import 的函数。你在向另一个进程发消息,然后等它回应。
JavaScript
|
| 消息:{ command: "greet", args: { name: "John" } }
|
IPC 桥接
|
Rust 命令分发器
|
fn greet(name: String) -> String
|
| 返回:"Hello, John"
|
JavaScript Promise resolve在 Rust 一侧,接收这个的函数长这样:
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}", name)
}#[tauri::command] 属性把函数注册到 IPC 分发器上。当 JavaScript 调用 invoke("greet", ...) 时,分发器找到这个函数,传递参数,返回结果。JavaScript 一侧拿到一个 Promise。Rust 一侧跑一个真正的编译函数。
这个叫 IPC,进程间通信。它存在的原因是两个进程不能直接访问对方的内存。它们必须通过消息来通信。上面你看到的是一种结构化的方式,让两个进程在不共享内存的情况下交换数据。
这里有一个部分让我绊了一下。在 Web 应用里,浏览器通过 HTTP 跟后端对话,这意味着它需要一个端口,比如 localhost:3000。在 Tauri 里,没有端口。我一直在找连接是在哪里建立的,找不到,因为根本没有连接。IPC 桥接是 Tauri 运行时内部创建的。就像同一栋楼里的两个房间。隔壁房间里的人你不会打电话,你用内部传信管。
桥接已经在那儿了
一旦我理解了没有端口,下一个问题就很明显:如果没有服务器,JavaScript 怎么找到 Rust?
答案是它不找。它不需要找。
当 Rust 进程创建 WebView 时,它把通信桥接注入进去。概念上:
Rust 运行时
|
| 注入
|
window.__TAURI__当你的 JavaScript 调用 invoke() 时,它用那个桥接。前端不去发现 Rust。没有可以指向的 URL,没有可以连接的端口。Rust 构建了前端运行的环境,通信通道是那个环境的一部分,在第一行 JavaScript 加载之前就已经在了。
这跟 Web 模型是一个干净的区别。在 Web 上,浏览器通过 URL 发现后端。在 Tauri 里,后端创建了前端运行的环境,通信通道从一开始就是那个环境的一部分。
信任边界在哪里
前端不受信任。每个前端都是这样,这不是有意思的部分。有意思的是每个框架把信任边界放在哪里,以及它让你能 scope 什么。
在 Electron 里,Node 后端默认有完整的系统访问。前端能不能拿到这个访问是一个配置标志,nodeIntegration。设成 true,你的渲染器就能调用 child_process、fs,Node 能做的一切。设成 false,渲染器就被沙盒隔离,但主进程仍然有完整访问,你通常通过 ipcMain.handle 暴露你需要的东西。边界是"前端有没有 Node API"。这是一个二元的门。没有能力系统能说前端可以调用 exportReport 但不能调用 deleteFile。这个逻辑你在每个 IPC handler 里自己写。
在 Tauri 里,前端默认零访问。前端能调用的每个命令都必须显式注册为 #[tauri::command],能力系统控制前端被允许调用哪些命令。只有 Rust 一侧有 OS 访问。能力系统 scope 前端能请求什么。你不在每个 handler 里写 guard。框架强制执行。
| Electron | Tauri | |
|---|---|---|
| 后端 OS 访问 | 默认完整 | 完整,但只有 Rust 有 |
| 前端对 OS 的访问 | 配置标志(nodeIntegration) | 默认没有 |
| 前端能调用什么 | 你通过 ipcMain.handle 暴露的任何东西 | 只有注册了 #[tauri::command] 的命令 |
| 按命令 scope | 你自己写 guard 逻辑 | 能力系统 scope 每个命令 |
餐厅的类比仍然成立。餐厅服务员把订单放在传票轨上。厨房决定做什么、怎么做。但在 Electron 里,厨房门有一把锁,你必须记得锁上,还要决定谁拿钥匙。在 Tauri 里,厨房门一开始就是锁上的,餐厅能点的每一道具体菜品都必须显式加进菜单。默认是"菜单上什么都没有"。你一次加一项,框架强制执行。
构建
开发期间,两个东西同时跑:
前端:Vite dev 服务器(热重载,快速刷新)
后端:Cargo(Rust 编译器,监听变化)你编辑 React 组件,Vite 热重载它们。你编辑 Rust 代码,Cargo 重新编译。原生窗口在两边更新时保持打开。
生产环境里,前端被构建,Rust 被编译成 release 二进制。两者打包在一起:
前端构建
+
Rust release 构建
|
v
原生应用输出取决于目标平台:
| 平台 | 输出 |
|---|---|
| Windows | .exe / .msi |
| macOS | .app / .dmg |
| Linux | AppImage / .deb |
| Android | .apk / .aab |
| iOS | .ipa |
Tauri 移动版值得一提。在移动端,架构是一样的:你的 Web UI 跑在平台的 WebView 里,Android WebView 或 iOS WKWebView,Rust 为移动平台原生编译。它不是 React Native。没有 JavaScript 到原生 UI 的桥接。UI 是一个 WebView,Rust 处理原生那侧。
浏览器 vs Tauri
你的 React 应用可以用 pnpm dev 在普通浏览器里跑。但 invoke() 不会工作,因为没有 Rust 运行时,也没有 IPC 桥接。函数存在于 JavaScript 包里,但当它尝试通过桥接发消息时,另一端什么都没有。
在 Tauri 里,Rust 运行时在跑,桥接存在,invoke() 把消息传到真正的 Rust 函数。这就是把你的应用在 Chrome 里打开和通过 Tauri 运行的区别。代码一样。运行时不同。
什么时候适用
Tauri 适合:
- 桌面生产力应用
- 开发者工具
- 商业软件
- 笔记应用
- 文件工具
- 公司内部工具
- 需要在 Windows、macOS 和 Linux 上运行的跨平台工具
不太适合:
- 高性能 3D 游戏
- 重图形的应用,你需要每个原生 UI 细节
- 需要与 OS 级 UI 框架深度平台特定集成的应用
适合的场景都有一个共同点:界面是工作本身,操作系统提供文件访问、通知、窗口管理这类支持能力。不适合的场景相反:原生平台的图形或 UI 系统是工作本身,Web UI 碍事。
心智转变
我一开始的问题是,我们到底是在设计界面,还是在设计桌面应用。答案是是的,但框架重要。
你不是把一个网站放进桌面窗口。你在构建一个原生应用,只是它的 UI 技术恰好是 Web 平台。这个区分是架构能成立的关键。Rust 一侧是真正的原生后端,有真正的 OS 访问。Web 一侧是展示层。两者之间的桥接是信任边界所在。
窗口里的网站会是一个指向某个 URL 的浏览器,没有原生能力。Tauri 给你的是一个编译过的原生应用,只是恰好用 Web 技术渲染界面。区别是 Rust 后端、IPC 桥接,以及围绕它的安全模型。
