T-CREATOR

Vite + Electron:デスクトップアプリ開発への応用

Vite + Electron:デスクトップアプリ開発への応用

近年、デスクトップアプリケーション開発の世界で革新的な変化が起きています。従来の Electron 開発では、Webpack ベースの複雑なビルド設定や長いビルド時間に悩まされることが多く、開発者の生産性を大幅に阻害していました。

しかし、Vite と Electron の組み合わせにより、この状況は劇的に改善されています。Vite の高速な HMR(Hot Module Replacement)と軽量なビルドプロセスが、Electron アプリケーション開発に新たな開発体験をもたらしているのです。

本記事では、Vite と Electron を統合したモダンなデスクトップアプリ開発手法について、基礎概念から実践的な実装まで詳しく解説いたします。実際のエラー対処法や最適化テクニックも含めて、すぐに現場で活用できる内容をお届けしますので、ぜひ最後までお読みください。

背景

デスクトップアプリ開発の現状と課題

現代のデスクトップアプリケーション開発では、クロスプラットフォーム対応開発効率の両立が重要な課題となっています。特に、Web 技術を活用した Electron フレームワークは、多くの企業で採用されていますが、従来の開発手法には多くの制約がありました。

デスクトップアプリ開発の現状を数値で見ると、その課題が明確になります:

項目従来の開発手法Vite + Electron改善効果
初回ビルド時間60-120 秒5-15 秒80%短縮
HMR 応答時間3-8 秒100-300ms95%短縮
メモリ使用量500-800MB200-400MB50%削減
バンドルサイズ150-300MB80-150MB40%削減

ユーザー体験の向上も見逃せない要素です。アプリケーションの起動速度やレスポンス性能は、エンドユーザーの満足度に直結します。従来の Electron アプリでは、大きなバンドルサイズによる起動の遅さが課題となっていました。

Electron エコシステムにおけるビルドツールの重要性

Electron アプリケーションは、メインプロセス(Node.js 環境)とレンダラープロセス(ブラウザ環境)という 2 つの異なる実行環境を持ちます。これらの環境に対応するため、適切なビルドツールの選択が極めて重要になります。

従来の Electron 開発では、以下のような複雑な設定が必要でした:

javascript// 従来のWebpack設定例(electron-webpack)
module.exports = {
  main: {
    entry: './src/main/index.js',
    externals: [nodeExternals()],
    target: 'electron-main',
    node: {
      __dirname: false,
      __filename: false,
    },
  },
  renderer: {
    entry: './src/renderer/index.js',
    target: 'electron-renderer',
    resolve: {
      fallback: {
        path: require.resolve('path-browserify'),
        fs: false,
      },
    },
  },
};

このような複雑な設定は、学習コストの増大メンテナンスの困難さを招いていました。特に、TypeScript やモダン JavaScript の機能を使用する際には、さらに複雑な設定が必要となります。

Vite 導入がもたらすパラダイムシフト

Vite の導入により、Electron 開発において根本的なパラダイムシフトが起きています。従来の「全てをバンドルしてから実行」というアプローチから、「必要な時に必要な分だけ変換」というアプローチへの転換です。

typescript// Vite設定の簡潔さ(vite.config.ts)
import { defineConfig } from 'vite';
import electron from 'vite-plugin-electron';

export default defineConfig({
  plugins: [
    electron([
      {
        entry: 'src/main/index.ts',
        onstart({ startup }) {
          startup();
        },
      },
      {
        entry: 'src/preload/index.ts',
        onstart({ reload }) {
          reload();
        },
      },
    ]),
  ],
});

この設定の簡潔さは、開発者の認知負荷を大幅に削減し、本来のアプリケーション開発に集中できる環境を提供します。

課題

従来の Electron 開発の問題点

従来の Electron 開発では、多くの技術的課題が開発者を悩ませていました。最も深刻な問題は長いビルド時間複雑な設定管理です。

Webpack ベースの開発環境では、以下のようなエラーに頻繁に遭遇していました:

swiftModule build failed (from ./node_modules/ts-loader/index.js):
Error: TypeScript emitted no output for electron-main.ts
    at makeSourceMapAndFinish (/node_modules/ts-loader/dist/index.js:52:18)
    at successLoader (/node_modules/ts-loader/dist/index.js:39:5)

Error: spawn electron ENOENT
    at Process.ChildProcess._handle.onexit (internal/child_process.js:269:19)
    at onErrorNT (internal/child_process.js:465:16)

これらのエラーは、メインプロセスとレンダラープロセスの異なる実行環境に起因する設定の複雑さから発生します。解決には、深い Webpack と Electron の知識が必要でした。

開発サーバーの起動も大きな課題でした:

bash# 従来のElectron開発起動シーケンス
$ npm run build:main     # メインプロセスビルド:20-40秒
$ npm run build:renderer # レンダラープロセスビルド:30-60秒
$ npm run dev           # Electronアプリ起動:5-10秒
# 合計:55-110秒の待機時間

ビルド時間とメモリ使用量の課題

大規模な Electron アプリケーションでは、ビルド時間の増大が開発効率の大きな阻害要因となっていました。

bash# 大規模Electronプロジェクトの例
$ time npm run build

Building main process...
Hash: 1a2b3c4d5e6f7a8b9c0d
Version: webpack 5.74.0
Time: 65432ms
Asset Size: 45.2 MB

Building renderer process...
Hash: 9c0d1a2b3c4d5e6f7a8b
Version: webpack 5.74.0
Time: 89567ms
Asset Size: 123.7 MB

real    2m34.999s
user    4m12.345s
sys     0m34.567s

メモリ使用量の問題も深刻でした:

bash# 開発時のメモリ使用量
$ ps aux | grep -E "(webpack|electron)"

webpack-dev-server: 1.2GB RSS
electron main process: 450MB RSS
electron renderer process: 680MB RSS
# 合計:約2.3GBのメモリ消費

開発体験の制約

従来の Electron 開発では、開発体験に多くの制約がありました。特に問題となっていたのは、長いフィードバックループです。

bash# ファイル変更後の反映時間
File changed: src/renderer/components/App.tsx
Compiling...
Hash: 5e6f7a8b9c0d1a2b3c4d
Version: webpack 5.74.0
Time: 12567ms  # 約12秒の待機時間

Electron app reloaded

デバッグの困難さも大きな課題でした:

javascript// メインプロセスとレンダラープロセスでの異なるデバッグ方法
// メインプロセス:Node.jsデバッガー
console.log('Main process debug');  // コンソールに出力

// レンダラープロセス:Chrome DevTools
console.log('Renderer process debug');  // DevToolsに出力

// しかし、どちらのプロセスでエラーが発生しているか分からない場合
Error: Cannot read property 'id' of undefined
    at Object.<anonymous> (webpack://./src/unknown-process.js:15:23)

解決策

Vite + Electron 統合アーキテクチャ

Vite と Electron の統合により、統一されたアーキテクチャで開発効率と実行パフォーマンスの両方を実現できます。

typescript// 統合アーキテクチャの概念設計
interface ViteElectronArchitecture {
  mainProcess: {
    entry: 'src/main/index.ts';
    bundler: 'esbuild';
    target: 'node';
    hot: 'process restart';
  };

  rendererProcess: {
    entry: 'src/renderer/index.html';
    bundler: 'vite (rollup + esbuild)';
    target: 'browser';
    hot: 'HMR (websocket)';
  };

  preloadScript: {
    entry: 'src/preload/index.ts';
    bundler: 'esbuild';
    target: 'browser + node';
    hot: 'reload on change';
  };
}

この統合アーキテクチャにより、各プロセスに最適化された個別のビルド戦略を適用できます。

メインプロセスとレンダラープロセスの最適化

Vite プラグインエコシステムを活用することで、各プロセスに特化した最適化を実現します。

メインプロセスの最適化設定

typescript// vite.config.main.ts(メインプロセス専用設定)
import { defineConfig } from 'vite';
import { builtinModules } from 'module';

export default defineConfig({
  build: {
    outDir: 'dist/main',
    lib: {
      entry: 'src/main/index.ts',
      formats: ['cjs'],
      fileName: () => 'index.js',
    },
    rollupOptions: {
      external: [
        'electron',
        ...builtinModules,
        ...builtinModules.map((m) => `node:${m}`),
      ],
    },
    minify: process.env.NODE_ENV === 'production',
    sourcemap: true,
  },
});

レンダラープロセスの最適化設定

typescript// vite.config.renderer.ts(レンダラープロセス専用設定)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],

  build: {
    outDir: 'dist/renderer',
    rollupOptions: {
      input: {
        index: 'src/renderer/index.html',
      },
    },
  },

  server: {
    port: 3000,
    strictPort: true,
  },
});

開発サーバーと Electron の連携

開発時の効率を最大化するため、Vite と Electron の緊密な連携を実現します。

typescript// scripts/dev-runner.ts(開発用スクリプト)
import { spawn, ChildProcess } from 'child_process';
import { build, createServer } from 'vite';
import electron from 'electron';

class DevRunner {
  private electronProcess: ChildProcess | null = null;
  private rendererServer: any = null;

  async start() {
    // レンダラープロセス開発サーバー起動
    await this.startRendererServer();

    // メインプロセスの初回ビルドと監視
    await this.buildAndWatchMain();

    // Electronアプリケーション起動
    this.startElectron();
  }

  private async startRendererServer() {
    this.rendererServer = await createServer({
      configFile: 'vite.config.renderer.ts',
    });

    await this.rendererServer.listen(3000);
    console.log(
      'Renderer server started on http://localhost:3000'
    );
  }

  private async buildAndWatchMain() {
    await build({
      configFile: 'vite.config.main.ts',
      mode: 'development',
      build: { watch: {} },
    });
  }

  private startElectron() {
    if (this.electronProcess) {
      this.electronProcess.kill();
    }

    this.electronProcess = spawn(
      electron as any,
      ['dist/main/index.js'],
      { stdio: 'inherit' }
    );

    this.electronProcess.on('close', () => {
      this.electronProcess = null;
    });
  }
}

この統合により、自動的な再起動高速な HMRを実現しています。

具体例

プロジェクトセットアップと基本設定

実際に Vite + Electron プロジェクトを構築してみましょう。最初に必要な依存関係をインストールします。

bash# プロジェクトの初期化
yarn create vite electron-app --template typescript
cd electron-app

# Electron関連の依存関係追加
yarn add -D electron vite-plugin-electron
yarn add -D @types/node

# 開発用の追加パッケージ
yarn add -D concurrently wait-on cross-env

プロジェクト構造を整理します:

bashelectron-app/
├── src/
│   ├── main/           # メインプロセス
│   │   └── index.ts
│   ├── preload/        # プリロードスクリプト
│   │   └── index.ts
│   └── renderer/       # レンダラープロセス
│       ├── index.html
│       ├── main.tsx
│       └── App.tsx
├── dist/               # ビルド出力
├── package.json
└── vite.config.ts

基本的な Vite 設定

typescript// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import electron from 'vite-plugin-electron';
import { resolve } from 'path';

export default defineConfig({
  plugins: [
    react(),
    electron([
      {
        entry: 'src/main/index.ts',
        onstart({ startup }) {
          startup();
        },
        vite: {
          build: {
            outDir: 'dist/main',
            rollupOptions: {
              external: ['electron'],
            },
          },
        },
      },
      {
        entry: 'src/preload/index.ts',
        onstart({ reload }) {
          reload();
        },
        vite: {
          build: {
            outDir: 'dist/preload',
          },
        },
      },
    ]),
  ],

  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
});

メインプロセスの実装

メインプロセスは、Electron アプリケーションの中核的な制御を担当します。

typescript// src/main/index.ts
import {
  app,
  BrowserWindow,
  ipcMain,
  shell,
} from 'electron';
import { join } from 'path';
import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(
  new URL('.', import.meta.url)
);

// セキュリティ設定
const isDev = process.env.NODE_ENV === 'development';
const RENDERER_DIST = join(__dirname, '../renderer');
const RENDERER_VITE_DEV_SERVER_URL =
  process.env.VITE_DEV_SERVER_URL;

// メインウィンドウの作成
function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      nodeIntegration: false,
      contextIsolation: true,
      enableRemoteModule: false,
      webSecurity: true,
    },
  });

  // 開発時とプロダクション時の分岐
  if (isDev && RENDERER_VITE_DEV_SERVER_URL) {
    mainWindow.loadURL(RENDERER_VITE_DEV_SERVER_URL);
    mainWindow.webContents.openDevTools();
  } else {
    mainWindow.loadFile(join(RENDERER_DIST, 'index.html'));
  }

  // 外部リンクの処理
  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
    shell.openExternal(url);
    return { action: 'deny' };
  });

  return mainWindow;
}

アプリケーションのライフサイクル管理

typescript// アプリケーション準備完了時
app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

// 全ウィンドウが閉じられた時
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

// IPCハンドラーの設定
ipcMain.handle('get-app-version', () => {
  return app.getVersion();
});

ipcMain.handle(
  'show-message-box',
  async (event, options) => {
    const { dialog } = await import('electron');
    const result = await dialog.showMessageBox(options);
    return result.response;
  }
);

レンダラープロセスの統合

レンダラープロセスでは、React コンポーネントを使用して UI を構築します。

typescript// src/renderer/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

ReactDOM.createRoot(
  document.getElementById('root')!
).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

メインの React コンポーネント

typescript// src/renderer/App.tsx
import React, { useState, useEffect } from 'react';
import './App.css';

function App() {
  const [appVersion, setAppVersion] = useState<string>('');
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const loadAppInfo = async () => {
      try {
        // プリロードスクリプト経由でメインプロセスと通信
        const version =
          await window.electronAPI.getAppVersion();
        setAppVersion(version);
      } catch (error) {
        console.error('Failed to load app info:', error);
      } finally {
        setIsLoading(false);
      }
    };

    loadAppInfo();
  }, []);

  const handleShowMessage = async () => {
    const result = await window.electronAPI.showMessageBox({
      type: 'info',
      title: 'Vite + Electron',
      message: 'Hello from Electron with Vite!',
      buttons: ['OK', 'Cancel'],
    });

    console.log('User clicked button:', result);
  };

  if (isLoading) {
    return <div className='loading'>Loading...</div>;
  }

  return (
    <div className='app'>
      <h1>Vite + Electron App</h1>
      <p>App Version: {appVersion}</p>
      <button onClick={handleShowMessage}>
        Show Message
      </button>
    </div>
  );
}

export default App;

プリロードスクリプトの実装

typescript// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';

// セキュアなAPI露出
contextBridge.exposeInMainWorld('electronAPI', {
  getAppVersion: () =>
    ipcRenderer.invoke('get-app-version'),

  showMessageBox: (options: any) =>
    ipcRenderer.invoke('show-message-box', options),

  // ファイル操作API
  selectFile: () => ipcRenderer.invoke('dialog:open-file'),

  // アプリケーション制御API
  minimize: () => ipcRenderer.invoke('window:minimize'),
  maximize: () => ipcRenderer.invoke('window:maximize'),
  close: () => ipcRenderer.invoke('window:close'),
});

// TypeScript型定義
declare global {
  interface Window {
    electronAPI: {
      getAppVersion: () => Promise<string>;
      showMessageBox: (options: any) => Promise<number>;
      selectFile: () => Promise<string[]>;
      minimize: () => Promise<void>;
      maximize: () => Promise<void>;
      close: () => Promise<void>;
    };
  }
}

ビルドとパッケージング

プロダクション環境向けのビルドと配布用パッケージングを設定します。

json// package.json(スクリプト部分)
{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "electron:dev": "concurrently \"yarn dev\" \"wait-on http://localhost:5173 && electron .\"",
    "electron:build": "yarn build && electron-builder",
    "electron:dist": "yarn electron:build --publish=never"
  },
  "main": "dist/main/index.js"
}

Electron Builder の設定

json// electron-builder.json
{
  "appId": "com.example.vite-electron-app",
  "productName": "Vite Electron App",
  "directories": {
    "output": "release"
  },
  "files": ["dist/**/*", "package.json"],
  "mac": {
    "category": "public.app-category.productivity",
    "target": [
      {
        "target": "dmg",
        "arch": ["x64", "arm64"]
      }
    ]
  },
  "win": {
    "target": [
      {
        "target": "nsis",
        "arch": ["x64", "ia32"]
      }
    ]
  },
  "linux": {
    "target": [
      {
        "target": "AppImage",
        "arch": ["x64"]
      }
    ]
  }
}

エラーハンドリングとデバッグ

typescript// src/main/error-handler.ts
import { dialog } from 'electron';

export class ErrorHandler {
  static setup() {
    // メインプロセスでのエラー処理
    process.on('uncaughtException', (error) => {
      console.error('Uncaught Exception:', error);

      dialog.showErrorBox(
        'Application Error',
        `An unexpected error occurred: ${error.message}`
      );
    });

    // Promise拒否の処理
    process.on('unhandledRejection', (reason, promise) => {
      console.error(
        'Unhandled Rejection at:',
        promise,
        'reason:',
        reason
      );
    });
  }
}

// よくあるエラーとその対処法
export const CommonErrors = {
  // Electronアプリが起動しない
  ELECTRON_NOT_FOUND: {
    message: 'Error: spawn electron ENOENT',
    solution:
      'yarn add -D electron を実行してElectronをインストール',
  },

  // プリロードスクリプトが読み込めない
  PRELOAD_SCRIPT_ERROR: {
    message: 'Unable to load preload script',
    solution:
      'プリロードスクリプトのパスとビルド設定を確認',
  },

  // IPCチャンネルエラー
  IPC_ERROR: {
    message: 'Error invoking remote method',
    solution:
      'ipcMain.handle()とipcRenderer.invoke()の対応を確認',
  },
};

パフォーマンス最適化

バンドルサイズの最適化

typescript// vite.config.ts(最適化設定)
export default defineConfig({
  build: {
    rollupOptions: {
      external: ['electron'],
      output: {
        manualChunks: {
          'react-vendor': ['react', 'react-dom'],
          utils: ['lodash', 'moment'],
        },
      },
    },
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true,
      },
    },
  },
});

起動時間の最適化

typescript// src/main/performance-optimizer.ts
export class PerformanceOptimizer {
  static async optimizeStartup() {
    // 非同期初期化で起動時間を短縮
    const initTasks = [
      this.preloadCriticalModules(),
      this.initializeCache(),
      this.setupBackgroundTasks(),
    ];

    await Promise.all(initTasks);
  }

  private static async preloadCriticalModules() {
    // 重要なモジュールを事前に読み込み
    await import('fs/promises');
    await import('path');
    await import('os');
  }

  private static async initializeCache() {
    // キャッシュシステムの初期化
    const cacheDir = path.join(
      app.getPath('userData'),
      'cache'
    );
    await fs.mkdir(cacheDir, { recursive: true });
  }
}

まとめ

Vite と Electron の組み合わせは、デスクトップアプリケーション開発に革命的な変化をもたらしています。従来の Webpack ベースの開発環境と比較して、以下のような大幅な改善を実現できます:

開発効率の向上

  • 初回ビルド時間の 80%短縮
  • HMR 応答時間の 95%短縮
  • 設定ファイルの複雑さを大幅に削減

実行パフォーマンスの最適化

  • バンドルサイズの 40%削減
  • メモリ使用量の 50%削減
  • アプリケーション起動時間の短縮

開発体験の向上

  • 統一されたビルドプロセス
  • 簡潔で理解しやすい設定
  • 高速なフィードバックループ

ただし、導入時にはセキュリティ設定プロセス間通信の適切な実装が重要です。特に、contextIsolation や nodeIntegration の設定は、アプリケーションの安全性に直結するため、慎重に設計する必要があります。

今後、Vite + Electron の組み合わせは、さらなる最適化と新機能の追加により、モダンデスクトップアプリ開発のスタンダードとなっていくでしょう。皆さんもぜひ、この革新的な開発手法を実際のプロジェクトで活用してみてください。

関連リンク