从 Rust 主动调用前端
本教程共 48 篇 · 第 24 篇 · 更新于 2026-08-09 · 约 8 分钟阅读
本节目标:学完能用 Tauri 的事件(event)让 Rust 主动把数据推到前端,理解它和命令 invoke 的本质区别,并写出能跑的最小示例。
前面的命令都是「前端拉、Rust 给」:前端 invoke 一下,Rust 算完返回。但有些场景反过来更自然——比如后台下载进度、定时刷新、硬件状态变化,这些是 Rust 主动想告诉前端的。这套机制叫「事件(event)」。
命令是「拉」,事件是「推」
先记住这对核心区别,后面就不乱了:
- 命令(command):前端主动发起(拉),Rust 收到后返回一次结果。一对一,有来有回。
- 事件(event):Rust 主动广播(推),前端提前挂好监听器等着。一对多,Rust 想发几次就发几次,前端不一定回。
事件没有强类型约束,载荷(payload)必须是 JSON,不支持像命令那样的 capabilities 精细权限。它适合「小批量、流式、多消费者」的场景,比如推送通知、进度条更新。
Note事件永远是异步的,不能返回值。如果你需要「前端问、Rust 答」的往返,那就该用命令,而不是事件。
Rust 侧用 emit 主动推送
Rust 想发事件,靠的是 Emitter trait 上的 emit 方法。任何持有 AppHandle 或 WebviewWindow 的地方都能用,记得先 use tauri::Emitter;。
// src-tauri/src/lib.rs
use tauri::{AppHandle, Emitter};
#[tauri::command]
fn download(app: AppHandle, url: String) {
app.emit("download-started", &url).unwrap();
for progress in [1, 15, 50, 80, 100] {
app.emit("download-progress", progress).unwrap();
}
app.emit("download-finished", &url).unwrap();
}
app.emit("事件名", 载荷) 把事件广播给所有监听者。如果你手里有的是 WebviewWindow 而非 AppHandle,调用方式一样:window.emit("事件名", 载荷)。
这两者的区别在「范围」:AppHandle 代表整个应用,从它发出的事件默认面向全局;WebviewWindow 代表某个具体窗口,用 window.emit 时语义上更偏向「从这个窗口的视角发出」。无论哪种,要精确控制「只给某窗口」都得用 emit_to。新手常纠结该抓哪个句柄——简单说,命令函数签名里写 app: AppHandle 最通用,几乎什么都能干;只有当你明确需要操作「发起调用的那个窗口」本身(比如改它的标题、隐藏它)时,才用 WebviewWindow。
事件载荷可以是任意「可序列化且可克隆(Clone)」的类型。所以推荐用派生了 Clone 和 Serialize 的结构体,传更丰富的字段:
use serde::Serialize;
use tauri::{AppHandle, Emitter};
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadStarted<'a> {
url: &'a str,
download_id: usize,
content_length: usize,
}
#[tauri::command]
fn download(app: AppHandle, url: String) {
app.emit("download-started", DownloadStarted {
url: &url,
download_id: 1,
content_length: 1000,
}).unwrap();
}
Warning事件载荷必须同时满足
Serialize和Clone。忘加#[derive(Clone)]是新手常见编译错误,记住这两个派生要一起写。
前端用 listen 接收事件
前端用 @tauri-apps/api/event 里的 listen 挂监听器。回调函数收到的 event 对象,真正的数据在 event.payload 上。
import { listen } from '@tauri-apps/api/event';
type ProgressPayload = number;
listen<ProgressPayload>('download-progress', (event) => {
console.log('当前进度:', event.payload);
});
listen 本身返回的是一个取消函数(Promise 解析为 unlisten)。当这个组件要卸载时,调用它能避免内存泄漏:
const unlisten = await listen<number>('download-progress', (e) => {
console.log(e.payload);
});
// 之后想停止监听:
unlisten();
全局广播 vs 推给指定窗口
app.emit 是全局的:所有监听了该事件名的前端都会收到。如果你只希望某个窗口收到,用 emit_to,指定窗口的 label:
// 只推给 label 为 "editor" 的窗口
app.emit_to("editor", "file-changed", payload).unwrap();
还有更灵活的 emit_filter,可以写一个闭包决定推给哪些窗口。反过来,前端也能用 emitTo(来自 @tauri-apps/api/event)从某个窗口向特定窗口发事件。
Tip全局事件会送达所有监听器;而「定向事件」不会触发普通全局监听。如果你在前端想接收「任意目标」的事件(catch-all),要用
listen_any而不是listen。
完整最小示例(前后端打通)
Rust 侧:一个命令被调用后,在后台循环推送进度(这里借用上一章的异步运行时技巧):
// src-tauri/src/lib.rs
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use tauri::async_runtime;
#[tauri::command]
fn start_progress(app: AppHandle) {
async_runtime::spawn(async move {
for i in 0..=100 {
app.emit("progress", i).ok();
async_runtime::sleep(Duration::from_millis(30)).await;
}
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![start_progress])
.run(tauri::generate_context!())
.expect("启动 Tauri 应用失败");
}
前端:点按钮启动命令,同时挂好监听器把进度显示出来:
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
// 先挂监听,再触发命令,避免漏掉最早几条事件
const unlisten = await listen<number>('progress', (event) => {
console.log('进度:', event.payload, '%');
});
await invoke('start_progress');
// 不再需要时:
// unlisten();
Warning注意顺序:应该先
listen再invoke。否则命令瞬间发出的前几条事件,可能早于监听器挂好,就被丢了。这个坑在「一调用命令就立刻连发事件」的场景里特别容易踩,养成「先订阅、后触发」的习惯能省掉很多诡异的调试时间。
事件 vs 通道:什么时候用哪个
Tauri 还提供另一种「Rust 推前端」的机制叫通道(channel),它和事件长得像,定位却不同。一句话区分:事件适合「小数据、偶尔推、广播给多个监听者」;通道适合「大数据、高频流、有序送达」。
事件底层本质上是在触发时直接执行一段 JavaScript,所以每发一条都有一点开销;要流式推送大文件、WebSocket 消息、子进程输出这类高频大数据,用通道(前端 new Channel() 当参数传给命令,Rust 侧 Channel::send)更高效也更稳。本章聚焦事件,因为它覆盖了绝大多数「通知、进度、状态变化」的日常需求,理解事件后,通道只是换了个 API 的事。
小结
当你需要「Rust 主动、反复、一对多」地通知前端时,用事件:app.emit / window.emit 在 Rust 侧推送,listen 在前端接收。它和命令是互补的两套通信方式——命令是前端拉、事件是 Rust 推。把这两章合起来,前后端双向通信的拼图就完整了。