Chapter 05

事件系统

通过 emit/listen 实现 Rust 与前端的双向实时通信

Commands vs Events

Tauri 提供两种前后端通信机制,适用于不同场景:

Commands(invoke)
前端主动调用 Rust,请求-响应模式(类似函数调用)。适合:查询数据、执行操作并等待结果。特点:有明确的返回值,支持错误处理。
Events(emit/listen)
任意一方可以主动推送消息给另一方,发布-订阅模式。适合:进度更新、状态变化通知、后台任务完成通知、多窗口广播。特点:异步推送,无需等待。

前端 → Rust:前端发送事件

import { emit, emitTo } from '@tauri-apps/api/event';

// 向所有监听者广播事件(前端和 Rust 都能收到)
await emit('user-action', {
  type: 'click',
  target: 'button-save',
  timestamp: Date.now(),
});

// 发送给特定窗口
await emitTo('settings-window', 'config-changed', {
  theme: 'dark',
});

Rust → 前端:Rust 推送事件

use tauri::{AppHandle, Manager, Emitter};

// 在 Command 中获取 AppHandle 并发送事件
#[tauri::command]
async fn start_download(app: AppHandle, url: String) -> Result<(), String> {
    // 克隆 handle,在 spawn 中使用
    let app_clone = app.clone();

    tokio::task::spawn(async move {
        for progress in 0..=100 {
            tokio::time::sleep(Duration::from_millis(50)).await;

            // 向前端发送进度事件
            app_clone.emit("download-progress", serde_json::json!({
                "progress": progress,
                "url": &url,
                "done": progress == 100,
            })).unwrap();
        }
    });

    Ok(())
}

前端监听事件

import { listen, once } from '@tauri-apps/api/event';

// 持续监听事件(返回取消监听的函数)
const unlisten = await listen<{ progress: number; done: boolean }>(
  'download-progress',
  (event) => {
    console.log('Progress:', event.payload.progress);
    if (event.payload.done) {
      console.log('Download complete!');
      unlisten(); // 完成后取消监听
    }
  }
);

// 只监听一次(自动取消)
const payload = await new Promise(resolve => {
  once('task-complete', (event) => resolve(event.payload));
});

进度反馈完整示例

Rust 端:文件压缩并报告进度

#[derive(serde::Serialize, Clone)]
struct CompressProgress {
    file: String,
    current: usize,
    total: usize,
    percent: f64,
}

#[tauri::command]
async fn compress_files(
    app: AppHandle,
    files: Vec<String>,
    output: String,
) -> Result<(), String> {
    let total = files.len();

    for (i, file) in files.iter().enumerate() {
        // 执行压缩逻辑(省略)
        compress_one_file(file, &output)?;

        // 发送进度
        app.emit("compress-progress", CompressProgress {
            file: file.clone(),
            current: i + 1,
            total,
            percent: (i + 1) as f64 / total as f64 * 100.0,
        }).map_err(|e| e.to_string())?;
    }

    // 完成事件
    app.emit("compress-done", &output).unwrap();
    Ok(())
}

前端 React 组件

import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

interface Progress {
  file: string;
  current: number;
  total: number;
  percent: number;
}

function Compressor() {
  const [progress, setProgress] = useState<Progress | null>(null);
  const [done, setDone] = useState(false);

  useEffect(() => {
    let unlistenProgress: (() => void) | undefined;
    let unlistenDone: (() => void) | undefined;

    (async () => {
      // 注册监听器
      unlistenProgress = await listen<Progress>(
        'compress-progress',
        (e) => setProgress(e.payload)
      );
      unlistenDone = await listen('compress-done', () => setDone(true));
    })();

    // 组件卸载时取消监听(防止内存泄漏)
    return () => {
      unlistenProgress?.();
      unlistenDone?.();
    };
  }, []);

  async function startCompress() {
    setDone(false);
    await invoke('compress_files', {
      files: ['/tmp/a.txt', '/tmp/b.txt'],
      output: '/tmp/archive.zip',
    });
  }

  return (
    <div>
      <button onClick={startCompress}>开始压缩</button>
      {progress && (
        <div>
          <p>{progress.file} ({progress.current}/{progress.total})</p>
          <progress value={progress.percent} max="100" />
        </div>
      )}
      {done && <p>压缩完成!</p>}
    </div>
  );
}

窗口特定事件

use tauri::{Manager, WindowEvent};

// 监听特定窗口的系统事件
pub fn run() {
    tauri::Builder::default()
        .on_window_event(|window, event| {
            match event {
                WindowEvent::CloseRequested { api, .. } => {
                    // 拦截关闭请求,显示确认对话框
                    api.prevent_close();
                    let window = window.clone();
                    tauri::async_runtime::spawn(async move {
                        // 发事件给前端,让前端显示确认弹窗
                        window.emit("close-requested", ()).unwrap();
                    });
                }
                WindowEvent::Focused(focused) => {
                    println!("Window focused: {}", focused);
                }
                _ => {}
            }
        })
        .run(tauri::generate_context!())
        .unwrap();
}
记得取消监听

listen() 返回的取消函数必须在组件卸载时调用,否则会造成内存泄漏——即使组件已销毁,事件回调仍会被触发。在 React 中放在 useEffect 的 cleanup 函数中,在 Vue 中放在 onUnmounted 钩子中。

事件 vs Commands 的选择指南

Commands(invoke)
前端主动调用 Rust 函数,等待返回值。适合:请求-响应模式(查询数据、执行操作)、需要错误处理的操作、一次性操作(打开文件、保存设置)。
Events(emit/listen)
单向通知,可以从任一方向发送,不等待响应。适合:长时间运行的进度更新、Rust 主动推送通知(网络状态、后台任务完成)、多窗口间通信。
场景推荐方式原因
读取文件内容Command (invoke)需要返回值,有错误处理
下载进度更新Event (emit from Rust)多次单向推送
用户点击按钮触发操作Command (invoke)等待操作结果
系统通知(新消息)Event (Rust → Frontend)Rust 主动推送
多窗口数据同步Event (broadcast)一对多广播
获取应用设置Command (invoke)一次性请求-响应

全局事件与窗口事件

Tauri 2.0 中事件分为全局事件(所有窗口可接收)和窗口事件(只有特定窗口接收):

import { emit, listen, emitTo } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';

// 全局广播:所有窗口都收到
await emit('settings-changed', { theme: 'dark' });

// 发送给特定窗口
await emitTo('settings', 'theme-update', { theme: 'dark' });

// 发送给当前窗口自己
const currentWindow = getCurrentWindow();
await currentWindow.emit('internal-update', { data: 'value' });

// 监听全局事件
const unlisten = await listen<{ theme: string }>('settings-changed', (event) => {
  console.log('新主题:', event.payload.theme);
  console.log('来自窗口:', event.windowLabel);
});

// 组件卸载时取消监听
return () => unlisten();

Rust 端监听前端事件

在 Rust 中订阅事件

不只是 Rust 向前端发事件,前端也可以发事件让 Rust 订阅。这在需要持续监控前端状态(如用户活动检测)时很有用:

use tauri::{AppHandle, Manager, Listener};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            // 在 setup 中注册全局事件监听
            app.listen("user-action", |event| {
                let payload = event.payload();
                println!("收到前端事件: {}", payload);
            });

            // 只监听一次
            app.once("app-ready", |_event| {
                println!("前端已就绪");
            });

            Ok(())
        })
        .run(tauri::generate_context!())
        .unwrap();
}
app.listen(event, handler)
注册全局事件监听器,返回 EventId 可用于 app.unlisten(id) 取消。handler 在 Rust 线程中执行,注意不要执行耗时操作(应 spawn 异步任务)。
app.once(event, handler)
只监听一次,触发后自动移除。适合等待初始化完成事件。
window.listen(event, handler)
只监听来自特定窗口的事件,比全局监听更精确,避免窗口间事件混淆。

Channel:流式数据传输

Channel 的使用场景

Tauri 2.0 引入了 Channel API,专为从 Rust 向前端流式传输大量数据而设计。与 Event 的区别是:Event 是广播(任何人都能收),Channel 是一对一的直接通道,更高效且有序。

use tauri::{AppHandle, ipc::Channel};
use serde::Serialize;

#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
enum ProgressEvent {
    Started { total: usize },
    Progress { current: usize, item: String },
    Completed { output_path: String },
    Error { message: String },
}

#[tauri::command]
async fn process_files(
    files: Vec<String>,
    on_progress: Channel<ProgressEvent>,  // Channel 直接作为参数接收
) -> Result<(), String> {
    let total = files.len();

    // 发送开始事件
    on_progress.send(ProgressEvent::Started { total })
        .map_err(|e| e.to_string())?;

    for (i, file) in files.iter().enumerate() {
        // 模拟处理
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;

        on_progress.send(ProgressEvent::Progress {
            current: i + 1,
            item: file.clone(),
        }).map_err(|e| e.to_string())?;
    }

    on_progress.send(ProgressEvent::Completed {
        output_path: "/tmp/result".to_string(),
    }).map_err(|e| e.to_string())?;

    Ok(())
}
import { invoke } from '@tauri-apps/api/core';
import { Channel } from '@tauri-apps/api/core';

// 创建 Channel 并传给 Rust Command
const channel = new Channel<{
  event: 'started' | 'progress' | 'completed' | 'error';
  data: any;
}>();

// 注册消息处理器
channel.onmessage = (msg) => {
  switch (msg.event) {
    case 'started':
      console.log(`开始处理 ${msg.data.total} 个文件`);
      break;
    case 'progress':
      console.log(`处理中 [${msg.data.current}]: ${msg.data.item}`);
      break;
    case 'completed':
      console.log(`完成!输出: ${msg.data.outputPath}`);
      break;
  }
};

// 调用 Command 并传入 Channel
await invoke('process_files', {
  files: ['/tmp/a.txt', '/tmp/b.txt'],
  onProgress: channel,  // camelCase 对应 Rust 的 on_progress
});
Channel vs Event 的选择

Channel 是 Tauri 2.0 新增的 API,专为流式数据传输优化:Channel 适合一次性的流式传输(一个 invoke 对应一个 Channel),数据有序、一对一、高效;Event 适合持续的广播通知(多个订阅者),或 Rust 主动发起的推送(不需要前端先调用 invoke)。进度反馈两种都可以,Channel 更简洁;系统级通知(新消息来了、网络断开)用 Event 更自然。

事件调试技巧

import { listen } from '@tauri-apps/api/event';

// 开发时:监听所有事件(用于调试)
if (import.meta.env.DEV) {
  listen('*', (event) => {
    console.log(`[Event] ${event.event}`, event.payload);
  });
}

// 查看事件元数据
const unlisten = await listen('my-event', (event) => {
  console.log('事件名:', event.event);
  console.log('来源窗口:', event.windowLabel);
  console.log('负载:', event.payload);
});
事件系统的常见陷阱

本章小结

本章核心要点