在 Tauri 里,你可以把通信分成两条主线:
一条叫 Command :JS 主动调用 Rust 的函数,像"发起请求拿结果"。
一条叫 Event:Rust 主动推消息给 JS,像"通知、进度、订阅"。
这两条线合起来,就是一个完整的应用内 RPC + PubSub 体系。
1)JS → Rust:Command(invoke)
你在前端写的调用长这样
在 Tauri v1 常见写法:
import { invoke } from "@tauri-apps/api/tauri";
const result = await invoke<string>("greet", { name: "Aran" });
console.log(result);
你会发现它很像调用远端 API,只是目标不在网络上,而在本机的 Rust 里。
Rust 端怎么接
Rust 端注册一个 command:
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
这就是一套最小闭环:JS 发 invoke,Rust 收到并返回结果。
背后的协议本质是什么
前端把参数序列化成 JSON(或结构化 payload),通过 WebView 的桥接机制发送给宿主(Rust)。Rust 反序列化成函数入参,执行后把返回值再序列化回去。
你可以把它理解成:
-
JS 发起
commandName + payload -
Rust 执行
command(payload) -> result -
JS 等待 Promise resolve
这套机制天然带来了一个工程优势:前端代码不需要关心系统细节,它只关心"我能调用哪些能力"。
2)Rust → JS:Event(emit)
Command 解决了"请求-响应",但桌面应用还有大量场景需要"推送":
-
批处理进度条
-
后台任务状态
-
文件变更通知
-
SQLite 同步完成提示
-
模型推理流式输出
这类需求用 Event 更合适。
Rust 端发事件
use tauri::Manager;
fn main() {
tauri::Builder::default()
.setup(|app| {
let window = app.get_window("main").unwrap();
window.emit("progress", 10).unwrap();
Ok(())
})
.run(tauri::generate_context!())
.expect("error");
}
JS 端监听事件
import { listen } from "@tauri-apps/api/event";
const unlisten = await listen<number>("progress", (event) => {
console.log("progress:", event.payload);
});
这是一种典型的发布订阅模型:
-
Rust
emit(eventName, payload) -
JS
listen(eventName, handler)
Event 的关键价值是:Rust 可以在任何时刻主动通知 UI,适合长任务、实时变化、后台线程。
3)把两条线拼起来:桌面应用的"状态机循环"
一个成品软件的真实运行方式通常是这样的:
-
用户点按钮
-
JS invoke Rust 开始任务
-
Rust 在后台线程跑任务
-
Rust emit 进度事件给 JS
-
JS 更新 UI
-
Rust 完成后返回结果或再 emit 完成事件
这里你会看到:Command 更像启动器,Event 更像过程反馈。
如果你的软件要做"短信智标官"这种批处理,几乎一定是这个模型。
4)安全模型:为什么 Tauri 的通信比你想象中严格
桌面应用的最大风险常见在这里:
JS 是 UI 层,容易被注入脚本;Rust 是系统层,能读写文件、执行命令。
如果通信是全开放的,等于给潜在攻击者提供了一条直达系统能力的通路。
所以 Tauri 的设计很明确:通信能力必须被约束。
常见约束点包括:
-
只有标记了
#[tauri::command]的函数才允许被调用 -
command 名称是显式注册的,不存在"随便调"
-
权限、文件访问、shell 等能力建议走 allowlist(尤其是涉及系统级调用的插件)
-
生产环境建议启用 CSP 和严格的资源加载策略
你可以把它理解成:Rust 是高权限域,JS 只能走白名单桥接。
5)工程实践:什么时候用 Command,什么时候用 Event
这块直接决定你项目会不会越写越乱。
适合 Command 的典型场景
-
读取配置、查询 SQLite、加载文件
-
触发一次性动作(导入、导出、开始推理)
-
返回一个明确结果
特征:一次调用对应一个结果,前端可以 await。
适合 Event 的典型场景
-
进度更新、日志流、长任务状态
-
监听系统事件(文件变化、窗口状态)
-
推送消息、提醒
特征:持续产生信息,前端要订阅。
6)更进一步:你最终会需要的三个"通信层设计"
当项目变大,你会发现"随手写 invoke / listen"会变得难维护。成品感强的软件一般会有三层设计:
第一层:API 层(前端封装)
前端不直接 scattered invoke,而是封装成:
-
api.importSms(filePath) -
api.batchTag(options) -
api.exportResult(format)
这样 UI 组件永远不接触底层通信细节。
第二层:Rust Command 层(接口白名单)
Rust 里 command 只做三件事:
-
校验参数
-
调用 service
-
返回结构化结果
它不直接堆业务逻辑。
第三层:Service 层(业务核心)
真正的:
-
SQLite 操作
-
推理引擎调用
-
批处理 pipeline
-
日志与进度
都在 service 层跑,event 也从这里 emit。
这套结构会让你的项目可测试、可扩展、可维护。
7)结合你的场景:短信批处理/模型推理怎么通信最顺
你这种"离线小模型 + 批处理 + 复核 + 导出"的桌面软件,通信最好走这个模式:
-
点击"开始打标" →
invoke("start_tagging", params) -
Rust 启动后台线程
-
每处理 N 条短信 emit 一次
tagging_progress -
每条结果 emit
tagging_item_result或写入 SQLite 后只 emit id -
完成后 emit
tagging_done或让前端再 invoke 查询汇总
这样 UI 不会卡,体验更像成品软件。