Tauriの基礎 - 型付きイベントとパターン
概要
Tauriのイベントシステムを型安全に利用することで、RustバックエンドとReactフロントエンド間の通信におけるバグを事前に防止できる。
型付きイベントは、Rustの serde クレートによるシリアライゼーションと、TypeScriptの型システムを組み合わせて実現する。
型安全性の主なメリットは以下の通りである。
- コンパイル時のエラー検出
- 型の不一致やプロパティの誤字を開発時に発見できる。
- ランタイムエラーを減少させ、アプリケーションの安定性が向上する。
- 型の不一致やプロパティの誤字を開発時に発見できる。
- IDEの支援機能
- オートコンプリート、型推論、リファクタリング支援が有効になる。
- 開発効率が大幅に向上する。
- オートコンプリート、型推論、リファクタリング支援が有効になる。
- ドキュメントとしての役割
- 型定義自体がAPI仕様のドキュメントとして機能する。
- チーム開発でのコミュニケーションコストを削減できる。
- 型定義自体がAPI仕様のドキュメントとして機能する。
- リファクタリングの安全性
- 型定義を変更すると、影響範囲がコンパイルエラーとして明確になる。
- 安全なコード変更が可能である。
- 型定義を変更すると、影響範囲がコンパイルエラーとして明確になる。
型付きペイロードの定義
Rust側の型定義
Rustでイベントペイロードを型定義するには、serde::Serialize トレイトを導出する。
基本的な型定義の例を以下に示す。
use serde::Serialize;
// 基本的なイベントペイロード
#[derive(Clone, Serialize)]
pub struct DownloadStarted {
pub url: String,
pub download_id: u32,
pub content_length: u64,
}
// 進捗通知用のペイロード
#[derive(Clone, Serialize)]
pub struct DownloadProgress {
pub download_id: u32,
pub bytes_downloaded: u64,
pub percentage: f32,
}
// 完了通知用のペイロード
#[derive(Clone, Serialize)]
pub struct DownloadFinished {
pub download_id: u32,
pub success: bool,
pub error_message: Option<String>,
}
serde属性の活用
serde 属性を使用すると、フィールド名の変換や条件付きシリアライゼーションを制御できる。
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")] // フィールド名をcamelCaseに変換
pub struct UserSession {
pub user_id: u32, // -> userId
pub session_token: String, // -> sessionToken
pub expires_at: i64, // -> expiresAt
// 値がNoneの場合はフィールド自体を省略
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
}
// タグ付きバリアント (判別可能なunion)
#[derive(Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum FileEvent {
Created { path: String, size: u64 },
Modified { path: String, new_size: u64 },
Deleted { path: String },
}
TypeScript側の型定義
Rust側の型に対応するTypeScriptの型を定義する。
フィールド名は、Rust側で rename_all = "camelCase" を指定した場合、キャメルケース (camelCase) で記述する。
// ダウンロードイベントの型定義
interface DownloadStarted {
url: string;
downloadId: number;
contentLength: number;
}
interface DownloadProgress {
downloadId: number;
bytesDownloaded: number;
percentage: number;
}
interface DownloadFinished {
downloadId: number;
success: boolean;
errorMessage?: string; // オプション
}
// ユーザーセッションの型定義
interface UserSession {
userId: number;
sessionToken: string;
expiresAt: number;
refreshToken?: string; // オプション
}
// 判別可能なunion (tagged union)
type FileEvent =
| { type: 'created'; path: string; size: number }
| { type: 'modified'; path: string; newSize: number }
| { type: 'deleted'; path: string };
型定義の共有方法
RustとTypeScriptの型定義を手動で同期するのはエラーの原因となる。
以下に示す方法で型定義を共有することを推奨する。
- 手動同期 (小規模プロジェクト向け)
- RustとTypeScriptで同じ構造を維持し、変更時に両方を更新する。
- ts-rs クレートの使用
- Rustの型からTypeScriptの型定義を自動生成する。
- OpenAPI/JSON Schemaの使用
- スキーマファーストで型を管理する。
ts-rsクレートの使用例を以下に示す。
use serde::Serialize;
use ts_rs::TS;
// TSトレイトを導出するとTypeScriptの型定義が生成される
#[derive(Clone, Serialize, TS)]
#[ts(export)] // 自動的にファイルに出力
pub struct DownloadStarted {
pub url: String,
pub download_id: u32,
pub content_length: u64,
}
型安全なイベント送受信
型を指定したリッスン
listen 関数のジェネリクスを使用して、ペイロードの型を指定できる。
import { listen, UnlistenFn } from '@tauri-apps/api/event';
// 型を指定してリッスン
async function setupDownloadListener(): Promise<UnlistenFn> {
const unlisten = await listen<DownloadProgress>('download-progress', (event) => {
// event.payload は DownloadProgress 型として推論される
console.log(`進捗: ${event.payload.percentage.toFixed(1)}%`);
console.log(`ダウンロード済み: ${event.payload.bytesDownloadloaded} bytes`);
// 型エラー: 存在しないプロパティへのアクセス
// console.log(event.payload.invalidField); // コンパイルエラー
});
return unlisten;
}
Rustでの型付き送信
use tauri::{AppHandle, Emitter};
#[tauri::command]
fn start_download(app: AppHandle, url: String) -> Result<(), String> {
let download_id = 12345;
let content_length = 1024000;
// 型付きペイロードを送信
app.emit("download-started", DownloadStarted {
url,
download_id,
content_length,
}).map_err(|e| e.to_string())?;
Ok(())
}
Reactでのイベント購読パターン
useEffectでの基本パターン
Reactコンポーネントでイベントを購読する場合、useEffect フック内でリスナーを登録し、クリーンアップ関数で解除する。
import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
interface DownloadProgress {
downloadId: number;
percentage: number;
}
function DownloadIndicator() {
const [progress, setProgress] = useState<number>(0);
useEffect(() => {
// 非同期関数を定義してリスナーを設定
let unlisten: (() => void) | null = null;
const setupListener = async () => {
unlisten = await listen<DownloadProgress>('download-progress', (event) => {
setProgress(event.payload.percentage);
});
};
setupListener();
// クリーンアップ関数: コンポーネントのアンマウント時にリスナーを解除
return () => {
if (unlisten) {
unlisten();
}
};
}, []); // 依存配列が空 = マウント時に1回だけ実行
return (
<div>
<p>ダウンロード進捗: {progress.toFixed(1)}%</p>
</div>
);
}
即時実行パターン
useEffect 内で非同期関数を直接使用せず、即時実行関数を使用するパターンも一般的である。
import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
function StatusMonitor() {
const [status, setStatus] = useState<string>('idle');
useEffect(() => {
// 即時実行非同期関数 (IIFE)
const promise = (async () => {
const unlisten = await listen<string>('status-changed', (event) => {
setStatus(event.payload);
});
return unlisten;
})();
// クリーンアップ : Promise の結果を使ってリスナーを解除
return () => {
promise.then((unlisten) => unlisten());
};
}, []);
return <div>ステータス: {status}</div>;
}
複数イベントの購読
複数のイベントを購読する場合、全てのリスナーをクリーンアップする必要がある。
import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
interface DownloadEvent {
downloadId: number;
}
function DownloadManager() {
const [downloads, setDownloads] = useState<Map<number, number>>(new Map());
useEffect(() => {
const unlisteners: (() => void)[] = [];
const setupListeners = async () => {
// 開始イベント
const unlistenStart = await listen<DownloadEvent>('download-started', (event) => {
setDownloads((prev) => {
const next = new Map(prev);
next.set(event.payload.downloadId, 0);
return next;
});
});
unlisteners.push(unlistenStart);
// 進捗イベント
const unlistenProgress = await listen<DownloadEvent & { percentage: number }>(
'download-progress',
(event) => {
setDownloads((prev) => {
const next = new Map(prev);
next.set(event.payload.downloadId, event.payload.percentage);
return next;
});
}
);
unlisteners.push(unlistenProgress);
// 完了イベント
const unlistenFinish = await listen<DownloadEvent>('download-finished', (event) => {
setDownloads((prev) => {
const next = new Map(prev);
next.delete(event.payload.downloadId);
return next;
});
});
unlisteners.push(unlistenFinish);
};
setupListeners();
// 全てのリスナーを解除
return () => {
unlisteners.forEach((unlisten) => unlisten());
};
}, []);
return (
<ul>
{Array.from(downloads.entries()).map(([id, progress]) => (
<li key={id}>ダウンロード {id}: {progress}%</li>
))}
</ul>
);
}
カスタムフックの作成
基本的なカスタムフック
イベント購読をカプセル化したカスタムフックを作成すると、コンポーネント間でロジックを再利用できる。
import { useEffect, useCallback } from 'react';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
/**
* Tauriイベントを購読するカスタムフック
* @param eventName イベント名
* @param handler イベントハンドラ
*/
function useTauriEvent<T>(
eventName: string,
handler: (payload: T) => void
): void {
useEffect(() => {
let unlisten: UnlistenFn | null = null;
const setupListener = async () => {
unlisten = await listen<T>(eventName, (event) => {
handler(event.payload);
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
}, [eventName, handler]);
}
使用例
import { useState, useCallback } from 'react';
interface Notification {
title: string;
message: string;
timestamp: number;
}
function NotificationCenter() {
const [notifications, setNotifications] = useState<Notification[]>([]);
// イベントハンドラをuseCallbackでメモ化
const handleNotification = useCallback((payload: Notification) => {
setNotifications((prev) => [...prev, payload]);
}, []);
// カスタムフックを使用
useTauriEvent<Notification>('notification-received', handleNotification);
return (
<div>
<h2>通知</h2>
<ul>
{notifications.map((n, i) => (
<li key={i}>
<strong>{n.title}</strong>: {n.message}
</li>
))}
</ul>
</div>
);
}
戻り値付きカスタムフック
イベントデータを状態として管理するカスタムフックの例を以下に示す。
import { useState, useEffect, useCallback } from 'react';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
/**
* イベントデータを状態として管理するカスタムフック
* @param eventName イベント名
* @param initialValue 初期値
* @returns 最新のイベントデータ
*/
function useTauriEventState<T>(eventName: string, initialValue: T): T {
const [data, setData] = useState<T>(initialValue);
useEffect(() => {
let unlisten: UnlistenFn | null = null;
const setupListener = async () => {
unlisten = await listen<T>(eventName, (event) => {
setData(event.payload);
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
}, [eventName]);
return data;
}
// 使用例
function ConnectionStatus() {
// 最新の接続ステータスを取得
const status = useTauriEventState<{ connected: boolean; latency: number }>(
'connection-status',
{ connected: false, latency: 0 }
);
return (
<div>
ステータス: {status.connected ? '接続中' : '切断'}
{status.connected && <span> (遅延: {status.latency}ms)</span>}
</div>
);
}
メモリリーク防止
メモリリークの原因
Reactコンポーネントでイベントリスナーを適切に解除しないと、以下に示す問題が発生する。
- メモリ使用量の増加
- アンマウントされたコンポーネントのリスナーが残り続け、メモリが解放されない。
- 意図しない動作
- アンマウント済みコンポーネントの状態を更新しようとしてエラーが発生する。
- パフォーマンス低下
- 不要なリスナーがイベントを処理し続け、CPUリソースを消費する。
アンチパターン
// 悪い例 : クリーンアップがない
function BadComponent() {
const [data, setData] = useState(null);
useEffect(() => {
// クリーンアップ関数を返していない!
listen('data-updated', (event) => {
setData(event.payload);
});
}, []);
return <div>{JSON.stringify(data)}</div>;
}
// コンポーネントがアンマウントしてもリスナーが残り続ける
正しいパターン
// 良い例 : 適切なクリーンアップ
function GoodComponent() {
const [data, setData] = useState(null);
useEffect(() => {
let unlisten: (() => void) | null = null;
const setup = async () => {
unlisten = await listen('data-updated', (event) => {
setData(event.payload);
});
};
setup();
// クリーンアップ関数を返す
return () => {
if (unlisten) {
unlisten();
}
};
}, []);
return <div>{JSON.stringify(data)}</div>;
}
アンマウント後の状態更新エラー
コンポーネントがアンマウントされた後に状態を更新しようとすると、Reactが警告を出力する。
この問題を防ぐには、useRef でマウント状態を追跡する。
import { useEffect, useRef, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
function SafeComponent() {
const [data, setData] = useState(null);
const isMounted = useRef(true);
useEffect(() => {
let unlisten: (() => void) | null = null;
const setupListener = async () => {
unlisten = await listen('data-updated', (event) => {
// マウント状態を確認してから状態を更新
if (isMounted.current) {
setData(event.payload);
}
});
};
setupListener();
return () => {
isMounted.current = false; // アンマウントをマーク
if (unlisten) {
unlisten();
}
};
}, []);
return <div>{JSON.stringify(data)}</div>;
}
推奨される事柄
イベント名の定数化
イベント名を文字列リテラルで直接記述すると、タイポの原因となる。
イベント名は定数として定義して、RustとTypeScriptで共有することを推奨する。
// events.ts : イベント名の定数定義
export const EVENT_NAMES = {
DOWNLOAD_STARTED: 'download-started',
DOWNLOAD_PROGRESS: 'download-progress',
DOWNLOAD_FINISHED: 'download-finished',
FILE_CHANGED: 'file-changed',
USER_LOGGED_IN: 'user-logged-in',
} as const;
// 使用例
import { EVENT_NAMES } from './events';
listen(EVENT_NAMES.DOWNLOAD_PROGRESS, handler);
イベント型の集約
イベント名と型のマッピングを集約すると、型安全性がさらに向上する。
// events.ts : イベント型の集約定義
export interface EventMap {
'download-started': DownloadStarted;
'download-progress': DownloadProgress;
'download-finished': DownloadFinished;
'file-changed': FileEvent;
'user-logged-in': UserSession;
}
// 型安全なリッスン関数
export function listenTyped<K extends keyof EventMap>(
eventName: K,
handler: (payload: EventMap[K]) => void
): Promise<() => void> {
return listen<EventMap[K]>(eventName, (event) => {
handler(event.payload);
});
}
// 使用例
import { listenTyped } from './events';
// イベント名から型が自動推論される
listenTyped('download-progress', (payload) => {
// payloadは、自動的にDownloadProgress型になる
console.log(payload.percentage);
});
// 間違ったイベント名はコンパイルエラー
// listenTyped('invalid-event', handler); // エラー
エラー処理の実装
イベント処理でエラーが発生した場合、アプリケーション全体に影響しないように適切に処理する。
import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
function RobustComponent() {
const [data, setData] = useState(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let unlisten: (() => void) | null = null;
const setupListener = async () => {
unlisten = await listen('data-updated', async (event) => {
try {
// イベント処理
setError(null);
setData(event.payload);
}
catch (err) {
// エラーをキャッチして状態に反映
setError(err instanceof Error ? err.message : '不明なエラー');
console.error('イベント処理エラー:', err);
}
});
};
setupListener().catch((err) => {
// リスナー設定自体のエラー
setError('リスナーの設定に失敗しました');
console.error('リスナー設定エラー:', err);
});
return () => {
if (unlisten) {
unlisten();
}
};
}, []);
if (error) {
return <div className="error">エラー: {error}</div>;
}
return <div>{JSON.stringify(data)}</div>;
}
条件付きイベント購読
特定の条件下でのみイベントを購読するパターンを以下に示す。
import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
function ConditionalListener() {
const [isActive, setIsActive] = useState(false);
useEffect(() => {
// アクティブでない場合は何もしない
if (!isActive) {
return;
}
let unlisten: (() => void) | null = null;
const setupListener = async () => {
unlisten = await listen('status-update', (event) => {
console.log('ステータス更新:', event.payload);
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
}, [isActive]); // isActive が変更されたら再設定
return (
<button onClick={() => setIsActive(!isActive)}>
{isActive ? '無効化' : '有効化'}
</button>
);
}
サンプルコード
ファイルウォッチャー
ファイルシステムの変更を監視し、Reactコンポーネントに通知する例を以下に示す。
Rust (バックエンド)
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use std::collections::HashMap;
#[derive(Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum FileChange {
Created { path: String },
Modified { path: String, size: u64 },
Deleted { path: String },
}
#[tauri::command]
fn start_file_watcher(app: AppHandle, watch_path: String) -> Result<(), String> {
// 実際のファイル監視は notify クレート等を使用
// ここではシミュレーション
// ファイル作成イベント
app.emit("file-change", FileChange::Created {
path: format!("{}/new_file.txt", watch_path),
}).map_err(|e| e.to_string())?;
// ファイル変更イベント
app.emit("file-change", FileChange::Modified {
path: format!("{}/existing_file.txt", watch_path),
size: 1024,
}).map_err(|e| e.to_string())?;
Ok(())
}
TypeScript (フロントエンド)
import { useEffect, useState, useCallback } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
// ファイル変更イベントの型
type FileChange =
| { type: 'created'; path: string }
| { type: 'modified'; path: string; size: number }
| { type: 'deleted'; path: string };
// ファイル情報の型
interface FileInfo {
path: string;
status: 'created' | 'modified' | 'deleted';
size?: number;
timestamp: Date;
}
function FileWatcher({ watchPath }: { watchPath: string }) {
const [files, setFiles] = useState<FileInfo[]>([]);
// ファイル変更ハンドラ
const handleFileChange = useCallback((change: FileChange) => {
const fileInfo: FileInfo = {
path: change.path,
status: change.type,
size: 'size' in change ? change.size : undefined,
timestamp: new Date(),
};
setFiles((prev) => [fileInfo, ...prev].slice(0, 100)); // 最新100件
}, []);
// イベントリスナーの設定
useEffect(() => {
let unlisten: (() => void) | null = null;
const setupListener = async () => {
unlisten = await listen<FileChange>('file-change', (event) => {
handleFileChange(event.payload);
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
}, [handleFileChange]);
// ウォッチャーの開始
useEffect(() => {
invoke('start_file_watcher', { watchPath }).catch(console.error);
}, [watchPath]);
return (
<div>
<h2>ファイル変更履歴</h2>
<ul>
{files.map((file, index) => (
<li key={index}>
<span className="status">{file.status}</span>
<span className="path">{file.path}</span>
{file.size && <span className="size">({file.size} bytes)</span>}
<span className="time">{file.timestamp.toLocaleTimeString()}</span>
</li>
))}
</ul>
</div>
);
}
リアルタイムチャット
チャットメッセージの送受信をイベントシステムで実装する例を以下に示す。
import { useEffect, useState, useCallback, useRef } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
interface ChatMessage {
id: string;
sender: string;
content: string;
timestamp: number;
}
function ChatRoom({ roomId, username }: { roomId: string; username: string }) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
// メッセージ受信ハンドラ
const handleMessage = useCallback((message: ChatMessage) => {
setMessages((prev) => [...prev, message]);
}, []);
// イベントリスナーの設定
useEffect(() => {
let unlisten: (() => void) | null = null;
const setupListener = async () => {
unlisten = await listen<ChatMessage>('chat-message', (event) => {
handleMessage(event.payload);
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
}, [handleMessage]);
// 自動スクロール
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// メッセージ送信
const sendMessage = async () => {
if (!input.trim()) return;
await invoke('send_chat_message', {
roomId,
sender: username,
content: input.trim(),
});
setInput('');
};
return (
<div className="chat-room">
<div className="messages">
{messages.map((msg) => (
<div key={msg.id} className={`message ${msg.sender === username ? 'own' : ''}`}>
<span className="sender">{msg.sender}</span>
<span className="content">{msg.content}</span>
<span className="time">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="input-area">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="メッセージを入力..."
/>
<button onClick={sendMessage}>送信</button>
</div>
</div>
);
}
デバッグとトラブルシューティング
イベントのロギング
開発時にイベントの送受信をロギングすると、デバッグが容易になる。
// デバッグ用 : 全てのイベントをロギング
import { listenAny } from '@tauri-apps/api/event';
async function enableEventLogging() {
const unlisten = await listenAny((event) => {
console.log(`[Event] ${event.event}`, event.payload);
});
return unlisten;
}
// アプリケーション初期化時に有効化
enableEventLogging();
よくある問題と解決策
| 問題 | 原因 | 解決策 |
|---|---|---|
| イベントが受信されない | リスナーが登録されていない、またはイベント名が間違っている | イベント名を確認し、listenAny でイベントが送信されているか確認する。
|
| 型エラーが発生する | RustとTypeScriptの型定義が不一致 | rename_all 属性を確認し、フィールド名が一致しているか確認する。
|
| メモリ使用量が増加 | リスナーが解除されていない | useEffect のクリーンアップ関数で unlisten を呼び出す。
|
| アンマウント後にエラー | アンマウント後の状態更新 | isMounted フラグで状態更新をガードする。
|
| 高頻度イベントで遅延 | イベント処理が重い | スロットリングやデバウンスを定義する。 |
参考リンク
- Tauri公式ドキュメント - Calling the Frontend
- Serde公式ドキュメント
- React公式ドキュメント - useEffect
- Tauriの基礎 - Commands
- リクエスト・レスポンス型の通信
- Tauriの基礎 - イベントの送受信
- emit / listenの基本操作
- Tauriの基礎 - 状態管理
- アプリケーションの状態管理