Rust in Everything
基于 tauri 2.12.5

设计取舍

主线位置:三条主线的汇总。启动主线上的取舍是「系统 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 头;一个永不应答的请求就是一个永不落定的 Promisetauri 解决什么问题、本页实验、ipc 协议处理
3. IPC 用 fetch,postMessage 作退路原始字节不经 JSON;5 MiB 字节比 1.x 的路快几十倍两条路语义不同;退路是永久的、会重发,命令可能跑两次JS 侧:invoke 与回调、ipc 协议处理
4. JSON 是默认编码参数、返回值、错误都只要 serde顶层数组被发成文本;只有顶层键改 camelCase;大的结构化数据要解析参数、返回值与 InvokeResolver、Channel
5. 事件是一段 eval不用请求就能推到每个 webviewpayload 当 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 的窗口拿不到 WebviewWindowWindow、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 模板去掉 test feature,用默认 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 合计
tauri9.4 MiB / 2.9 MiB440 / 474665 / 70227 MB约 37 MB
裸 wry + tao1.6 MiB / 0.82 MiB415 / 384631 / 60225–26 MB约 36 MB
dioxus-desktop6.1 MiB / 2.9 MiB446 / 453669 / 68727 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。
❗IMPORTANT
动脑筋:同一个页面、同一个 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 协议处理)。
📝NOTE
没有蠢问题

问: 既然 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。

📝NOTE
冷知识:allowlist 被一个 PR 整个换掉

提交 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 bound AppHandle: CommandArg<'_, R> is not satisfied」,提示「the trait serde_core::de::Deserialize<'_> is not implemented for AppHandle」(#[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)。

📝NOTE
冷知识:1.x 的 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.5dioxus-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)
谁能调 Rustinvoke 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 tauri crate.」应用直接依赖运行时 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 commands list on the plugin/app manifest instead of two explicit permissions (allow-$command and deny-$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 和不持锁的插件钩子。

💬 关联讨论

发起讨论
加载中...