第 11 章 前后端通信 1:Commands(invoke)
本章目标
- 把
#[tauri::command]的所有签名玩熟。 - 掌握参数、返回值、错误、窗口与 AppHandle 注入。
- 学会给前端生成类型安全的 invoke 包装(
tauri-specta)。 - 踩完「命名大小写」、「参数序列化」、「错误结构化」三大坑。
一、Command 的 5 种常用签名
// 1. 同步无参
#[tauri::command]
fn ping() -> String { "pong".into() }
// 2. 异步带参
#[tauri::command]
async fn add(a: i64, b: i64) -> i64 { a + b }
// 3. 带错误
#[tauri::command]
async fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 { Err("div by zero".into()) } else { Ok(a / b) }
}
// 4. 注入 AppHandle / Window / State
#[tauri::command]
async fn play(
app: tauri::AppHandle,
window: tauri::Window,
state: tauri::State<'_, AppState>,
song_id: i64,
) -> Result<(), AppError> {
state.player().play(song_id).await?;
app.emit("song-changed", song_id).ok();
Ok(())
}
// 5. 结构化参数
#[derive(serde::Deserialize)]
struct ScanRequest { root: String, recursive: bool }
#[tauri::command]
async fn scan(req: ScanRequest) -> Result<usize, AppError> { /* ... */ }
前端调用:
import { invoke } from "@tauri-apps/api/core";
await invoke("ping"); // "pong"
await invoke("add", { a: 1, b: 2 }); // 3
await invoke("divide", { a: 6, b: 0 }); // 抛异常
await invoke("play", { songId: 123 }); // songId 自动映射 song_id
await invoke("scan", { req: { root: "/music", recursive: true } });
二、命名大小写:Rust snake_case ↔ JS camelCase
Tauri 自动把 song_id 映射到 songId(前端传 camelCase,后端接 snake_case)。这是 Tauri 2 的默认行为,由 rename_all="camelCase" 在宏层面实现。
两边约定清晰:
- 命令名:Rust
fn play→ 前端invoke("play")。 - 参数名:Rust
song_id→ 前端songId。 - 如果 Rust 侧字段就叫
songId(不推荐),前端也传songId。
三、返回值的序列化
返回值必须实现 serde::Serialize。CloudTone 里的常用返回类型:
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanResult {
pub added: usize,
pub updated: usize,
pub skipped: usize,
pub duration_ms: u64,
}
前端得到:
interface ScanResult { added: number; updated: number; skipped: number; durationMs: number; }
大字节流
返回几十 MB 的字节数组用 JSON 会把主线程卡住。两个办法:
#[tauri::command]返回Vec<u8>——Tauri 会走 binary channel,前端拿ArrayBuffer。- 或者用
Channel<T>做流式返回(见第 12 章)。
四、错误处理:结构化 AppError
直接 Result<T, String> 对调试不友好。推荐:
// src-tauri/src/error.rs
use serde::{Serialize, Serializer};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("db: {0}")]
Db(#[from] sqlx::Error),
#[error("audio: {0}")]
Audio(String),
#[error("not found: {0}")]
NotFound(String),
#[error("{0}")]
Other(String),
}
impl Serialize for AppError {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
#[derive(Serialize)]
struct Repr<'a> { kind: &'a str, message: String }
let repr = match self {
AppError::Io(e) => Repr { kind: "io", message: e.to_string() },
AppError::Db(e) => Repr { kind: "db", message: e.to_string() },
AppError::Audio(m) => Repr { kind: "audio", message: m.clone() },
AppError::NotFound(m) => Repr { kind: "notFound", message: m.clone() },
AppError::Other(m) => Repr { kind: "other", message: m.clone() },
};
repr.serialize(s)
}
}
前端:
try {
await invoke("play", { songId });
} catch (e) {
// e 是 { kind: "audio", message: "..." } 或 string
if (typeof e === "object" && e !== null && "kind" in e) {
const err = e as { kind: string; message: string };
toast.error(`[${err.kind}] ${err.message}`);
} else {
toast.error(String(e));
}
}
五、State 注入与并发
#[tauri::command]
async fn set_volume(state: tauri::State<'_, AppState>, volume: f32) -> Result<(), AppError> {
let mut player = state.player.lock().await;
player.set_volume(volume);
Ok(())
}
注意点:
State<'_, T>的生命周期由宏自动填。不要手写'a。- 同一个 State 会被多次并发调用。确保里面的同步原语正确(第 7 章)。
六、类型安全的前端包装:tauri-specta
默认 invoke<T>(cmd, args) 里 T 靠你手写。大项目两边签名很容易对不上。
方案:引入 specta + tauri-specta,自动把 Rust command 导出成 TS 函数。
Cargo.toml:
[dependencies]
specta = { version = "2.0.0-rc", features = ["serde", "derive"] }
tauri-specta = { version = "=2.0.0-rc", features = ["derive", "typescript"] }
在 command 上同时加 #[specta::specta]:
#[tauri::command]
#[specta::specta]
async fn add(a: i64, b: i64) -> i64 { a + b }
导出 TS:
use tauri_specta::{collect_commands, Builder};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let specta = Builder::<tauri::Wry>::new().commands(collect_commands![add, play, pause]);
#[cfg(debug_assertions)]
specta.export(
specta_typescript::Typescript::default(),
"../src/lib/ipc.ts",
).expect("export TS");
tauri::Builder::default()
.invoke_handler(specta.invoke_handler())
.run(tauri::generate_context!())
.unwrap();
}
pnpm tauri dev 一跑,src/lib/ipc.ts 自动生成:
// ipc.ts (auto-generated)
export const commands = {
async add(a: number, b: number): Promise<number> { return invoke("add", { a, b }); },
async play(songId: number): Promise<void> { return invoke("play", { songId }); },
// ...
};
前端直接 commands.add(1,2) —— 改 Rust 签名编译器立刻报错。这是 CloudTone 生产级项目的标配。
七、invoke 的小魔法
取消
Tauri 2 的 invoke 目前不原生支持取消——但你可以通过 Channel + AbortController 模拟。
超时
封一个工具:
export async function invokeWithTimeout<T>(cmd: string, args: any, ms = 10000): Promise<T> {
return Promise.race([
invoke<T>(cmd, args),
new Promise<T>((_, r) => setTimeout(() => r(new Error("timeout")), ms)),
]);
}
进度反馈
对长任务(扫库、下载),用 Events 或 Channel 往前端推进度,invoke 只负责启停。
常见陷阱
1. 前端
invoke("play", { song_id: 1 }),后端收不到用 camelCase:
{ songId: 1 }。
2.
Result<(), AppError>在 JS 里的 resolved value 是null
()会被序列化成null。前端判空用=== null或忽略返回值。
3. 返回很大的
Vec<u8>,内存爆流式用 Channel;或者落盘后返回 path,前端用
asset://协议读。
4. 命令没注册:
Command x not found忘记写到
generate_handler![...]。
5.
State<'_, T>里的T没Send + SyncRust 会大段报错。常见原因:T 里放了
Rc、RefCell;改成Arc<Mutex<_>>。
本章小结
#[tauri::command]是前后端桥梁。camelCase/snake_case自动转换。AppError+serde::Serialize让错误结构化。tauri-specta让前端类型安全,是生产级项目必备。
动手时刻
在你的 hello 项目里:
-
加一个
get_system_info命令返回结构体{ os: String, arch: String, memory_mb: u64 },前端展示。 - 让它带上错误路径(模拟 NotFound),前端 toast 显示。
-
接入
tauri-specta,生成ipc.ts后在前端用。
下一章,讲 Events:事件总线、订阅取消、与 Channel 的关系。