フロントエンドから 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 との使い分け
| 観点 | invoke | emit |
|---|---|---|
| 方向 | 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の引数で渡す方が速いです。
