Tauri IPC 通訊機制:前後端雙向溝通完全指南
上一篇我們聊了 Tauri Command——前端主動呼叫 Rust 後端的方式。但在真實應用中,溝通不可能只是單向的。想像你正在做一個檔案下載器:前端點了「下載」按鈕,然後呢?使用者就乾等著嗎?當然不行,你需要 Rust 後端能「主動」告訴前端目前的下載進度。
這就是 IPC(Inter-Process Communication)的完整面貌。在 Tauri 中,前後端的溝通有兩種主要模式:Commands(請求-回應)和 Events(發布-訂閱)。今天我們把 Event 系統翻個底朝天。
兩種通訊模式的角色分工
先釐清什麼時候該用哪一種:
- Commands:前端需要一個明確的回傳值時使用。像是「讀取設定檔」、「儲存資料」這類一問一答的場景。
- Events:不需要立即回傳值,或是需要多次推送資料時使用。像是「下載進度更新」、「檔案系統變更通知」、「背景任務完成」。
你可以把 Commands 想像成打電話——一問一答,同步等待回覆。Events 則像是廣播電台——發送方不在乎有沒有人在聽,接收方可以隨時開始監聽。
Event 系統基礎:emit 與 listen
Tauri 的 Event 系統非常直覺。讓我們從最基本的用法開始,逐步建構一個完整的即時進度回報系統:
// src-tauri/src/lib.rs
use tauri::{command, AppHandle, Emitter};
use serde::Serialize;
#[derive(Clone, Serialize)]
struct DownloadProgress {
url: String,
downloaded: u64,
total: u64,
percentage: f32,
speed_mbps: f32,
}
#[derive(Clone, Serialize)]
struct TaskNotification {
task_id: String,
status: String, // "running", "completed", "failed"
message: String,
}
// 從 Rust 主動推送事件到前端
#[command]
async fn start_download(app: AppHandle, url: String) -> Result<String, String> {
let task_id = uuid::Uuid::new_v4().to_string();
let task_id_clone = task_id.clone();
// 在背景執行緒中處理下載
tauri::async_runtime::spawn(async move {
let total_size: u64 = 100_000_000; // 模擬 100MB
let mut downloaded: u64 = 0;
let chunk_size: u64 = 1_000_000;
while downloaded < total_size {
// 模擬下載延遲
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
downloaded += chunk_size;
let progress = DownloadProgress {
url: url.clone(),
downloaded,
total: total_size,
percentage: (downloaded as f32 / total_size as f32) * 100.0,
speed_mbps: 10.0,
};
// 推送進度事件到前端
let _ = app.emit("download-progress", &progress);
}
// 下載完成通知
let _ = app.emit("task-notification", &TaskNotification {
task_id: task_id_clone,
status: "completed".to_string(),
message: format!("下載完成: {}", url),
});
});
Ok(task_id)
}
// 監聽來自前端的事件
pub fn run() {
tauri::Builder::default()
.setup(|app| {
// 在 setup 中監聽前端發來的事件
let handle = app.handle().clone();
app.listen("user-action", move |event| {
println!("收到前端事件: {:?}", event.payload());
// 可以在這裡根據事件做相應處理
let _ = handle.emit("action-confirmed", "收到你的操作了!");
});
Ok(())
})
.invoke_handler(tauri::generate_handler![start_download])
.run(tauri::generate_context!())
.expect("啟動失敗");
}幾個關鍵觀念:
app.emit()會把事件廣播給所有前端視窗- 事件的 payload 必須實作
Serialize和Clone AppHandle可以透過 Command 參數自動注入,也可以在setup中取得app.listen()讓 Rust 端可以監聽前端發來的事件
前端的事件處理
在前端,事件的監聯和發送同樣簡單直覺:
import { listen, emit, once } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
// === 監聽事件 ===
interface DownloadProgress {
url: string;
downloaded: number;
total: number;
percentage: number;
speed_mbps: number;
}
interface TaskNotification {
task_id: string;
status: string;
message: string;
}
// 持續監聽下載進度
async function setupProgressListener() {
const unlisten = await listen<DownloadProgress>(
'download-progress',
(event) => {
const p = event.payload;
console.log(`下載進度: ${p.percentage.toFixed(1)}% (${p.speed_mbps} MB/s)`);
updateProgressBar(p.percentage);
}
);
// 元件卸載時記得取消監聽!
// 在 React: useEffect 的 cleanup
// 在 Vue: onUnmounted
return unlisten;
}
// 只監聽一次的事件(自動取消)
async function waitForCompletion() {
const event = await once<TaskNotification>('task-notification');
console.log(`任務 ${event.payload.task_id}: ${event.payload.message}`);
}
// === 發送事件到 Rust ===
async function notifyBackend() {
await emit('user-action', {
type: 'settings-changed',
timestamp: Date.now(),
});
}
// === 完整的下載流程 ===
async function downloadFile(url: string) {
// 1. 先設定監聽器
const unlistenProgress = await setupProgressListener();
const completionPromise = waitForCompletion();
// 2. 啟動下載(Command)
const taskId = await invoke<string>('start_download', { url });
console.log(`下載任務已啟動: ${taskId}`);
// 3. 等待完成事件
await completionPromise;
console.log('下載完成!');
// 4. 清理監聽器
unlistenProgress();
}
function updateProgressBar(percentage: number) {
// 更新 UI 的進度條
const bar = document.getElementById('progress-bar');
if (bar) bar.style.width = `${percentage}%`;
}這裡有幾個重要的實踐要點:
記得取消監聽:listen 回傳一個 unlisten 函式。如果你在 SPA 中切換頁面或元件卸載時沒有呼叫它,就會造成記憶體洩漏和重複處理。在 React 中放在 useEffect 的 cleanup 中,Vue 中放在 onUnmounted。
listen vs once:listen 會持續監聽直到你手動取消,適合進度更新這類連續事件;once 監聽到第一個事件後就自動取消,適合等待一次性的完成通知。
事件命名慣例:建議使用 kebab-case(如 download-progress),保持一致性。可以考慮加上命名空間前綴(如 app:download-progress)來避免衝突。
Channel API:Tauri 2.0 的新武器
Tauri 2.0 引入了 Channel API,這是一個專門為「Command 執行過程中需要多次回傳資料」場景設計的機制。相較於 Event 系統,Channel 更加結構化且型別安全:
use tauri::ipc::Channel;
#[derive(Clone, Serialize)]
#[serde(tag = "type")]
enum ScanEvent {
#[serde(rename = "started")]
Started { total_files: u32 },
#[serde(rename = "progress")]
Progress { file: String, current: u32, total: u32 },
#[serde(rename = "completed")]
Completed { results: Vec<String>, duration_ms: u64 },
}
#[command]
async fn scan_directory(
path: String,
on_event: Channel<ScanEvent>,
) -> Result<(), String> {
let entries: Vec<_> = std::fs::read_dir(&path)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.collect();
let total = entries.len() as u32;
on_event.send(ScanEvent::Started { total_files: total }).unwrap();
let start = std::time::Instant::now();
let mut results = Vec::new();
for (i, entry) in entries.iter().enumerate() {
let file_name = entry.file_name().to_string_lossy().to_string();
on_event.send(ScanEvent::Progress {
file: file_name.clone(),
current: i as u32 + 1,
total,
}).unwrap();
results.push(file_name);
// 模擬處理時間
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
on_event.send(ScanEvent::Completed {
results,
duration_ms: start.elapsed().as_millis() as u64,
}).unwrap();
Ok(())
}Channel 的優勢在於:它和特定的 Command 呼叫綁定,不像 Event 是全域廣播。這意味著如果你同時執行多個掃描任務,每個任務的進度事件不會互相干擾。
前端使用 Channel 也很直覺——傳入一個回呼函式即可:
import { invoke, Channel } from '@tauri-apps/api/core';
type ScanEvent =
| { type: 'started'; total_files: number }
| { type: 'progress'; file: string; current: number; total: number }
| { type: 'completed'; results: string[]; duration_ms: number };
async function scanDirectory(path: string) {
const onEvent = new Channel<ScanEvent>();
onEvent.onmessage = (event) => {
switch (event.type) {
case 'started':
console.log(`開始掃描,共 ${event.total_files} 個檔案`);
break;
case 'progress':
console.log(`[${event.current}/${event.total}] ${event.file}`);
break;
case 'completed':
console.log(`掃描完成!耗時 ${event.duration_ms}ms,找到 ${event.results.length} 個檔案`);
break;
}
};
await invoke('scan_directory', { path, onEvent });
}效能考量與最佳實踐
談到 IPC 通訊,效能是不可忽視的議題。以下是幾個實戰中累積的經驗:
控制事件頻率:如果你的 Rust 後端每毫秒都在產生進度更新,不要每次都 emit——前端 UI 更新的速度跟不上,只會造成不必要的序列化開銷。用一個簡單的節流機制:
let mut last_emit = std::time::Instant::now();
// 在迴圈中...
if last_emit.elapsed().as_millis() >= 100 { // 每 100ms 最多推送一次
app.emit("progress", &progress).ok();
last_emit = std::time::Instant::now();
}最小化 payload 大小:序列化和反序列化是有成本的。不要在每個事件中塞入完整的應用狀態,只傳送實際變更的資料。如果需要傳遞大量資料(例如圖片),考慮使用共享記憶體或暫存檔案,只在事件中傳遞檔案路徑。
Channel vs Event 的選擇:如果是和特定 Command 相關的串流資料(如下載進度),優先使用 Channel。如果是全域性的通知(如系統主題變更),使用 Event。
錯誤傳播:Event 的 emit 不會告訴你前端是否成功處理了事件。如果你需要確認前端收到了,考慮讓前端透過 Command 回覆確認,或設計一個帶確認的協議。
避免事件風暴:在 setup 中監聽事件時,確保處理邏輯足夠輕量。如果需要做重量級處理,把工作丟到背景執行緒。
架構建議
在大型 Tauri 應用中,我建議這樣組織 IPC 層:
定義清楚的事件目錄:在一個集中的地方(例如
events.rs)定義所有事件名稱為常數,前端也維護一份對應的列舉。這樣當事件名稱需要修改時,只需要改一個地方。為事件加上版本:當你的 payload 結構需要演進時,加上版本欄位可以讓前後端的升級更平滑。
考慮使用 TypeScript codegen:Tauri 社群有工具可以從 Rust 型別自動生成 TypeScript 型別定義,確保前後端的型別永遠同步。
總結
Tauri 的 IPC 系統提供了豐富的工具來處理前後端通訊。Command 負責請求-回應模式,Event 負責非同步通知,Channel 則為 Command 中的串流資料提供了結構化的管道。
三者搭配使用,基本上可以覆蓋所有前後端通訊的場景。重點是根據場景選擇正確的工具——不要用 Command 輪詢來模擬即時更新(那是 Event 的活),也不要用全域 Event 來處理應該屬於特定操作的回饋(那是 Channel 的強項)。
把每種機制用在對的地方,你的 Tauri 應用就能既高效又好維護。
分享這篇文章