跳至主要內容

Tauri IPC 通訊機制:前後端雙向溝通完全指南

9 分鐘閱讀 2,300 字

上一篇我們聊了 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 必須實作 SerializeClone
  • 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 oncelisten 會持續監聽直到你手動取消,適合進度更新這類連續事件;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 層:

  1. 定義清楚的事件目錄:在一個集中的地方(例如 events.rs)定義所有事件名稱為常數,前端也維護一份對應的列舉。這樣當事件名稱需要修改時,只需要改一個地方。

  2. 為事件加上版本:當你的 payload 結構需要演進時,加上版本欄位可以讓前後端的升級更平滑。

  3. 考慮使用 TypeScript codegen:Tauri 社群有工具可以從 Rust 型別自動生成 TypeScript 型別定義,確保前後端的型別永遠同步。

總結

Tauri 的 IPC 系統提供了豐富的工具來處理前後端通訊。Command 負責請求-回應模式,Event 負責非同步通知,Channel 則為 Command 中的串流資料提供了結構化的管道。

三者搭配使用,基本上可以覆蓋所有前後端通訊的場景。重點是根據場景選擇正確的工具——不要用 Command 輪詢來模擬即時更新(那是 Event 的活),也不要用全域 Event 來處理應該屬於特定操作的回饋(那是 Channel 的強項)。

把每種機制用在對的地方,你的 Tauri 應用就能既高效又好維護。

分享這篇文章