Tauri Commands 深度解析:Rust 後端的強大力量
如果你曾經用過 Electron,大概會對「整個 Chromium 打包進去」這件事又愛又恨。Tauri 走了一條不同的路——用系統原生的 WebView 做前端渲染,然後把真正的重活交給 Rust 後端。而連接這兩個世界的橋樑,就是 Tauri Command 系統。
今天我們就來徹底搞懂這套機制,從最基礎的用法到進階的狀態管理和非同步處理,一次講清楚。
Command 系統:前後端的握手協議
Tauri 的 Command 本質上是一個 RPC(Remote Procedure Call)機制。前端的 JavaScript/TypeScript 透過 invoke 函式呼叫 Rust 端定義好的函式,Rust 處理完畢後把結果序列化回傳。你可以把它想像成一個超快的本地 API——不需要 HTTP server,不需要 port,資料直接在行程內傳遞。
整個流程是這樣的:
- 你在 Rust 端用
#[tauri::command]標記一個函式 - 在
Builder中註冊這個 Command - 前端用
invoke('command_name', { args })呼叫 - Tauri 負責參數的反序列化、函式執行、結果序列化
聽起來很簡單對吧?實際上也確實不複雜,但魔鬼藏在細節裡。
基本用法:從 Hello World 開始
讓我們先看最簡單的 Command 定義和呼叫方式:
// src-tauri/src/lib.rs
use tauri::command;
#[command]
fn greet(name: String) -> String {
format!("你好,{}!歡迎使用 Tauri。", name)
}
// 檔案系統操作範例
#[command]
fn read_config(path: String) -> Result<String, String> {
std::fs::read_to_string(&path)
.map_err(|e| format!("無法讀取檔案 {}: {}", path, e))
}
// 系統資訊讀取
#[command]
fn get_system_info() -> SystemInfo {
SystemInfo {
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
hostname: hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string()),
}
}
#[derive(serde::Serialize)]
struct SystemInfo {
os: String,
arch: String,
hostname: String,
}
// 在 Builder 中註冊
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
greet,
read_config,
get_system_info
])
.run(tauri::generate_context!())
.expect("啟動 Tauri 應用失敗");
}幾個重點:
#[tauri::command]宏會自動幫你處理參數的反序列化(Deserialize)和回傳值的序列化(Serialize)- 回傳的自訂型別需要實作
serde::Serialize - 所有 Command 都必須在
generate_handler!中註冊,否則前端呼叫時會收到錯誤
前端呼叫:invoke 的正確姿勢
在前端,呼叫 Command 非常直覺:
// src/App.tsx 或任何前端元件
import { invoke } from '@tauri-apps/api/core';
// 基本呼叫
async function sayHello() {
const message = await invoke<string>('greet', { name: '開發者' });
console.log(message); // "你好,開發者!歡迎使用 Tauri。"
}
// 帶有錯誤處理的呼叫
async function loadConfig(filePath: string) {
try {
const content = await invoke<string>('read_config', { path: filePath });
return content;
} catch (error) {
console.error('讀取設定檔失敗:', error);
return null;
}
}
// 取得系統資訊
interface SystemInfo {
os: string;
arch: string;
hostname: string;
}
async function fetchSystemInfo() {
const info = await invoke<SystemInfo>('get_system_info');
console.log(`作業系統: ${info.os}, 架構: ${info.arch}`);
}注意 invoke 回傳的是 Promise,所以記得用 await 或 .then() 來處理。參數名稱必須和 Rust 端的函式參數名稱完全一致——Tauri 2.0 預設使用 camelCase 轉換,所以 Rust 的 file_path 對應前端的 filePath。
錯誤處理:Result 模式
在真實應用中,幾乎每個 Command 都可能失敗。Tauri 鼓勵你使用 Rust 的 Result 型別來優雅地處理錯誤:
// 自訂錯誤型別
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("找不到檔案: {0}")]
FileNotFound(String),
#[error("權限不足: {0}")]
PermissionDenied(String),
#[error("JSON 解析失敗: {0}")]
ParseError(#[from] serde_json::Error),
}
// 讓錯誤可以序列化回前端
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}當 Command 回傳 Err 時,前端的 invoke Promise 會被 reject,你可以用 try/catch 捕捉錯誤訊息。這種模式讓錯誤處理變得非常自然——Rust 端負責定義錯誤類型和訊息,前端負責呈現。
State 管理:應用程式的記憶
大多數應用都需要某種形式的全域狀態。Tauri 提供了內建的 State 管理機制,讓你可以在不同的 Command 之間共享資料:
use std::sync::Mutex;
use tauri::State;
struct AppState {
counter: Mutex<i32>,
config: Mutex<AppConfig>,
}
#[derive(Default, serde::Serialize, Clone)]
struct AppConfig {
theme: String,
language: String,
}
#[command]
fn increment(state: State<'_, AppState>) -> i32 {
let mut counter = state.counter.lock().unwrap();
*counter += 1;
*counter
}
#[command]
fn get_config(state: State<'_, AppState>) -> AppConfig {
state.config.lock().unwrap().clone()
}
pub fn run() {
tauri::Builder::default()
.manage(AppState {
counter: Mutex::new(0),
config: Mutex::new(AppConfig {
theme: "dark".to_string(),
language: "zh-TW".to_string(),
}),
})
.invoke_handler(tauri::generate_handler![increment, get_config])
.run(tauri::generate_context!())
.expect("啟動失敗");
}State 透過 Tauri 的依賴注入系統自動傳入,你不需要(也不能)從前端傳遞它。由於 Command 可能在多個執行緒上執行,共享狀態需要用 Mutex 或 RwLock 來保護。
這裡有個實用建議:如果你的狀態讀多寫少,考慮用 RwLock 取代 Mutex,這樣多個讀取操作可以同時進行,效能會更好。
非同步 Command:不要阻塞主執行緒
如果你的 Command 需要做耗時的操作(網路請求、大量檔案 I/O),絕對應該使用 async:
#[command]
async fn fetch_data(url: String) -> Result<String, String> {
let response = reqwest::get(&url)
.await
.map_err(|e| format!("請求失敗: {}", e))?;
response.text()
.await
.map_err(|e| format!("讀取回應失敗: {}", e))
}同步的 Command 會在 Tauri 的執行緒池中執行,而 async Command 則在 Tokio runtime 上執行。兩者的差異在於:async Command 更適合 I/O 密集型操作,因為它不會佔用執行緒池中的執行緒等待 I/O。
一個常見的陷阱:如果你在 async Command 中需要存取 State,不能直接使用 State<'_, T>,因為它不是 Send。解決方案是在 async 區塊開始前就把需要的資料取出來。
實戰經驗總結
經過幾個 Tauri 專案的實戰,我歸納出幾個最佳實踐:
Command 粒度:不要把所有邏輯塞進一個巨大的 Command。每個 Command 應該做一件明確的事情,就像設計 REST API 端點一樣。
錯誤訊息要有意義:前端收到的錯誤訊息就是你在 Rust 端定義的,確保它們對使用者或開發者有幫助,而不是一堆 Rust 內部的堆疊追蹤。
善用型別系統:Rust 強大的型別系統是你的好朋友。用 enum 定義清楚的錯誤類型,用 struct 定義清楚的資料結構,讓編譯器幫你抓 bug。
測試:Command 本質上就是普通的 Rust 函式(State 除外),你完全可以用標準的 Rust 測試框架來測試它們。把核心邏輯抽出來放在獨立的函式中,Command 只負責做薄薄的一層包裝。
下一步
掌握了 Command 系統之後,下一個要理解的就是 Tauri 的 Event 系統——它讓 Rust 後端可以主動推送資料到前端,實現真正的雙向通訊。這部分我們在下一篇文章中會詳細探討。
Tauri 的 Command 系統設計得非常巧妙:既保持了簡單性(基本用法幾行程式碼就搞定),又提供了足夠的彈性(State、async、自訂錯誤型別)來應對複雜場景。如果你正在猶豫要不要從 Electron 遷移到 Tauri,Command 系統絕對不會是阻礙你的理由——反而可能會是吸引你的原因。
分享這篇文章