如何在 Tauri 中实现事件监听和消息广播?
Tauri 的前后端通信有两条路:命令(Command)和事件(Event)。命令是一问一答,前端调用后端返回结果;事件是发布-订阅,一方发出消息,所有订阅方都能收到。当你需要后端主动推送数据、多个窗口之间同步状态、或者实现观察者模式时,事件系统就是正确选择。
Tauri 事件系统的核心概念
Tauri 的事件系统基于发布-订阅模式,主要有三个角色:
- 发送方(Emitter):发出事件的一方,可以是 Rust 后端,也可以是前端 JavaScript
- 监听方(Listener):订阅并处理事件的一方
- 事件总线(Event Bus):Tauri 内部的消息分发通道
事件分为两种作用域:
- 全局事件(Global):广播给所有监听者,任何注册了该事件名的监听器都会收到
- Webview 特定事件:只发送给指定 webview 窗口,用于窗口间定向通信
事件载荷始终是 JSON 字符串,因此不适合传输大数据。如果需要类型安全或返回值,应该用 Command 而非 Event。
前端监听事件
基本用法
从 @tauri-apps/api/event 导入 listen 函数:
javascriptimport { listen } from '@tauri-apps/api/event'; const unlisten = await listen('download-progress', (event) => { console.log(`进度: ${event.payload}%`); }); // 组件销毁时取消订阅,防止内存泄漏 unlisten();
listen 返回一个 Promise,resolve 后得到取消订阅函数。务必在组件卸载时调用它。
在 Vue 中使用
javascriptimport { listen } from '@tauri-apps/api/event'; import { onUnmounted } from 'vue'; const unlisten = await listen('file-saved', (event) => { console.log('文件已保存:', event.payload); }); onUnmounted(() => { unlisten(); });
在 React 中使用
javascriptimport { listen } from '@tauri-apps/api/event'; import { useEffect } from 'react'; function App() { useEffect(() => { let unlisten; listen('file-saved', (event) => { console.log('文件已保存:', event.payload); }).then((fn) => { unlisten = fn; }); return () => unlisten?.(); }, []); }
监听 Webview 特定事件
使用 getCurrentWebviewWindow 只接收发送给当前窗口的事件:
javascriptimport { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; const appWebview = getCurrentWebviewWindow(); appWebview.listen('logged-in', (event) => { localStorage.setItem('session-token', event.payload); });
注意:webview 特定事件不会触发全局 listen 注册的监听器。如果需要监听所有事件(不论目标),使用 listenAny。
前端发送事件
全局广播
javascriptimport { emit } from '@tauri-apps/api/event'; await emit('user-logout', { reason: 'timeout' });
发送到指定窗口
javascriptimport { emitTo } from '@tauri-apps/api/event'; await emitTo('settings-window', 'config-changed', { theme: 'dark' });
emitTo 的第一个参数是目标窗口的 label,只有该窗口的监听器会收到事件。
Rust 后端监听和发送事件
在 setup 中注册监听器
Rust 端监听事件通常在 Builder::setup 回调中进行:
rustuse tauri::Manager; fn main() { tauri::Builder::default() .setup(|app| { // 监听前端发出的事件 app.listen("frontend-event", |event| { println!("收到前端事件: {:?}", event.payload()); }); // 只监听一次 app.once("init-complete", |event| { println!("初始化完成"); }); Ok(()) }) .run(tauri::generate_context!()) .expect("启动失败"); }
app.listen 注册持久监听器,app.once 只触发一次后自动注销。
发送事件到前端
在拥有 AppHandle 或 WebviewWindow 的地方都可以发送事件:
rustuse tauri::Emitter; // 全局广播 app.emit("download-progress", 75).unwrap(); // 发送到指定窗口 app.emit_to("main", "download-progress", 75).unwrap();
在 Command 中发送事件
Command 函数可以通过参数获取 AppHandle,从而在业务逻辑中发送事件:
rustuse tauri::{AppHandle, Emitter}; #[tauri::command] async fn start_download(app: AppHandle) -> Result<(), String> { for i in 1..=100 { tokio::time::sleep(std::time::Duration::from_millis(50)).await; app.emit("download-progress", i).map_err(|e| e.to_string())?; } Ok(()) }
前端监听:
javascriptconst unlisten = await listen("download-progress", (event) => { updateProgressBar(event.payload); });
在后台线程中发送事件
长耗时任务通常在独立线程中运行,需要将 AppHandle 克隆传入:
rustuse std::sync::mpsc; use tauri::{AppHandle, Emitter}; #[tauri::command] fn start_task(app: AppHandle) -> Result<(), String> { let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { // 后台执行耗时操作 for i in 0..10 { std::thread::sleep(std::time::Duration::from_secs(1)); tx.send(i).unwrap(); } }); // 在主线程转发事件到前端 while let Ok(progress) = rx.recv() { app.emit("task-progress", progress).map_err(|e| e.to_string())?; } Ok(()) }
更推荐的做法是使用 tokio::spawn 配合异步运行时:
rustuse tauri::{AppHandle, Emitter}; #[tauri::command] async fn start_task(app: AppHandle) -> Result<(), String> { let app = app.clone(); tokio::spawn(async move { for i in 0..10 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; let _ = app.emit("task-progress", i); } }); Ok(()) }
多窗口间通信
Tauri 的事件系统天然支持多窗口通信。一个典型的场景:主窗口打开设置窗口,设置窗口修改配置后通知主窗口刷新。
设置窗口发送事件:
javascriptimport { emitTo } from '@tauri-apps/api/event'; async function saveConfig(config) { await emitTo('main', 'config-updated', config); await getCurrentWebviewWindow().close(); }
主窗口监听事件:
javascriptimport { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; const appWebview = getCurrentWebviewWindow(); appWebview.listen('config-updated', (event) => { applyConfig(event.payload); });
常见问题排查
事件监听器收不到消息
排查步骤:
- 确认事件名前后端完全一致,区分大小写
- 确认作用域匹配:用
emit发送的全局事件用listen接收;用emit_to发送的窗口事件用WebviewWindow.listen接收 - 确认监听器注册时机:必须在事件发出之前注册,否则会错过
内存泄漏
忘记调用 unlisten 是最常见的泄漏来源。在 React 中用 useEffect 的清理函数,在 Vue 中用 onUnmounted,确保组件销毁时取消订阅。
事件载荷为空或格式不对
事件载荷会被序列化为 JSON。Rust 端发送的结构体必须实现 Serialize,前端接收时注意 event.payload 的类型是自动解析的 JS 对象,不是字符串:
rust#[derive(Clone, serde::Serialize)] struct ProgressPayload { percent: u32, message: String, } app.emit("progress", ProgressPayload { percent: 50, message: "下载中".into(), }).unwrap();
javascriptlisten("progress", (event) => { // event.payload 是 { percent: 50, message: "下载中" } console.log(event.payload.percent); });
实践要点
- 事件命名用小写短横线:
download-progress而非downloadProgress,与 Tauri 系统事件风格一致 - 优先用 Command 做请求-响应:事件适合推送和广播,不适合需要返回值的场景
- 避免高频事件:事件载荷是 JSON,高频场景考虑用 Channel
- 载荷要精简:只传必要字段,大文件路径优于文件内容
- 错误处理不要吞异常:
app.emit可能失败(比如窗口已关闭),用let _ = app.emit(...)静默忽略或记录日志