4.2 IPC:命令、事件与数据流动

原文链接: Inter-Process Communication 与 Calling Rust from the Frontend

4.2 IPC:命令、事件与数据流动

IPC 是 Tauri 的生命线。前端和 Rust 进程之间不能直接共享内存或直接调用函数,而是通过 异步消息传递 通信。

sequenceDiagram
    participant FE as 前端 WebView
    participant IPC as Tauri IPC
    participant RS as Rust 核心进程

    FE->>IPC: invoke("greet", { name: "小明" })
    IPC->>RS: 序列化并路由到 greet 命令
    RS-->>IPC: 返回结果
    IPC-->>FE: Promise resolve

消息传递比直接共享内存更安全,因为接收方可以拒绝请求。例如 Rust 端发现某个调用没有通过权限检查,可以直接丢弃。

Commands:前端“打电话”给 Rust

Commands 是“一问一答”型 IPC,适合获取数据、执行计算。它类似浏览器里的 fetch,但目的地是你的 Rust 函数。

第一步:在 Rust 定义命令

1
2
3
4
5
#[tauri::command]
fn greet(name: &str) -> String {
    // 拼接问候语并返回给前端
    format!("你好,{}!", name)
}

第二步:注册命令

在 lib.rs 的 Builder 中加入 .invoke_handler(tauri::generate_handler![...]):

1
2
3
4
5
6
7
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("运行 Tauri 应用时出错");
}

第三步:前端调用

安装官方 API:

1
npm install @tauri-apps/api

然后调用:

1
2
3
4
import { invoke } from '@tauri-apps/api/core';

const message = await invoke<string>('greet', { name: '小明' });
console.log(message);

命令变多时:拆到独立文件

所有命令都写在 lib.rs 会让文件迅速膨胀。可以拆到 src-tauri/src/commands.rs:

1
2
3
4
5
6
// src-tauri/src/commands.rs

#[tauri::command]
pub fn greet(name: &str) -> String {
    format!("你好,{}!", name)
}

然后在 lib.rs 中声明模块并注册:

1
2
3
4
5
6
7
8
9
mod commands;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![commands::greet])
        .run(tauri::generate_context!())
        .expect("运行 Tauri 应用时出错");
}

注意一个容易踩的坑:

  • 命令直接写在 lib.rs 时,不要标 pub;
  • 命令拆到独立模块后,必须标 pub,并在 generate_handler! 中写完整路径 commands::greet。

无论放哪里,命令名都只取函数名本身(例如 greet),前端调用时不需要写 commands:: 前缀。

参数命名:camelCase

JavaScript 对象默认使用 camelCase,Rust 函数参数使用 snake_case,Tauri 会自动映射:

1
2
3
4
5
#[tauri::command]
fn save_note(note_id: u32, content: String) {
    // 参数在 JSON 中是 noteId 与 content
    println!("保存 {} 号笔记:{}", note_id, content);
}

前端:

1
invoke('save_note', { noteId: 7, content: '买牛奶' });

如果想强制前端也使用 snake_case,可以给命令加属性:

1
2
#[tauri::command(rename_all = "snake_case")]
fn save_note(note_id: u32, content: String) {}

返回值与错误处理

返回值可以是任何能序列化为 JSON 的类型。需要表达失败时,返回 Result:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#[tauri::command]
fn login(user: String, password: String) -> Result<String, String> {
    if user == "admin" && password == "123456" {
        // 验证成功
        Ok("logged_in".into())
    } else {
        // 验证失败
        Err("用户名或密码错误".into())
    }
}

前端:

1
2
3
invoke('login', { user, password })
  .then((session) => console.log(session))
  .catch((error) => console.error(error));

经验:不要把标准库错误类型直接返回给前端,因为很多错误类型没有实现 serde::Serialize。简单场景先 map_err(|e| e.to_string()),复杂场景自定义一个可序列化的错误枚举。

async 命令

普通(非 async)命令默认在 Tauri 核心进程的主线程上执行。如果同步命令里写很重的计算或 sleep,窗口管理、IPC 等仍可能被拖慢。

耗时操作应声明为 async,让 Tauri 把它调度到异步运行时:

1
2
3
4
5
#[tauri::command]
async fn do_heavy_work() -> String {
    some_async_function().await;
    "完成".into()
}

async 命令在 Tauri 的异步运行时里执行。注意:异步命令参数中尽量避免借用类型(如 &str、State<'_, T>),可改用 String 或让返回类型包含 Result。

如果你的函数因历史原因不能写成 async fn,也可以使用 #[tauri::command(async)] 属性,让同步函数体在异步任务中执行。

Events:更像“群发广播”

Events 是单向、即发即忘的消息,适合通知状态变化,例如“下载完成”“登录成功”。

flowchart LR
    RS["Rust"] -- "emit 广播" --> E["事件总线"]
    E --> W1["窗口 main"]
    E --> W2["窗口 settings"]

Rust 发送全局事件

1
2
3
4
5
6
7
use tauri::{AppHandle, Emitter};

#[tauri::command]
fn download_finished(app: AppHandle, file_name: String) {
    // 向所有监听者广播
    app.emit("download-finished", &file_name).unwrap();
}

前端也可以发出事件,其他窗口与 Rust 监听者都能收到:

1
2
3
4
import { emit } from '@tauri-apps/api/event';

// 例如通知其他窗口主题已切换
await emit('theme-changed', 'dark');

只想发给某个具体窗口时,可使用 emitTo:

1
2
3
4
import { emitTo } from '@tauri-apps/api/event';

// 只通知 label 为 "settings" 的窗口
await emitTo('settings', 'settings-update-requested', { key: 'theme', value: 'dark' });

只发给特定窗口

1
2
3
4
5
6
7
use tauri::{AppHandle, Emitter};

#[tauri::command]
fn notify_login(app: AppHandle) {
    // 只发给 label 为 "login" 的窗口
    app.emit_to("login", "login-result", "success").unwrap();
}

前端监听事件

1
2
3
4
5
6
7
8
9
import { listen } from '@tauri-apps/api/event';

// listen 返回 Promise,解析后才是取消监听的函数
const unlisten = await listen<string>('download-finished', (event) => {
  console.log('下载完成:', event.payload);
});

// 组件卸载等时机记得取消,避免内存泄漏
unlisten();

在 React 里,通常放在 useEffect 的清理函数中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
useEffect(() => {
  // listen 返回 Promise,resolve 后才是真正的取消函数
  const unlisten = listen<string>('download-finished', (event) => {
    console.log('下载完成:', event.payload);
  });

  // 组件卸载时取消监听,即使 Promise 稍后才完成也会被清理
  return () => {
    unlisten.then((fn) => fn());
  };
}, []);

Commands 与 Events 怎么选

需求选什么
前端取数据、要返回值Command
前端触发一次动作并关注结果Command
Rust 主动通知前端Event
一个事件被多个窗口监听Event
高频、有序、大量数据流Channel(见 4.3 状态管理、事件进阶与 Channels)

需要权限吗?

  • 你自己注册的自定义命令默认允许所有窗口调用;
  • 官方核心 API 与插件命令默认被限制,需要在 capabilities 中放行;

完整规则见 4.5 权限系统与安全边界。

官方参考