JS から Rust へイベントを送る (emit)

フロントエンドの emit / emitTo で発火したイベントを Rust 側の listen / once / listen_any で受け取り、ペイロードを serde でデシリアライズする。invoke との使い分けも整理する。

フロントエンド 対象: Tauri 2.x 更新日: 読了目安: 約8分 front-006
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 2. バックエンドから実装する (Rust)
  4. 送り元は分からない・ウィンドウ単位で受ける
  5. invoke との使い分け
  6. 動作確認
  7. よくあるエラーと対処法
  8. ペイロードが構造体にデシリアライズできない
  9. emitTo('main', ...) が app.listen に届かない
  10. イベント名でパニックする
  11. Permissions associated with this command: core:event:allow-emit という趣旨のエラー
  12. 注意点
  13. 関連レシピ

フロントエンドから emit でイベントを発火し、Rust 側で listen して受け取る方法を紹介します。invoke が「呼んで戻り値を待つ」リクエスト/レスポンス型なのに対し、イベントは投げっぱなしの一方向通知で、「画面の準備ができた」といった状態変化を Rust に知らせたり、複数ウィンドウと Rust に同報したりする用途に向きます。プラグインは不要で、@tauri-apps/api/event と Tauri 本体の Listener トレイトだけで実装できます。

前提条件

必要な権限は core:event:default にまとまっており、テンプレートの capabilities/default.json にある core:default が内包しています。core:default を外している場合は core:event:allow-emit と core:event:allow-emit-to を追加してください。

1. フロントエンドから実装する (TypeScript)

emit(event, payload?) は全リスナーへのブロードキャスト、emitTo(target, event, payload?) は宛先を絞った送信です。ペイロードは JSON 化できる値なら何でも渡せます。

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

interface EditorState {
  fileName: string;
  dirty: boolean;
  cursorLine: number;
}

// 全リスナー(Rust と全ウィンドウ)へブロードキャスト
export async function notifyEditorState(state: EditorState) {
  await emit<EditorState>('editor:state-changed', state);
}

// Rust 側(App / AppHandle のリスナー)だけに届ける
export async function notifyRustOnly(state: EditorState) {
  await emitTo({ kind: 'App' }, 'editor:state-changed', state);
}

// ラベル "settings" のウィンドウだけに届ける(文字列は AnyLabel 扱い)
export async function reloadSettings() {
  await emitTo('settings', 'settings:reload');
}

宛先は { kind: 'Any' | 'App' | 'AnyLabel' | 'Window' | 'Webview' | 'WebviewWindow', label?: string } か、ラベル文字列です。

2. バックエンドから実装する (Rust)

tauri::Listener を use すると App / AppHandle / Window / WebviewWindow / Webview で listen / once / listen_any / unlisten が使えます。event.payload() は JSON 文字列 (&str) なので serde_json::from_str で構造体に戻します(Cargo.toml に serde_json = "1" が必要)。

// src-tauri/src/lib.rs
use serde::Deserialize;
use tauri::Listener;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] // JS 側の fileName / cursorLine に合わせる
struct EditorState {
    file_name: String,
    dirty: bool,
    cursor_line: u32,
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            // emit()(宛先 Any)と { kind: 'App' } 宛の両方が届く
            app.listen("editor:state-changed", |event| {
                match serde_json::from_str::<EditorState>(event.payload()) {
                    Ok(state) => println!("[Rust] state = {state:?}"),
                    Err(e) => eprintln!("[Rust] payload parse error: {e}"),
                }
            });

            // 一度だけ受け取り、自動で解除される
            app.once("frontend:ready", |event| {
                println!("[Rust] frontend ready: {}", event.payload());
            });

            // 宛先に関係なくすべて拾う(emitTo('settings', ...) も届く)
            let id = app.listen_any("settings:reload", |event| {
                println!("[Rust] settings reload (id={})", event.id());
            });
            let _ = id; // 不要になったら app.unlisten(id)

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ハンドラは Send + 'static なクロージャで async にはできません。状態を触るなら app.handle().clone() を move で持ち込み、重い処理は tauri::async_runtime::spawn に逃がします。

送り元は分からない・ウィンドウ単位で受ける

Rust が受け取る Event には ID とペイロードしか入っておらず、どのウィンドウが送ったかは分かりません。送り元で処理を変えたいなら、JS 側でペイロードにウィンドウのラベルを入れるか、引数で WebviewWindow を受けられる invoke を使います。なお、ペイロードなしの emit('frontend:ready') は Rust では "null" という文字列で届きます。

ログのように形の決まっていないペイロードは、構造体を作らずに serde_json::Value で読めます。ラベルを指定した emitTo('main', ...) は、get_webview_window() で取得したそのウィンドウの listen で受けます。

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

// setup の中で register_extra_listeners(app.handle()) のように呼ぶ
fn register_extra_listeners(app: &AppHandle) {
    // 形の決まっていないペイロードは serde_json::Value で読む
    app.listen("front:log", |event| {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(event.payload()) else { return };
        let level = v["level"].as_str().unwrap_or("info");
        let from = v["from"].as_str().unwrap_or("?");
        println!("[{level}] {from}: {}", v["message"].as_str().unwrap_or(""));
    });

    // emitTo('main', ...) と emit() の両方が届く({ kind: 'App' } 宛ては届かない)
    if let Some(main) = app.get_webview_window("main") {
        main.listen("main:saved", |event| println!("[Rust] main saved: {}", event.payload()));
    }
}
import { emit, emitTo } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';

export async function sendLog(message: string) {
  // 送り元のラベルをペイロードに入れておく
  await emit('front:log', { level: 'info', message, from: getCurrentWebviewWindow().label });
}

export async function notifySaved(path: string) {
  await emitTo('main', 'main:saved', path);
}

invoke との使い分け

観点invokeemit
方向JS → Rust → JS(戻り値あり)JS → Rust(戻り値なし)
型・エラー型付き、Result が JS に伝わるJSON 文字列、失敗は握りつぶされる
宛先1 対 1複数ウィンドウ + Rust

「結果や失敗を知りたい」なら invoke、「知らせるだけ」なら emit が目安です。高頻度の大量データはどちらにも向かず、公式ドキュメントではチャネル (Channel) が案内されています。

動作確認

npm run tauri dev で起動し、notifyEditorState({ fileName: 'memo.txt', dirty: true, cursorLine: 12 }) と emit('frontend:ready', { at: Date.now() }) を呼ぶと、tauri dev を起動したターミナル(ブラウザのコンソールではない)に次のように出ます。frontend:ready を 2 回 emit しても once は 1 回しか反応しません。

[Rust] state = EditorState { file_name: "memo.txt", dirty: true, cursor_line: 12 }
[Rust] frontend ready: {"at":1757577600000}

よくあるエラーと対処法

ペイロードが構造体にデシリアライズできない

missing field を含むエラーは、JS のキー(fileName)と Rust のフィールド名(file_name)の食い違いです。#[serde(rename_all = "camelCase")] を付け、省略され得るフィールドは Option<T> にしてください。

emitTo('main', ...) が app.listen に届かない

宛先を AnyLabel や WebviewWindow に絞ったイベントは App / AppHandle のリスナーには配送されません。そのウィンドウの WebviewWindow で listen するか、宛先を問わない listen_any を使います。

イベント名でパニックする

Rust 側の listen は、イベント名に英数字と - / : _ 以外の文字が含まれるとパニックします。ドットや空白は使えません。

Permissions associated with this command: core:event:allow-emit という趣旨のエラー

capabilities から core:default(または core:event:default)が抜けています。前提条件の権限を追加してください。

注意点

  • OS による挙動の差はありません。
  • リスナーは listen を呼んだ時点から有効です。JS が起動直後に emit するなら、Rust 側は setup 内で登録しておかないと取りこぼします。
  • ハンドラは同期的に呼ばれます。ファイル書き込みや HTTP を直接書くと配送が滞るので非同期タスクへ逃がしてください。
  • Uint8Array などのバイナリは JSON 化されて非効率です。バイナリは invoke の引数で渡す方が速いです。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する