[TSKaigi2025] TypeScript だけを書いて Tauri でデスクトップアプリを作ろう (記事版)
2025/05/23・24 に行われた TSKaigi2025 で、「TypeScript だけを書いて Tauri でデスクトップアプリを作ろう」という LT を行いました。5分間に収まりきらなかった部分があるので、内容を追加して記事としてまとめます。
スライド
まとめ
- TypeScript だけで充分に実用的なネイティブアプリを作れる。
- ネイティブアプリとして必要な処理を TypeScript から簡単に呼び出せる。
- ツラみはあるが、快適さが勝るのでかなりオススメ。
- Rust を知らなくても良い。
- Web 系の知識を活かして、その延長でアプリが作れる。
- 開発ループが速い。
Tauri とは
Tauri は Rust 製のクロスプラットフォームフレームワークです。デスクトップ(Windows, macOS, Linux)に加えて、モバイル(Android, iOS)のネイティブアプリも作成可能です。
アプリの基本的な部分は主に Rust(環境によって Swift や Kotlin)のネイティブ言語で書かれたコードで動作し、UI の部分を JavaScript/TypeScript によって記述して Web ビューに描画する仕組みになっています。(UI 部分に Rust の Web フロントエンド向けクレートも利用可能です)
UI の描画のために、システムが備えている Web ビューを使用するのが Tauri の特徴です。そのためアプリ自体には Web ブラウザをバンドルする必要がなく、アプリのファイルサイズが小さくなります。例えば macOS 向けだと 10MB 程度のレベル感になります。
UI 部分で使用する Web フロントエンド技術を選びません。JS/TS で動けば良いだけなので、React・Vue・Svelte・Solid のような最近のものも使えますし、古くからある jQuery や、果ては単なる Vanilla JavaScript でも書けます。なお Next.js のようなメタフレームワークも利用できますが、Static Export のみ対応しているなどの制限があります(参考)。
Rust 等で書かれたネイティブ処理と、Web ビューで動く JS/TS の間は、プロセス間通信(IPC)によりやり取りを行います。基本的には Rust 側で処理を行い、その結果を UI 側に伝えて画面を描画する、という構造になっています。つまり、Rust で書かれたアプリケーションに、Web ビューを使用した UI を被せるという思想のフレームワークです。
TypeScript だけで Tauri を使う
「Rust はちょっと……」と腰が引ける人は多いと思います。実際、Rust は難しくて大変です。
そんな人におすすめなのが、TypeScript だけで Tauri を使うやり方です。Rust をまったく触ることなく、充分な機能を備えたアプリを開発できます。
Tauri では JS/TS からバックエンド側 (Rust) の処理を呼び出す API やプラグインが提供されています。これを使えば、TS から簡単にネイティブ側の処理を実行可能です。例えばファイル操作、ウィンドウ操作、メニュー、ネットワークアクセス、通知といった、ネイティブアプリとして必要な機能が公式から提供されています。
呼び出しの詳細については後述します。
TypeScript だけでどんなものが作れるか?
かなりのものが作れます。普通のアプリは問題なく作れますし、アプリ外でのキー操作を取得して動作するランチャーのようなものも作成可能です。
例として、自分は macOS 向けの漫画ビューワである magv というものを作っています。
これは以下のような、普通の漫画ビューワが備えているような機能を実現しています。
- フォルダ内の画像を開く
- 画像をまとめた zip ファイルを開く
- 前後の zip ファイルを開く
- zip ファイルをリネームする
- ウィンドウ位置を保存・復元する
JS/TS のエコシステムが利用できますし、ネイティブ処理を TS から呼び出すことで普通のアプリを作れています。
ただ TS だけではちょっとした限界があったりはします。例えばアプリ起動時のウィンドウ位置の復元が一発で決まらない(一瞬別の位置で表示されたあとに復元する)という挙動になっており、これを解消するためには起動時のパラメーター設定を Rust で書き換える実装が必要そうです(多分)。あと、大きい zip ファイルを開いたときに遅いので、これをなんとかしたいとは思っています。
TypeScript だけで書く嬉しさ
TypeScript だけで書ける
何かの構文のようになっていますが、実際 TS だけで書けるのはかなり嬉しいと思います。
新たに Rust を習得する必要がなく、書きやすく書き慣れた TS だけでデスクトップアプリ作成が完結するのは魅力で夢があります。
HMR で快適に開発可能
Hot Module Replacement、いわゆるホットリロードがよく効いて、快適に開発可能です。コードを変更して保存するとすぐに反映されます。
ネイティブ処理を Rust で書いていると、保存するたびにビルド処理が走り、動作を確認するための数秒の待ち時間が発生します。しかしすべてを TS で書いていると変更が即時反映されるため、かなり良い開発者体験を得ることができます。
逆に UI 部分まで Rust で書いていると、何をするにも待ち時間が発生してしまいかなり体験が損なわるため、雲泥の差です。
使い慣れた Web 系ツールチェインだけで完結
これは TS で書くからというわけではないのですが、Rust ツールチェイン (cargo 等) を基本的に触らなくて良く、pnpm などの Web 系ツールチェインだけを使って開発が完結します。
pnpm create tauri-app
でプロジェクトを作成し、pnpm tauri dev
で開発実行し、pnpm tauri build
で実行ファイルをビルドする、といったことが可能です。
TypeScript だけで書くツラみ
もちろん良い部分だけではなく、少しツラい部分もあります。
ネイティブ処理 (Rust 実装) の呼び出しがちょっと遅い
TS からはプロセス間通信で Rust 実装のネイティブ処理を呼び出すことになりますが、通信が絡むことによるオーバーヘッドにより遅さを感じることがあります。
実際、単純に呼び出し処理を 1,000回 ループ実行すると、完了までにおよそ 100ms 程度を要します。例えばフォルダ内のファイルの情報をループで取得するような場合に引っかかったりします。
この場合の対応としては、ループ処理を Rust 側にまとめて、TS からの呼び出しを1回のみにするという方法が一番効きます。「TS だけで書く」という趣旨からは外れてしまいますが、Rust が書けるならこれが確実です。
ここからは補足として、ネイティブ処理との連携や Tauri 自体のツラみなどについて少し書いていきます。
ネイティブ処理の呼び出し
Tauri のネイティブ処理の呼び出しがどのようなものかを軽く見ていきます。
(公式ドキュメント:Calling Rust from the Frontend)
基本的な呼び出し方法
TS から Rust で書かれた処理を呼び出すときは、@tauri-apps/api/core
の invoke
関数によりプロセス間通信を実行します。この関数の型定義 (.d.ts
ファイル) を見ると、いくつかのことが分かります。
declare function invoke<T>(cmd: string, args?: InvokeArgs, options?: InvokeOptions): Promise<T>;
- コマンド名
cmd
として文字列を指定して実行する。 - 引数
args
とオプションoptions
を渡せる。 - 実行結果の戻り値の型
T
を指定できる。 - 非同期関数であり、
Promise
に包まれて結果が返ってくる。
コマンド名は、基本的には Rust 側でコマンドとして定義した関数の名前です。例えばテンプレートプロジェクトでは、以下のように name
という文字列スライスの引数を取る greet
関数が定義されています。
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
これを TS 側から invoke("greet", { name: "名前" })
として呼び出すことで、greet
の実行結果である加工された文字列を得ることができます。(ちなみにこれだと戻り値の型 T
を指定していないので、型推論結果は Promise<unknown>
になります)
非同期関数になっているのはプロセス間通信が行われるためで、普通の fetch
等による通信と同じような非同期処理になります。
TypeScript 向け API
Rust の関数は invoke
で TS から呼び出すことになりますが、実際に使用する機会は少ないと思われます。Tauri では API やプラグインとしてすでに Rust 実装をラップした TS の実装が用意されており、それだけでほぼ事足りるためです。
例えばパスで指定したファイルのサイズを得るための関数が @tauri-apps/plugin-fs
に size
関数として存在します。(コード)
async function size(path: string | URL): Promise<number> {
if (path instanceof URL && path.protocol !== 'file:') {
throw new TypeError('Must be a file URL.')
}
return await invoke('plugin:fs|size', {
path: path instanceof URL ? path.toString() : path
})
}
引数のチェックを行ったあと、invoke
で Rust 実装を関数内部で呼び出してくれています。これにより TS 側からは単なる TS の関数呼び出しとして実行するだけで、ネイティブ側の実装を気にせずに実行結果(ファイルサイズ)を得ることができるようになっています。
こうしてラップする API やプラグインが様々なジャンルで用意されているため、TS のみでほぼ問題なくアプリを作り上げることができます。
Tauri 自体のツラみ
ここからは話が変わり、TS 書くことに限らない Tauri 自体のちょっとしたツラみを挙げていきます。
環境による差異が発生することがある
Tauri は OS が持つ Web ビューを使用するため、マルチプラットフォームのアプリを作るとき、環境による差異が出ることがあります。挙動が違っていたり、対応していない CSS があったりして、バグやデザイン崩れが起きがちです。
各 OS が持っている Web ビューはとても古いバージョンのブラウザ相当のケースもあるため、普通の Web 系システム開発以上に気を使う必要があります。Baseline が Widely available だから使える、なんてことはないと思いましょう。
ドキュメント不足
全体としてドキュメントの整備がまだ足りていません。
ファイルへアクセスする @tauri-apps/plugin-fs
を見ると、「許可を与えるために "permissions"
を設定してね」と書いてありますが、どのファイルに記載すればよいのかが書かれていません。実際の対象ファイルは src-tauri/capabilities/default.json
で、Capabilities のドキュメントに書かれていますが、このドキュメントにたどり着くのが困難です。
ネット検索して出てくる情報のバージョンが古い
検索して出てくる情報が古い Tauri v1 の話であることがよくあります。Tauri v2 は 2024 年 10 月に正式リリースされており、その前後で使い方などが大きく変わっているため、書かれた時期や内容に注意する必要があります。
特に Google だと公式ドキュメントですら v1 の方が上に表示されるため、注意する必要があります。最近になって「古いバージョンの情報を見ている」という警告が出るようになりましたが、まだ安心できる状況には至っていません。
最後のまとめ
冒頭に述べた通り、Tauri + TypeScript の開発はかなりオススメです。
- TypeScript だけで充分に実用的なネイティブアプリを作れる。
- ネイティブアプリとして必要な処理を TypeScript から簡単に呼び出せる。
- ツラみはあるが、快適さが勝るのでかなりオススメ。
- Rust を知らなくても良い。
- Web 系の知識を活かして、その延長でアプリが作れる。
- 開発ループが速い。
もっと浸透すれば、今後、マルチプラットフォームのネイティブアプリを作るときの第一の選択肢になるのではないか、と思っています。
Discussion