监听器注册之前发的事件丢了
主线位置:事件主线的第一步,
AppManager::emit决定把事件eval进哪些 webview;和它竞速的是 JS 的listen,后者本身是一次调用主线(plugin:event|listen)。emit的两段分发见 AppManager 与 Manager trait的「事件:emit 先送 JS,再调 Rust 监听器」。
sequenceDiagram participant S as Rust setup 和页面加载 participant M as Listeners 表 participant P as 页面 S->>M: emit boot 表里没有 跳过 S->>M: emit page-finished 表里没有 跳过 P->>M: invoke plugin:event listen 登记 Note over S,P: 之前的事件不会补发 P->>S: invoke ready S->>P: emit boot 这次 eval 进页面
实验 crate exp-tauri/pit3-event:从模板 base 复制,withGlobalTauri: true,能力给 main 窗口 core:default(listen 要 core:event:allow-listen)。setup 里模拟「后端初始化完成」,存一个状态并发 boot 事件;on_page_load 在页面开始加载和加载完成时各发一个事件;每次 emit 都打印返回值和时间:
.setup(move |app| {
// pretend the backend finished loading something
*app.state::<Boot>().status.lock().unwrap() = "db opened".into();
let r = app.emit("boot", "db opened");
log(start, format!("setup: emit boot -> {r:?}"));
Ok(())
})
.on_page_load(move |webview, payload| {
let event = match payload.event() {
PageLoadEvent::Started => "page-started",
PageLoadEvent::Finished => "page-finished",
};
let r = webview.emit(event, payload.url().as_str());
log(start, format!("on_page_load: emit {event} -> {r:?}"));
})
页面一开始就对三个事件 listen,等它们都登记完,把收到的东西报回来:
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
const got = [];
(async () => {
for (const name of ['boot', 'page-started', 'page-finished']) {
await listen(name, (e) => got.push(`${name}: ${e.payload}`));
}
got.push('-- listeners registered');
if (location.hash !== '#fix') {
await invoke('report', { msg: JSON.stringify(got) });
return;
}
// ...
})();
tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,debug 构建,平台 M2 Max、macOS 27、rustc 1.99.0。
现象
342ms setup: emit boot -> Ok(())
483ms on_page_load: emit page-started -> Ok(())
484ms on_page_load: emit page-finished -> Ok(())
550ms REPORT ["-- listeners registered"]
三个事件一个都没收到,三次 emit 都返回 Ok(())。又连跑三次,结果相同,时间略有出入(page-finished 在 373–493 ms,报告在 436–495 ms),page-finished 总在页面登记完监听器之前发出。
setup里的boot:配置里的窗口这时已经建好(窗口在用户的setup之前创建),但页面还没加载,更谈不上监听。page-started:页面刚开始加载,脚本还没执行。page-finished:最容易让人以为「这时候页面准备好了」。小页面的load来得很快,而listen是一次异步的 IPC 往返,页面的脚本已经在跑、监听器却还没登记进 Rust。页面大一些、脚本多一些,两者的先后就可能反过来,于是这种代码表现为「偶尔收不到」。
出处
AppManager::emit 先 emit_js 再调 Rust 监听器。emit_js 落到 Listeners::emit_js_filter:
let event = &emit_args.event;
let js_listeners = self.inner.js_event_listeners.lock().unwrap();
webviews.try_for_each(|webview| {
if let Some(handlers) = js_listeners.get(webview.label()).and_then(|s| s.get(event)) {
let ids = handlers
.iter()
.filter(|handler| match_any_or_filter(&handler.target, &filter))
.map(|handler| handler.id)
.collect::<Vec<_>>();
webview.emit_js(emit_args, &ids)?;
}
Ok(())
})
查的是 js_event_listeners 这张表:哪个 webview 为这个事件登记过监听器,就给哪个 webview eval 一段分发脚本;表里没有,就什么也不做。这里没有「等页面准备好再送」的队列,事件也不会被记下来留给以后登记的监听器。没有接收者不算错误,emit 返回 Ok(())。
表是 JS 的 listen 填的。@tauri-apps/api 的 listen 是一次 invoke('plugin:event|listen', ..),Rust 侧的 listen 命令是 async fn,在 async_runtime 上调 Webview::listen_js,后者先 eval 一段脚本在页面里登记回调,再把 (事件, id) 插进表里。从页面执行 listen(..) 到表里有这一行,中间隔着一次完整的 IPC 往返;这段时间里发的事件,和页面加载之前发的一样,直接丢。
修法
推送的前提是对方在听,而「对方在听」只有页面自己知道。两种写法,同一个页面里各用一次(窗口 URL 带 #fix):
// fix 1: tell Rust we are listening, then let it emit
await invoke('ready');
// fix 2: pull the current state instead of waiting for a push
got.push(`boot_status(): ${await invoke('boot_status')}`);
await invoke('report', { msg: JSON.stringify(got) });
#[tauri::command]
fn ready(app: tauri::AppHandle, boot: tauri::State<'_, Boot>) {
log(boot.start, "command ready: page says its listeners are registered");
let status = boot.status.lock().unwrap().clone();
let r = app.emit("boot", status);
log(boot.start, format!("emit boot (after ready) -> {r:?}"));
}
#[tauri::command]
fn boot_status(boot: tauri::State<'_, Boot>) -> String {
boot.status.lock().unwrap().clone()
}
334ms setup: emit boot -> Ok(())
397ms on_page_load: emit page-started -> Ok(())
398ms on_page_load: emit page-finished -> Ok(())
461ms command ready: page says its listeners are registered
461ms emit boot (after ready) -> Ok(())
462ms REPORT ["-- listeners registered","boot: db opened","boot_status(): db opened"]
- 页面登记完再通知 Rust。
await listen(..)返回时,监听器已经在表里;之后调ready,Rust 在命令里emit,事件一定送得到。适合「之后还会持续推送」的场景:ready是推送的起点。 - 用命令拉取当前状态。初始化完成这类「状态」,不该做成一次性的事件:Rust 把它存进
State,页面启动时用命令取一次,之后的变化再用事件推。命令的返回值走调用主线,不存在「对方还没在听」的问题。 invoke('ready')兑现和boot事件到达是两条独立的路(IPC 响应和eval),tauri-runtime-wry 与事件循环讲过两者之间没有顺序保证。本例里事件先到了,但代码不应该依赖「ready返回时事件已经收到」。
怎么确认没中招
- 搜
setup、on_page_load、on_webview_ready、插件setup里的emit/emit_to:在页面还没跑起来的时刻发出、又只发一次的事件,都会丢。 emit的返回值不说明有没有人收到。需要确认送达就让页面回一个命令,或者改成命令拉取。- 测的时候故意放大页面(多加载几个脚本)或把
listen放到别的await之后,看事件还收不收得到。本页的实验每次都丢;时机侥幸对上的代码,换一台慢机器就会丢。
小结
emit只eval进已经登记了 JS 监听器的 webview,不排队、不重放,没有接收者也返回Ok(())。- JS 的
listen是一次异步 IPC,登记完成之前发的事件丢失;setup里、on_page_load的Finished里发的事件都赶不上。 - 页面登记完监听器再调一个
ready命令,由 Rust 从那里开始推;一次性的状态用命令拉取,不用事件。