Rust in Everything
基于 tauri 2.12.5

监听器注册之前发的事件丢了

主线位置:事件主线的第一步,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 从那里开始推;一次性的状态用命令拉取,不用事件。

💬 关联讨论

发起讨论
加载中...