Electron vs Tauri vs Wails 実装して比較したお話

おひさしぶりです!バックエンドエンジニアの宮崎です。

以前の記事でElectron + SvelteKitでデスクトップアプリを作った話を書かせていただきましたが、
今回はその簡単な続編として、
よく自分の中で検討対象になる主要なデスクトップアプリフレームワーク3つを実際に使い比べてみた結果をお届けします。

「Electronは重い」「Tauriはどうなの?」「Wailsは?」という話はよく聞きますが、実際どれくらい違うの?開発体験は?という疑問に、向き合っていきたいと思います。

比較用アプリの実装

公平に比較するため、同じ機能を持つシンプルなCSVビューワーもどきを3つのフレームワークで実装しました。

実装した機能:

フロントエンドは共通でReact + Papaparse(CSVパーサー)を使用し、各フレームワーク固有のファイル読み込み部分のみを抜粋しています。

共通のフロントエンド構造

// App.jsx の基本構造(共通部分)
function App() {
  const [csvData, setCsvData] = useState([]);
  const [headers, setHeaders] = useState([]);
  const [filter, setFilter] = useState('');
  
  const processCSV = (content) => {
    const result = Papa.parse(content, {
      header: true,
      skipEmptyLines: true,
      dynamicTyping: true
    });
    // テーブル表示用にデータをセット
  };
  
  // フィルタリングとテーブル表示のレンダリング
  // ...
}

Electron実装

// main.js
const { app, BrowserWindow, ipcMain } = require('electron');
const fs = require('fs').promises;
const path = require('path');

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false
    }
  });
  // 省略
}

ipcMain.handle('read-csv', async (event, filePath) => {
  try {
    const content = await fs.readFile(filePath, 'utf-8');
    return { success: true, data: content };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

app.whenReady().then(createWindow);
// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  readCSV: (filePath) => ipcRenderer.invoke('read-csv', filePath)
});
// App.jsx(Electron固有部分のみ)
// ドロップされたファイルの処理
const handleFile = async (file) => {
  // Electronではfile.pathが取得できる
  const result = await window.electronAPI.readCSV(file.path);
  if (result.success) {
    processCSV(result.data);
  }
};

Tauri実装

// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use std::fs;

#[tauri::command]
fn read_csv(file_path: String) -> Result<String, String> {
    fs::read_to_string(file_path)
        .map_err(|e| e.to_string())
}

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![read_csv])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
// App.jsx(Tauri固有部分のみ)
import { invoke } from '@tauri-apps/api/tauri';
import { listen } from '@tauri-apps/api/event';

useEffect(() => {
  // Tauriのファイルドロップイベントをリッスン
  const unlisten = listen('tauri://file-drop', async (event) => {
    const files = event.payload;
    if (files.length > 0) {
      const content = await invoke('read_csv', { filePath: files[0] });
      processCSV(content);
    }
  });
  
  return () => {
    unlisten.then(fn => fn());
  };
}, []);

Wails実装

// app.go
package main

import (
    "context"
    "os"

    "github.com/wailsapp/wails/v2/pkg/runtime"
)

type App struct {
    ctx context.Context
}

func (a *App) ReadCSV(filePath string) (string, error) {
    content, err := os.ReadFile(filePath)
    if err != nil {
        return "", err
    }
    return string(content), nil
}

func (a *App) OpenFileDialog() (string, error) {
    selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
        Title: "CSVファイルを選択",
        Filters: []runtime.FileFilter{
            {DisplayName: "CSV Files", Pattern: "*.csv"},
        },
    })
    return selection, err
}
// App.jsx(Wails固有部分のみ)
import { ReadCSV, OpenFileDialog } from '../wailsjs/go/main/App';
import { EventsOn } from '../wailsjs/runtime/runtime';

useEffect(() => {
  // Wailsのドロップイベント
  EventsOn('wails:file-drop', async (x, y, paths) => {
    if (paths.length > 0) {
      const content = await ReadCSV(paths[0]);
      processCSV(content);
    }
  });
}, []);

// ファイル選択ダイアログ
const handleFileSelect = async () => {
  const filePath = await OpenFileDialog();
  if (filePath) {
    const content = await ReadCSV(filePath);
    processCSV(content);
  }
};

実装の違いまとめ

項目 Electron Tauri Wails
ファイルパス取得 file.pathで直接 イベント経由 イベント経由
バックエンド呼び出し IPC (invoke) invoke 関数直接呼び出し
セキュリティ設定 preload必須 デフォルトで安全 特に不要
型安全性 手動定義 手動定義 自動生成

パフォーマンス比較

バンドルサイズ(概算)

フレームワーク 相対サイズ 理由
Electron 70-150 MB ChromiumとNode.jsを同梱
Tauri 5-15 MB システムのWebViewを使用
Wails 10-20 MB システムのWebViewを使用 + Goランタイム

実際の計測例(シンプルなCSVビューワー):

  • Electron (Mac .app): 約145MB(解凍後)
  • Tauri (Mac .app): 約12MB(解凍後)
  • Wails (Mac .app): 約18MB(解凍後)

起動時間の傾向

Tauri <= Wails <<< Electron
(ElectronはTauri/Wailsの約2-3倍の時間がかかる)

Electronは独自のChromiumを起動するため、システムのWebViewを使用する他の2つより起動が明らかに遅いです。

AIに出してもらった開発体験(DX)の違い

Electron

良い点:

  • 📚 圧倒的な情報量とコミュニティ
  • 🔧 Node.jsのエコシステムをフル活用できる
  • 🐛 Chrome DevToolsでのデバッグが快適
  • 📦 npm packageがそのまま使える
  • 🔄 ホットリロードが安定して動作

イマイチな点:

  • 🔓 ソースコードの保護が困難
  • 😅 セキュリティ設定が複雑(contextIsolation, nodeIntegration)
  • 🐌 バンドルサイズが大きい(最小でも70MB以上)
  • 💾 メモリ使用量が多い(Chromium丸ごと)

Tauri

良い点:

  • 🚀 バンドルサイズが圧倒的に小さい(10MB以下も可能)
  • 🔒 セキュリティがデフォルトで堅牢
  • ⚡ 起動が速い
  • 🦀 Rustの型安全性
  • 📱 v2.0からモバイル対応

イマイチな点:

  • 📖 日本語情報がまだ少ない(LLMである程度カバー可能)
  • 🔨 Rust知識が必要(複雑な処理を書く場合)
  • 📱 WebViewの挙動がOSごとに微妙に違う
  • 🔧 Node.js APIが直接使えない(全てRust経由)

Wails

良い点:

  • ⚡ Goの並行処理を活かせる
  • 📏 バランスの取れたバンドルサイズ(15-20MB程度)
  • 🎯 シンプルなAPI
  • 🔗 バックエンドとフロントエンドの連携が直感的
  • 📝 自動的にTypeScript型定義生成

イマイチな点:

  • 📚 情報量が最も少ない(公式ドキュメントも不十分)
  • 🛠 エコシステムが発展途上
  • 📱 WebViewの挙動がOSごとに微妙に違う
  • 🚫 モバイル非対応(デスクトップのみ)
  • 🐛 デバッグツールが貧弱

AIに出してもらった機能面の比較

ファイルシステムアクセス

機能 Electron Tauri Wails
ファイル読み書き ◎ Node.js API ○ Rust経由 ○ Go経由
ファイル監視 ◎ chokidar等 ○ notify-rs △ 要実装
ドラッグ&ドロップ HTML5標準 ○ 独自イベント ○ 独自イベント
パス取得の容易さ

ネイティブ機能

機能 Electron Tauri Wails
システムトレイ
通知 プラグイン
グローバルショートカット プラグイン
自動アップデート ◎ electron-updater ○ tauri-updater △ 手動実装
メニューバー
クリップボード プラグイン

クロスプラットフォーム対応

Electron Tauri Wails
Windows
macOS
Linux
iOS × ◎ v2.0〜 ×
Android × ◎ v2.0〜 ×
WebView一貫性 Chromium統一 △ OS依存 △ OS依存

開発・配布機能

機能 Electron Tauri Wails
コード署名 ○ 要設定 ◎ 組み込み ○ 要設定
ストア配布 △ 制限あり
ソースコード保護 ×
バイナリサイズ × 70MB〜 ◎ 10MB〜 ○ 15MB〜

実装してみての所感

3つとも触ってみて感じたのは、
Electronは「安定の選択」でした。
HTML5の知識があれば大抵のことは実現でき、困ったときに調べれば大抵の問題は解決できる安心感があります。
ただ、ソースコードの秘匿性は皆無に近く、バンドルサイズも大きいので、配布するアプリの規模や用途によっては不向きに感じてます。

Tauriは「理想と現実のギャップ」がありました。
Rustでかけるのは魅力的ですが、日本語の情報はまだ少なく、複雑な機能を実装しようとすると英語のドキュメントを読み解く必要があります。
LLM全盛期ですので以前よりはかなり触りやすくなりましたが、やはり学習コストは高く、WebViewの挙動差など独特な部分も多いです。
それでも、バンドルサイズの小ささや起動の速さは本当に魅力的で、改めてRustを学びたいという気持ちにさせられました。
なにより自分の中でv2.0がリリースされ、モバイル対応も可能になったことで将来性を感じます。

Wailsは「Go技術者向け専用」という印象を受けました。
Go未経験者があえてWailsを選ぶ理由は薄く、情報量も最も少ないため、
既にGoを書ける人が学習コストをかけずに作りたい場合にのみ選択肢になるのかなと思いました。

まとめ

さて、いかがだったでしょうか?
正直なところ、慣れの問題もありますが書きやすさはダントツでElectronでしたが、性能面や配布面を考えるとTauriが非常に魅力的に感じました。
Rustは型の堅牢性が高く、最近Pythonをメインで触っている身としては書きづらかったです…!
Goに関してはその間といった感じでバランスがいいのですが、Rustとの著しい性能差はないとはいえあまり学習のモチベーションが上がりませんでした。

個人的には、今後Tauriの学習を進めていこうかなとは思っています。
理由としては、様々なミドルウェアがRustで書かれることが増えていて以前よりRustが身近になってきたこと、
モバイル対応も視野に入れられることで、より多く楽しめそうに感じたからです。

すこしでも皆さんの技術選定の参考になれば幸いです!

最後に

クロスマートでは、様々な技術にチャレンジしながら業界のDXを推進するエンジニアをはじめ、PM、BizDevなど幅広い職種で仲間を募集しています。
ご興味のある方は、ぜひこちらをご覧ください!

xorder.notion.site