设计取舍
主线位置:三条主线的汇总。启动主线上的取舍是「系统 webview」和「页面走自定义 scheme」;调用主线上的是「IPC 用 fetch、postMessage 作退路」「JSON 是默认编码」「ACL 在编译期解析」;事件主线上的是「事件是一段 eval」;「
R: Runtime泛型」和「窗口与 webview 拆开」横跨三条线。每一条只说收益和代价,机制在链接指向的页面里。
flowchart TB launch[启动主线] --> t1["系统 webview"] launch --> t2["自定义 scheme 不开端口"] callm[调用主线] --> t3["fetch 为主 postMessage 兜底"] callm --> t4["JSON 默认编码"] callm --> t6["ACL 编译期解析"] evt[事件主线] --> t5["事件是一段 eval"] t4 --> t5 all[横跨三条] --> t7["R Runtime 泛型"] all --> t8["窗口与 webview 拆开"] t7 --> v3["3.x 动态运行时"]
每一节回答三个问题:tauri 选了什么,换来了什么,付出了什么。和别的方案对比只用三种依据:本页和本卷跑出来的数,tauri / dioxus 仓库里能读到的源码和 CHANGELOG,以及注明了的通用常识。Electron 没有跑,表里不比它的数。实验平台 M2 Max、macOS 27、rustc 1.99.0;Windows(WebView2)和 Linux(WebKitGTK)只读了源码。
| 取舍 | 换来的 | 付出的 | 证据 |
|---|---|---|---|
| 1. 系统 webview | 不带浏览器引擎:裸二进制 3–10 MiB,渲染进程由系统提供 | 各平台引擎不同;源、子 frame、请求体在三个平台上各有差异 | 本页实验、tauri 协议与 CSP、初始化脚本 |
| 2. 页面和 IPC 走自定义 scheme | 不监听任何端口,没有「本机别的进程连进来」这个面 | 所有请求在主线程进来;响应要自己补 CORS 头;一个永不应答的请求就是一个永不落定的 Promise | tauri 解决什么问题、本页实验、ipc 协议处理 |
| 3. IPC 用 fetch,postMessage 作退路 | 原始字节不经 JSON;5 MiB 字节比 1.x 的路快几十倍 | 两条路语义不同;退路是永久的、会重发,命令可能跑两次 | JS 侧:invoke 与回调、ipc 协议处理 |
| 4. JSON 是默认编码 | 参数、返回值、错误都只要 serde | 顶层数组被发成文本;只有顶层键改 camelCase;大的结构化数据要解析 | 参数、返回值与 InvokeResolver、Channel |
| 5. 事件是一段 eval | 不用请求就能推到每个 webview | payload 当 JS 源码解析,10 MiB 结构化数据 2.4 秒;和 IPC 应答之间不排序 | Channel、AppManager 与 Manager trait |
| 6. ACL 在编译期解析 | 能力文件写错在构建时报;运行时只查表;能按窗口、来源、作用域细分 | 没有应用清单时应用命令不查;拼错命令名报 not allowed;完整权限表编进二进制 | 07 安全、on_message 与分发 |
7. R: Runtime 泛型 | MockRuntime 不开窗口测 IPC 和 ACL;3.x 能接第二个真实运行时 | 公开类型全带类型参数;MockRuntime 上的行为和真实的不同 | tauri-runtime 的抽象、#[command] 与 generate_handler! |
| 8. 窗口和 webview 拆开 | 一个窗口能放多个 webview | 两张表、两套标签;多 webview 的窗口拿不到 WebviewWindow | Window、Webview、WebviewWindow |
1. 系统 webview,不带浏览器引擎
选择。 tauri 不打包渲染引擎:macOS 上是 WKWebView,Windows 上是 WebView2,Linux 上是 WebKitGTK,都经 wry 拿到(wry 在 macOS 上)。应用二进制里只有 Rust 代码和压缩过的前端资源(generate_context!)。
换来。 体积小,而且大部分内存不算在应用头上。下面这组实验把同一件事做三遍:一个隐藏窗口加载一个页面,页面向 Rust 要一个 greet 结果,再把结果送回来,Rust 收到后量一次时间和内存、退出。
design-size:tauri 2.12.5,base模板去掉testfeature,用默认 feature 和--features tauri/custom-protocol;时间和内存在report命令里量,此时页面已经做完两次 invoke。design-wry:wry 0.57.1 + tao 0.37.2,一个自定义协议同时提供页面和greet,页面用window.ipc.postMessage送回结果;在事件循环收到这条消息时量。design-dx:dioxus 0.7.10 的 desktop(解析到 wry 0.53.5、tao 0.34.8),rsx!渲染一行文字,第一次渲染后的 effect 里document::eval一次往返,然后量。
三个程序共用同一段测量代码:从 main 开头起算的毫秒数,加上本进程和它之后启动的 WebKit 辅助进程的 RSS 与 footprint(macOS 自带的工具,统计进程独占的脏内存,不含共享的系统库页面):
pub fn probe(label: &str, start: Instant) {
let elapsed = start.elapsed().as_millis();
let me = std::process::id();
let ps = Command::new("ps").args(["-axo", "pid=,rss=,etime=,comm="]).output().unwrap();
let mut rows = vec![];
for line in String::from_utf8_lossy(&ps.stdout).lines() {
let f: Vec<&str> = line.split_whitespace().collect();
if f.len() < 4 {
continue;
}
let pid: u32 = f[0].parse().unwrap();
let name = f[3].rsplit('/').next().unwrap().to_string();
let ours = pid == me || (pid > me && pid < me + 50 && name.starts_with("com.apple.WebKit") && f[2].len() == 5 && f[2] < "00:10");
if ours {
rows.push((pid, f[1].parse::<u64>().unwrap(), name));
}
}
// ... 逐行打印 pid、rss_kb、footprint,最后打印合计
}
每个二进制编两种:cargo 默认的 release,以及 small,后者照抄 tauri 仓库自己根目录 Cargo.toml 里的 [profile.release](注释是「default to small, optimized workspace release binaries」:panic = "abort"、codegen-units = 1、lto = true、opt-level = "s"、strip = true)。二进制大小(字节):
9823904 release/design-size
1672064 release/design-wry
6397488 release/design-dx
3068976 small/design-size
859552 small/design-wry
2999008 small/design-dx
每个二进制连跑 5 次,每次间隔 1 秒。每种各摘第 2 次的完整输出(wall_ms 是外面的 Python 脚本量的「启动到进程退出」):
== release/design-size run 2 exit=0 wall_ms=665
PROBE tauri elapsed_ms=433
PROBE pid=69368 rss_kb=141632 footprint=27 MB design-size
PROBE pid=69369 rss_kb=13552 footprint=16 MB com.apple.WebKit.GPU
PROBE pid=69370 rss_kb=27568 footprint=14 MB com.apple.WebKit.WebContent
PROBE pid=69371 rss_kb=11472 footprint=7056 KB com.apple.WebKit.Networking
PROBE total_rss_kb=194224 processes=4
== release/design-wry run 2 exit=0 wall_ms=669
PROBE wry elapsed_ms=427
PROBE pid=58188 rss_kb=104032 footprint=25 MB design-wry
PROBE pid=58189 rss_kb=17120 footprint=15 MB com.apple.WebKit.GPU
PROBE pid=58190 rss_kb=30832 footprint=13 MB com.apple.WebKit.WebContent
PROBE pid=58191 rss_kb=15568 footprint=7776 KB com.apple.WebKit.Networking
PROBE total_rss_kb=167552 processes=4
== release/design-dx run 2 exit=0 wall_ms=674
PROBE dioxus elapsed_ms=448
PROBE pid=64170 rss_kb=142624 footprint=27 MB design-dx
PROBE pid=64171 rss_kb=15136 footprint=17 MB com.apple.WebKit.GPU
PROBE pid=64172 rss_kb=28256 footprint=20 MB com.apple.WebKit.WebContent
PROBE pid=64173 rss_kb=13584 footprint=8560 KB com.apple.WebKit.Networking
PROBE total_rss_kb=199600 processes=4
5 次的中位数(完整输出见实验目录 design-runs-tauri.txt、design-runs.txt、design-runs-dx.txt):
| 二进制 release / small | 到量的那一刻(elapsed_ms)release / small | 到进程退出(wall_ms)release / small | 应用进程 footprint | 三个 WebKit 进程 footprint 合计 | |
|---|---|---|---|---|---|
| tauri | 9.4 MiB / 2.9 MiB | 440 / 474 | 665 / 702 | 27 MB | 约 37 MB |
| 裸 wry + tao | 1.6 MiB / 0.82 MiB | 415 / 384 | 631 / 602 | 25–26 MB | 约 36 MB |
| dioxus-desktop | 6.1 MiB / 2.9 MiB | 446 / 453 | 669 / 687 | 27 MB | 约 45 MB |
- 体积:tauri 在裸 wry 之上多了约 8 MiB(release),按 tauri 自己的发布配置编是 2.9 MiB;页面资源只有一个 275 字节的
index.html,这些几乎全是代码。和 dioxus-desktop 在small下差不多大。 - 启动:三者从
main到第一次往返完成都在 0.4–0.5 秒,tauri 比裸 wry 慢几十毫秒,在 5 次的波动范围里。时间花在哪里,一次启动的逐步时间戳给出了答案:建窗口和 WKWebView 那一段(wry::webview::create)就占了约 150 毫秒(debug 构建),之后才轮到页面请求。框架本身的那部分在这个量级里不显眼。 - 内存:应用进程的独占内存三者都在 25–27 MB。RSS 一栏 tauri 和 dioxus 是 140 MB 左右、裸 wry 是 104 MB,差的主要是映射进来的共享页面,
footprint不算它们。另有三个 WebKit 进程(GPU、WebContent、Networking)合计 36–45 MB,它们是系统提供的,不在应用包里;页面的 JS 跑在 WebContent 里(tauri 解决什么问题的动脑筋)。 - 第一次运行稍慢(release 的 tauri 第 1 次是 527 毫秒)。按 pid 相邻挑 WebKit 进程的办法只是近似:最早一组
small的 tauri 测量(design-runs.txt)里有两次多算进了上一轮没退干净的 WebKit 进程(processes=6);表里 tauri 的数取自重编后的那一组(design-runs-tauri.txt),10 次都是processes=4。
greet,tauri 的 release 二进制比只用 wry + tao 写的大多少?启动到第一次往返完成,又慢多少?平台 M2 Max、macOS 27,tauri 2.12.5、wry 0.57.1、tao 0.37.2,cargo 默认的 release 配置。先猜两个数,再看文末。付出。 渲染引擎由系统决定,一份前端在三个引擎上未必一样。本卷读源码时碰到的差别已经不少:
- 页面的源:macOS 和 Linux 上是
tauri://localhost,Windows 和 Android 上是http(s)://tauri.localhost(tauri 协议与 CSP)。依赖location.origin的前端代码、CSP 和remote.urls都要按平台写。 - 初始化脚本:macOS 上 tauri 的脚本只进主 frame,wry 的文档说 Windows 上一律进子 frame(初始化脚本)。
- 请求体:Android 上 webview 读不到自定义协议请求的正文,
ipc-protocol.js在 L20 写死了const canUseCustomProtocol = osName !== 'android',那里只走postMessage(第 3 条)。 - 版本:WKWebView 跟着 macOS 走,WebView2 由系统或安装包提供,应用控制不了用户机器上是哪一版(通用常识)。
3.x 的 alpha 在这一点上给了另一个选项,见文末「3.x 的方向」。
2. 页面和 IPC 都走自定义 scheme,不开端口
选择。 页面从 tauri://localhost 取,IPC 发给 ipc://localhost/<命令>,两者都是注册在 webview 上的 URI scheme 处理函数,请求在应用进程内交给 Rust(一次启动、一次 invoke)。
换来。 应用不监听任何端口。在上面的 design-size 和 design-dx 里各加一段:收到最后一次调用时对自己跑 lsof -a -n -P -p <pid> -i(release 构建):
LSOF exit=Some(1) lines=0
REPORT greet=hello, rie origin=tauri://localhost
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
design-dx 68776 user 3u IPv4 0xe63bdb347cd123ec 0t0 TCP 127.0.0.1:61215 (LISTEN)
design-dx 68776 user 12u IPv4 0x80016189b921f39b 0t0 TCP 127.0.0.1:61215->127.0.0.1:61216 (ESTABLISHED)
REPORT greet=hello, rie origin=Ok(String("dioxus://index.html"))
tauri 一个套接字都没有(debug 构建的同一结论在 tauri 解决什么问题)。dioxus-desktop 在 127.0.0.1 的随机端口上监听,页面连上来收 DOM 指令(第 9 节)。端口意味着本机任何进程都能连,dioxus 为此自己加了一层随机密钥,edits.rs 开头的注释写得很直接:「Using websockets does mean we need to handle security and content security policies ourselves.」tauri 没有这个面:能发 ipc:// 请求的只有 webview 里的页面,于是检查集中在「这个页面是谁」上(invoke key、Origin、ACL)。
付出。
- 请求在主线程上进来。wry 的 scheme handler 回调在主线程(wry 在 macOS 上),同步命令就在这次回调里执行,执行期间别的 invoke、
tauri://资源、窗口事件都在排队(参数、返回值与 InvokeResolver)。 - 跨源要自己处理:页面是
tauri://localhost,请求发给ipc://localhost,是两个源。每个响应都要带Access-Control-Allow-Origin和Access-Control-Expose-Headers: Tauri-Response,JS 才读得到成败(ipc 协议处理);页面配了 CSP 而connect-src没放行ipc:,fetch被挡,悄悄退到第 3 条的退路(CSP、原型冻结与隔离模式)。 - 没有 HTTP 栈的超时。一个处理函数不应答,WebKit 就一直等:invoke key 不对的请求被丢弃后永远不应答,
fetch永远挂着(一次 invoke)。
3. IPC 用 fetch,postMessage 留作退路
选择。 invoke 先 fetch 一个 POST ipc://localhost/<命令>,正文是 JSON 或原始字节,应答是一个 HTTP 响应;fetch 链上任何一步失败,就永久改用 wry 的 window.ipc.postMessage,应答变成一段 eval(JS 侧:invoke 与回调)。
1.x 只有后面那条路。tauri 1.8.3 的初始化脚本定义 __TAURI_POST_MESSAGE__ 为 window.ipc.postMessage(JSON.stringify(message)),Rust 侧的应答在 window_invoke_responder 里:
pub fn window_invoke_responder<R: Runtime>(
window: Window<R>,
response: InvokeResponse,
success_callback: CallbackFn,
error_callback: CallbackFn,
) {
// ...
let callback_string =
match format_callback_result(response.into_result(), success_callback, error_callback) {
Ok(callback_string) => callback_string,
Err(e) => format_callback(error_callback, &e.to_string())
.expect("unable to serialize response string to json"),
};
let _ = window.eval(&callback_string);
}
去程是一个 JSON 字符串,回程是一段把结果当字面量拼进去的 JS。2.0 的 alpha 改成了自定义协议,原因和版本见一次 invoke的冷知识(#7170,CHANGELOG 的说法是「to enhance performance」)。
换来。 字节能原样过去、原样回来。ipc 协议处理在同一个应用里把两条路都跑了一遍(release):5 MiB 字节 Rust → JS,fetch 2–6 毫秒、拿到 ArrayBuffer,postMessage 约 113 毫秒、拿到 524 万个元素的 Array;JS → Rust 约 16 毫秒对约 340 毫秒。差距全在字节上,字符串两条路都是 5 毫秒左右。
付出。 两条路并存,而且语义不同:
postMessage路上 Rust 收到的正文永远是Json,原始字节变成数字数组,Float32Array变成以下标为键的对象;按InvokeBody::Raw写的命令换了路就拿不到字节(JS 侧:invoke 与回调)。- 退路是永久的,而且会把失败的那条消息重发一遍。触发条件比「CSP 拦截」宽:响应声明是 JSON 却解析失败、页面刷新取消了请求,都会触发,于是命令可能执行两次(同上)。
- 只有
fetch路有 HTTP 的形状(方法、头、状态码),所以 invoke key 和回调 id 放在头里;postMessage路把它们放进 JSON,两条路在ipc/protocol.rs里各有一个入口拼出同一个InvokeRequest(ipc 协议处理)。
问: 既然 fetch 快那么多,为什么不干脆删掉 postMessage?
答: 有两种情况离不开它。一是 Android:webview 拦截请求时读不到正文,canUseCustomProtocol 在那里恒为假,所有调用都走 postMessage(只有取 Channel 数据的那个命令例外:它的参数是 null,数据 id 放在 Tauri-Channel-Id 头里,用不着正文)。二是桌面上 fetch 被挡住的时候,最常见的是页面自己的 CSP 没放行 ipc:。删掉退路,这些应用的 invoke 会直接失败;留着它,代价是上面那几条语义差异,以及失败只体现为一条控制台警告。
4. JSON 是默认编码
选择。 参数和返回值默认走 serde_json:JS 侧 JSON.stringify 整个参数对象,Rust 侧 CommandItem 按键取出一个 Value 交给参数类型的 Deserialize;返回值和错误只要 Serialize。原始字节是另一条显式的路:JS 传 ArrayBuffer / 类型化数组作为整个参数,Rust 用 ipc::Request 取 InvokeBody::Raw,返回 ipc::Response 得到 InvokeResponseBody::Raw(参数、返回值与 InvokeResolver)。
换来。 命令就是普通的 Rust 函数,类型怎么反序列化完全交给 serde(规则见 serde 卷的 Deserializer 与 Visitor);任何 Serialize 的类型都能当错误,JS 拿到的是它的 JSON。两条传输路都只需要搬字符串,第 3 条里 postMessage 也能工作,靠的就是这一点。
付出。
- 「是不是 JSON」只看顶层:顶层的普通数组会被当成字节路,在
fetch上发出去的是"1,2,3"这样的文本;嵌套在对象里的才走 JSON(JS 侧:invoke 与回调)。 - 改名也只在顶层:参数名按 camelCase 找,嵌套结构体的字段按它自己的 serde 属性,于是 JS 传
pageSize时内层报missing field page_size(参数、返回值与 InvokeResolver)。 - 结构化数据要编码、解析两次。Channel 的实验里 10 MiB 的 140598 条记录,命令返回约 53 毫秒;同样大小的原始字节用
ipc::Response返回只要 3 毫秒。
5. 事件是每个 webview 一段 eval
选择。 emit 先对注册了这个事件 JS 监听器的每个 webview 生成一段脚本(emit_js_script),把序列化好的 payload 原样拼进源码 eval 进去,再在调用方线程上同步调 Rust 监听器(AppManager 与 Manager trait)。
换来。 Rust 不需要等页面来请求就能推送,一次 emit 覆盖所有有监听器的 webview;没有监听器的 webview 根本不收到脚本。
付出。
- payload 是 JS 源码。10 MiB 的结构化记录塞进一个事件,WebKit 要把它当对象字面量解析:2401 毫秒,同样的数据走命令返回或 Channel 是 50 毫秒出头;全是
a的大字符串测不出这个差别(Channel)。要传大数据用Channel,它小消息 eval、大消息 fetch。 eval和 IPC 应答是两条互不排序的路:先emit再让命令返回,页面不一定先看到事件(tauri-runtime-wry 与事件循环)。- 页面没加载、监听器没注册时发的事件没有地方去:
setup里emit的事件到不了页面(Builder、App、AppHandle)。
6. ACL 在编译期解析
选择。 插件和应用的权限文件、capabilities/ 里的能力文件,由 tauri-build 收集和校验(tauri-build),generate_context! 用 Resolved::resolve 展开成「命令 → 允许它的窗口、webview、来源和作用域」的表编进二进制(编译期解析);运行时 Webview::on_message 调 resolve_access 查表(运行时检查)。
1.x 没有这张表。那时的「allowlist」是 Cargo feature:tauri 1.8.3 的 Cargo.toml 有 128 个 feature,大部分是 fs-read-file、dialog-open 这样一个内置 API 一个;没开的 API 由 module_command_handler 换成一个只返回「API not in the allowlist」的桩。它管的只是 tauri 内置的那些 API:1.x 的 on_message 对本地页面调用用户命令和插件命令不做任何检查,远程页面只有一份按域名和窗口列出的 dangerousRemoteDomainIpcAccess 配置。2.12.5 的 tauri 只剩 52 个 feature,权限从 feature 挪进了数据文件。旧名字留在了一个地方:feature 和配置不一致时 tauri-build 的报错文本还是「does not match the allowlist defined under tauri.conf.json」,校验用的结构体也还叫 AllowlistedDependency。
提交 3c2f79f6f「Tauri ACL/Allowlist v2 Implementation and Plugin System Refactor (#8428)」在 2024 年 1 月合入,第一个包含它的 tag 是预发布版 tauri-v2.0.0-beta.0,正式版 2.0.0。它的 changefile 是另一个 PR(#8661)补的,tauri 的 CHANGELOG 在 2.0.0-beta.0 下只记了一句「Implement access control list for IPC usage.」第二天合入的还有 #8667「feat(cli): migrate allowlist to new ACL」。之前,权限是编译开关,粒度是「这个 API 编不编进来」;之后,权限是数据,粒度到「哪个窗口、哪个来源、带什么作用域」,代价是多了 tauri-build 里的收集、generate_context! 里的解析和运行时的查表这三段代码。
换来。
- 能力文件写错(引用了不存在的权限)在构建时就报(tauri-build);运行时不解析文件,只查一张已经展开好的表。
- 粒度细:按窗口和 webview 标签、按本地或
remote.urls来源、带作用域;命令通过CommandScope<T>/GlobalScope<T>拿到作用域,自己决定怎么用(权限、能力、作用域)。 - 改权限不用改 feature、不用重编 tauri 本身;
dynamic-acl(默认开)下还能在运行时add_capability。
付出。
- 默认对应用命令不设防:没有应用权限清单时,本地页面能调
generate_handler!里的每一个应用命令,能力文件管不到它们;加上第一个应用权限文件,所有应用命令立刻都要授权。远程来源在 2.11.1 之前也享受这份豁免(运行时检查)。 - 查表在找命令之前:需要检查时,拼错的命令名得到的是「not allowed」而不是「not found」;debug 和 release 的拒绝文本不同(on_message 与分发)。
- 解析阶段不裁决冲突,运行时的拒绝只看来源、不看窗口:写在一个窗口能力里的 deny 对所有窗口生效(运行时检查)。
- 运行时追加走的是同一个解析函数,权限名写错时
add_capability直接 panic(编译期解析)。 - 表本身要占二进制:默认 feature 下内置插件的整张权限清单都编进去(generate_context!)。在
small/design-size上跑strings,一个没有任何能力文件的应用里照样能看到core:window:default、allow-set-title、deny-set-title这些标识符连成的长串。3.x 已经在改这一点(文末)。
7. R: Runtime 泛型
选择。 tauri 本体不直接依赖 wry:App、AppHandle、Window、Webview 和大多数公开类型都带一个 R: Runtime 参数,实现在 tauri-runtime-wry 里,Builder::default() 选 Wry(tauri-runtime 的抽象)。在 crates/tauri/src 里数一下,R: Runtime 出现了 355 次,分布在 47 个文件里。为了让用户少写一个类型参数,AppHandle 这些类型上挂着 default_runtime 属性宏,开着 wry feature 时把最后一个泛型参数的默认值设成 Wry。
换来。
- 第二个实现
MockRuntime:不开窗口、不起事件循环,cargo test就能跑完整的 IPC 分发和 ACL 判断。本卷 06、07 两部分的大部分实验都跑在它上面。 - 运行时可以换。2.x 里只有 wry 这一个真实实现;3.x 的 alpha 加了第二个(文末)。
付出。
- 类型参数到处都是。用户代码里写
AppHandle时实际是AppHandle<Wry>,在MockRuntime上编译命令就会出现类型不匹配,报错还绕到了Deserialize上:「the trait boundAppHandle: CommandArg<'_, R>is not satisfied」,提示「the traitserde_core::de::Deserialize<'_>is not implemented forAppHandle」(#[command] 与 generate_handler!)。要让命令同时能在两个运行时上编译,就得自己写<R: Runtime>。 - 第二个实现不等于第二个平台。
MockRuntime没有事件循环,run之前所有消息在调用线程当场执行,同一段代码在它上面得到空标题和 0×0 的尺寸(tauri-runtime 的抽象);线程、窗口、死锁这一类问题测不出来。 - 抽象本身带来的接口约束:
Runtime不要求Send,跨线程操作一律经派发器往返主线程,getter 返回Result并阻塞等待,于是主线程等别的线程、那个线程又调 getter 就是死锁(tauri-runtime-wry 与事件循环)。这一条严格说来自 macOS 的主线程规则,trait 只是把它写进了签名。
8. 窗口和 webview 拆开
选择。 2.x 把「窗口」和「webview」拆成两个对象、两张按标签索引的表;最常见的一窗一 webview 由 WebviewWindow 组合出来,unstable feature 下 Window::add_child 能往一个窗口里加多个 webview(Window、Webview、WebviewWindow)。
Window 改名成了 WebviewWindow#8280(「refactor(core): add support to multiple webviews on a Tauri window」,首次发布于预发布版 tauri-v2.0.0-beta.0)做的拆分。之前 1.x 只有一个 Window,窗口和它的 webview 是一回事,on_message、eval、on_page_load 都挂在它上面。tauri 的 CHANGELOG 在 2.0.0-beta.0 下记了几条:「Add multiwebview support behind the unstable feature flag.」「Renamed Window to WebviewWindow, WindowBuilder to WebviewWindowBuilder, Manager::windows to Manager::webview_windows and Manager::get_window to Manager::get_webview_window.」「The invoke_system, on_page_load hooks now gives you a Webview argument instead of a Window.」于是 2.x 里 Window 这个名字指的是新的、不带 webview 的窗口,而 1.x 的代码里写 Window 的地方大多要改成 WebviewWindow。
换来。 一个窗口能放多个 webview(比如侧栏和主内容分属两个源、两套能力);IPC、ACL 和事件都按 webview 走,能力里可以分别写 windows 和 webviews(权限、能力、作用域)。
付出。
- 两张表、两套标签,只在各自的表里唯一:已有 webview
left时还能建窗口left,但这对窗口和 webview 互不相认;get_webview_window靠「窗口里所有 webview 都和窗口同名」来认,多 webview 的窗口拿不到WebviewWindow(Window、Webview、WebviewWindow)。 - 多 webview 在 2.12.5 里还在
unstable后面,子 webview 的位置和大小固定,开了auto_resize才跟随窗口;Webview::close立即删表、不发事件(同上)。 - 关窗流程跨两个对象:
CloseRequested在窗口上,页面监听了tauri://close-requested时 Rust 替它拦下,再由 JS 调destroy();能力里缺core:window:allow-destroy就关不掉(同上)。
9. 和 dioxus-desktop 对照
dioxus-desktop 和 tauri 底下是同一套 wry 和 tao,用法却几乎相反。tauri 解决什么问题讲了分界在「UI 写在哪边」;这里按本页的几条取舍逐项对照 dioxus 0.7.10 的源码。
| tauri 2.12.5 | dioxus-desktop 0.7.10 | |
|---|---|---|
| UI 在哪 | 前端 JS,Rust 只提供命令 | Rust 里的 VirtualDom,webview 只是渲染器 |
| 页面从哪来 | tauri://localhost,内嵌资源 | dioxus://index.html/,desktop_handler 生成首页 |
| Rust → 页面 | 调用的应答(HTTP 响应)、事件和小 Channel 消息(eval) | DOM 指令经 127.0.0.1 随机端口上的 WebSocket 推送(start_server),二进制编码 |
| 页面 → Rust | 异步的 fetch,Promise 落定 | DOM 事件用同步 XHR POST 到 dioxus:// 的 __events 路径;其余消息走 window.ipc.postMessage(with_ipc_handler) |
| 谁能调 Rust | invoke key + ACL | 没有命令,也就没有权限的概念;WebSocket 靠随机密钥认证 |
| 端口 | 0(本页第 2 节) | 1 个监听 + 1 个已连接(本页第 2 节) |
| 体积、启动、内存 | 本页第 1 节 | 本页第 1 节,和 tauri 在同一量级 |
最能说明「用法相反」的是事件那一行。dioxus 的事件处理要在 JS 的事件回调返回之前知道要不要 preventDefault,所以页面这边用的是同步请求,handleVirtualdomEventSync:
function handleVirtualdomEventSync(
endpoint: string,
contents: string
): EventSyncResult {
// Handle the event on the virtualdom and then process whatever its output was
const xhr = new XMLHttpRequest();
// Serialize the event and send it to the custom protocol in the Rust side of things
xhr.open("POST", endpoint, false);
xhr.setRequestHeader("Content-Type", "application/json");
// ...
xhr.setRequestHeader("dioxus-data", contents_base64);
xhr.send();
// Deserialize the response, and then prevent the default/capture the event if the virtualdom wants to
return JSON.parse(xhr.responseText);
}
open 的第三个参数 false 就是同步:JS 线程停在 send() 上,直到 Rust 处理完事件、算出要不要阻止默认行为。这和 dioxus 卷里 web 渲染器的规则是同一个约束:事件委托讲过 prevent_default 只认第一个 .await 之前。tauri 不需要这个:UI 在 JS 里,preventDefault 由前端自己当场决定,Rust 只回答「数据是什么」,所以 invoke 可以是异步的。
反过来,Rust → 页面这一侧 dioxus 走得比 tauri 远:它要高频推送一串串二进制指令,edits.rs 的注释说最早是在自定义协议上长轮询,因 Android 上 wry 的问题改成 WebSocket,换来的是一个本机端口和自己管的密钥。tauri 的推送(事件、Channel)都是 eval 或者让 JS 来 fetch,没有端口,但如第 5 条所示,eval 搬大块结构化数据很慢。
3.x 的方向
仓库里已经有 tauri-v3.0.0-alpha.0(2026-09-12)到 tauri-v3.0.0-alpha.5(2026-10-14)六个预发布 tag。下面只写 alpha.5 的 crates/tauri/CHANGELOG.md 和工作区里能直接读到的东西,alpha 阶段随时可能再变:
- 运行时变成运行期选择。 alpha.0 的 CHANGELOG:「The webview runtime is now selected when building the app instead of through Cargo features of the
tauricrate.」应用直接依赖运行时 crate,写tauri::Builder::default().runtime(tauri_runtime_wry::Wry::default());Builder::default()用的是新的类型擦除的tauri::DynRuntime,它也是AppHandle、Window、Webview等的默认运行时类型;要静态分发就写tauri::Builder::<tauri_runtime_wry::WryRuntime>::new()。这是对第 7 条代价的回应:类型参数还在,但默认不再绑死某一个实现。 - 第二个真实运行时。 alpha.5 的工作区里多了
crates/tauri-runtime-cef,Cargo.toml的描述是「Tauri runtime interface for Chromium Embedded Framework」。上面那条 CHANGELOG 的示例里写着// or tauri_runtime_cef::Cef::default()。也就是说,第 1 条「系统 webview」在 3.x 里会变成可选项之一,可以换成自带 Chromium 的运行时。 - 自定义 scheme 的 URL 由运行时决定。
tauri://localhost还是http://tauri.localhost不再按平台写死,Runtime::custom_scheme_url挪到了RuntimeHandle;MockRuntime在所有平台上都用tauri://localhost。 - 编进二进制的 ACL 变小。 alpha.0 的 Performance Improvements:「Reduce the size of the resolved ACL embedded in the app by storing the autogenerated command permissions as a
commandslist on the plugin/app manifest instead of two explicit permissions (allow-$commandanddeny-$command) per command.」应用清单还多了隐式的allow-*/deny-*。2.12.5 的Resolved::resolve上方也留着一条 TODO,说 v3 要把base_scope_id做成它的参数(编译期解析的冷知识)。 - 插件钩子不再持锁调用。 alpha.3 的 #16135:「Plugin hooks no longer run with the plugin store locked, so a plugin can add or remove plugins or exit the app from any of its callbacks without deadlocking.」代价是
Plugin要求Sync、钩子改成&self并可能并发调用。这正是 2.12.5 里插件那两种死锁的成因。 - 多 webview 的布局。 alpha.3 的 #15971 给 unstable 的
WebviewBuilder加了bounds、size、position,不设时子 webview 填满父窗口并自动跟随;Window::add_child去掉了位置和大小参数。
动脑筋答案
二进制大小:
9823904 release/design-size
1672064 release/design-wry
启动到第一次往返完成,从两份输出里筛出每次运行的标题行和 elapsed_ms(grep -E '^== release|elapsed' design-runs-tauri.txt,裸 wry 同样筛 design-runs.txt 的 release 部分):
== release/design-size run 1 exit=0 wall_ms=758
PROBE tauri elapsed_ms=527
== release/design-size run 2 exit=0 wall_ms=665
PROBE tauri elapsed_ms=433
== release/design-size run 3 exit=0 wall_ms=681
PROBE tauri elapsed_ms=438
== release/design-size run 4 exit=0 wall_ms=658
PROBE tauri elapsed_ms=445
== release/design-size run 5 exit=0 wall_ms=661
PROBE tauri elapsed_ms=440
== release/design-wry run 1 exit=0 wall_ms=671
PROBE wry elapsed_ms=448
== release/design-wry run 2 exit=0 wall_ms=669
PROBE wry elapsed_ms=427
== release/design-wry run 3 exit=0 wall_ms=587
PROBE wry elapsed_ms=357
== release/design-wry run 4 exit=0 wall_ms=596
PROBE wry elapsed_ms=375
== release/design-wry run 5 exit=0 wall_ms=631
PROBE wry elapsed_ms=415
- 体积差 8 151 840 字节,约 7.8 MiB:tauri 的二进制是裸 wry 的 5.9 倍。用 tauri 仓库自己的发布配置(
small)编,两者是 3 068 976 对 859 552 字节,差约 2.1 MiB。 - 启动差得很少:中位数 440 对 415 毫秒,差 25 毫秒,而裸 wry 自己 5 次之间就差了 91 毫秒(357 到 448)。从
main到页面第一次回话的四百多毫秒里,大部分花在建窗口、拉起 WebKit 的三个进程和加载页面上,这一部分两者完全一样。 - 两边量的时刻略有不同:tauri 在页面做完
greet和report两次 invoke 之后,裸 wry 在一次fetch加一次postMessage之后。所以 25 毫秒是上限意义上的估计,不是 tauri 初始化的精确开销。
小结
- 系统 webview:tauri 的 release 二进制 9.4 MiB(按它自己的发布配置 2.9 MiB),比裸 wry + tao 大约 8 MiB,启动只慢几十毫秒;三个 WebKit 进程由系统提供、约 37 MB。代价是三个平台的引擎、源、子 frame 和请求体行为各不相同。
- 页面和 IPC 走自定义 scheme:进程里一个网络套接字都没有(dioxus-desktop 有一个本机端口),代价是请求在主线程进来、跨源头要自己补、处理函数不应答就永远挂着。
- IPC 用
fetch,1.x 的postMessage+eval留作退路:5 MiB 字节快几十倍;两条路的正文语义不同,退路永久且会重发。Android 上只能走退路。 - JSON 是默认编码,原始字节是显式的第二条路;只看顶层的规则带来
"1,2,3"和嵌套字段不改名这类问题。事件是一段 eval,大的结构化 payload 很慢,要用Channel。 - ACL 从 1.x 的 Cargo feature 变成编译期解析的数据表:粒度细到窗口、来源和作用域,代价是应用命令默认不查、拼错命令报 not allowed、deny 不分窗口、整张权限表编进二进制。
R: Runtime换来MockRuntime和可替换的运行时,代价是满屏的类型参数和与真实运行时不同的测试行为;窗口和 webview 拆开换来多 webview,代价是两套标签。3.x alpha 的方向是默认类型擦除的DynRuntime、一个 CEF 运行时、更小的 ACL 和不持锁的插件钩子。