事件系统:Rust 主动通知前端

命令解决「前端问、Rust 答」,事件解决「Rust 说、前端听」。后台下载进度、外部设备接入、定时任务完成这类场景,前端不可能一直轮询,事件系统正是为此设计。本篇讲事件的双向用法、订阅管理与典型场景实现。

一、事件的基本用法

Rust 端发送事件使用 app_handle.emit:

use tauri::{AppHandle, Emitter};

#[tauri::command]
async fn start_task(app: AppHandle) {
    for i in 1..=10 {
        std::thread::sleep(std::time::Duration::from_secs(1));
        app.emit("progress", i * 10).unwrap();
    }
}

前端订阅:

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

const unlisten = await listen('progress', (event) => {
  console.log(`进度:${event.payload}%`)
})

// 不再需要时取消订阅
unlisten()

事件载荷依然是 JSON 序列化的,event.payload 直接是前端可用的数据。注意 listen 返回的是一个取消订阅函数,类型为异步,需要 await。

对比项 invoke 命令 事件
方向 前端到 Rust Rust 到前端,也可反向
触发者 前端 任意一端
返回值 有,Promise 无,回调接收
适用场景 请求响应 状态推送与通知

二、事件的定向发送

全局 emit 会发给所有窗口。如果只想通知某个窗口,可以拿到对应的 WebviewWindow 再 emit:

use tauri::{WebviewWindow, Emitter};

fn notify_window(window: &WebviewWindow) {
    window.emit("file-saved", "data.json").unwrap();
}

前端到 Rust 的事件同样支持,Rust 端用 listen 方法订阅,常用作逆向通知场景,例如前端通知 Rust 做一次低频的全量同步。具体 API 以官方文档为准。

三、订阅管理与内存泄漏

事件系统最常见的坑是忘记取消订阅。在单页应用里,组件卸载后监听器仍然存在,重复进入组件会叠加订阅,同一事件被处理多次。Vue 与 React 的规范写法:

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

const unlisten = await listen('progress', (e) => {
  console.log(e.payload)
})

onBeforeUnmount(() => {
  unlisten()
})
框架 取消订阅时机
Vue onBeforeUnmount
React useEffect 返回清理函数
Svelte 组件销毁回调

养成「注册与取消成对出现」的习惯,调试事件重复触发问题时,先怀疑订阅泄漏。

四、典型场景:带进度的后台任务

把命令与事件组合,就能实现完整的后台任务模式。前端 invoke 启动任务,命令内部异步执行并不断 emit 进度,完成后 emit 结束事件:

use tauri::{AppHandle, Emitter};

#[tauri::command]
async fn download(app: AppHandle, url: String) -> Result<(), String> {
    for step in 1..=5 {
        std::thread::sleep(std::time::Duration::from_millis(500));
        app.emit("download-progress", step * 20).unwrap();
    }
    app.emit("download-done", ()).unwrap();
    Ok(())
}

前端同时维护进度条与完成状态,代码结构比轮询清晰得多。这个模式的完整异步写法见「异步命令与后台任务」,窗口间的状态共享见「全局状态:tauri::State 与共享数据」。

五、事件命名与规范

事件多了之后需要约定命名,建议用「域名加动作」格式,例如 download:progress、sync:complete,并在项目里维护一份事件清单文档。载荷结构也建议统一,比如总是包一层 { status, data },方便前端写通用处理函数。

事件系统的设计与浏览器扩展的消息机制很相似,做过扩展开发的同学可以对照 浏览器扩展指南 理解。Rust 端事件枚举与字符串管理,配合 正则 mastery 做批量重命名也很方便,Rust 语法基础见 Rust 入门。

六、事件系统的可靠投递

事件是「发后不管」的,它不像命令那样有确认机制。设计关键链路时要想清楚丢事件的影响。三种增强手段:

手段 做法 适用
序列号校验 载荷带递增序号,前端检测跳号 要求顺序的场景
心跳确认 前端定期检查最后事件时间 判断后台是否存活
关键事件补 command 询问 事件只做通知,数据用命令拉取 必须准确的数据

第三种是最常用的模式:事件负责「喊一嗓子」,前端收到后用 invoke 拉完整数据。这样即使事件丢了,下次任何事件触发时数据也会自动修正;而事件重发也不会导致数据重复处理。把通知与数据分离,可靠性问题自然消解大半。

事件系统与命令系统的分工再强调一次:命令用于请求,事件用于推送。设计接口时先问自己「这是前端发起还是 Rust 发起」,答案直接决定用哪一套。常见误用是把事件当命令的返回值用,或者反过来轮询命令模拟事件,两者都说明通信模型没有理清。把这两条通道的设计在文档里写清楚,团队成员读代码会轻松很多。

再补一个协作层面的建议:事件清单与命令清单放在同一份文档里维护,按命名空间分组。新成员接手项目时,这份文档几乎就是全部接口语义。文档与代码不一致是常态而非例外,因此把它纳入 code review 的必查项,虽然繁琐但回报极高——事件系统最怕的就是没有人说得清某个事件是谁发的、谁在收。

再给两条排错经验。其一,事件监听不生效时先检查事件名拼写,再检查订阅代码是否真的执行到,最后检查 Capability 是否声明了事件相关权限。其二,若只有部分窗口收到事件,多半是 emit 作用域问题,全局与定向行为不同,对照本篇第二节确认。事件排错本质沿「发送方、通道、接收方」三段逐段验证,习惯这个流程后定位会很快。

排查顺序 检查点
一 事件名拼写
二 订阅代码是否执行
三 权限是否声明
四 emit 作用域

关于事件还有一个设计层面的建议:把事件当作「系统的神经信号」而不是「数据的运输车」。神经信号负责通知变化,具体数据让前端用命令拉取。这个「事件轻、命令重」的分工能让整个应用的数据流保持单向与可预测,调试时只需追两类调用,而不是面对满天飞的事件载荷。它还顺带解决了重复消费与乱序问题:通知可以重复,数据查询天然幂等。把这条原则写进团队的接口规范,半年后回看会感谢此刻的自己。

顺带一句提示:调试事件时可以在 Rust 端订阅一份相同的事件打印日志,两端同事核对,排查「发了没收到」这类问题会快很多。工具虽朴素,效果立竿见影,建议直接固化进项目的调试技巧清单。

延伸阅读

  • 异步命令与后台任务——事件推送的生产者实现
  • 窗口管理:创建、多窗口与装饰——定向事件的目标
  • 浏览器扩展指南:相似消息模型的对照学习