実際に Electron でデスクトップアプリ(複数ファイルへの一括テキスト置換ツール)を0から作りました。
開発中に何度もハマり、そのたびに解決策を記録してきました。次回また同じ轍を踏まないよう、ここに備忘録としてまとめておきます。

複数ルールで一括grep置換ツール「Multi Grep Replacer」 Web開発やプログラミングで、こんな経験はありませんか?🔄 同じ置換作業を何度も繰り返している😓 サクラエディタで5つの置換パターンを1つずつ実行するのが面倒めんどくさかったので、複数ルールで一括置換できる「Multi Grep Replacer」 を作ってみ...  続きを読む

    1. プロジェクト構成・アーキテクチャの基本

    Electron アプリは メインプロセス(Node.js)レンダラープロセス(Chromium) の2層構造になっている。この分離を最初からきちんと意識して設計しないと、後から大幅なリファクタが必要になる。

    src/
    ├── main/           # メインプロセス(Node.js)
    │   ├── main.js
    │   ├── file-operations.js
    │   ├── replacement-engine.js
    │   └── config-manager.js
    ├── renderer/       # レンダラープロセス(Chromium)
    │   ├── index.html
    │   ├── css/
    │   └── js/
    └── preload/
        └── preload.js  # セキュアな橋渡し役
    

    原則として何をどちらに置くか:

    • ファイルI/O・Node.js API → メインプロセス
    • UI描画・ユーザーインタラクション → レンダラープロセス
    • 両者の通信 → preload.js 経由の IPC

    2. セキュリティ設定は最初から正しく

    Electron の新しいベストプラクティスでは、以下の設定が必須。後から変えようとすると全体に影響するので、プロジェクト作成時の Task 1 で必ず設定する。

    // main.js - BrowserWindow 生成時
    const mainWindow = new BrowserWindow({
      webPreferences: {
        nodeIntegration: false,     // レンダラーから Node.js を直接使わせない
        contextIsolation: true,     // レンダラーと preload の実行コンテキストを分離
        preload: path.join(__dirname, '../preload/preload.js')
      }
    });
    

    contextIsolation: true にすると、レンダラープロセスから process オブジェクトに直接アクセスできなくなる。開発中に ReferenceError: process is not defined が出たら、レンダラーで process を使おうとしている箇所を探す。

    安全な API 公開は contextBridge 経由で:

    // preload.js
    const { contextBridge, ipcRenderer } = require('electron');
    
    contextBridge.exposeInMainWorld('electronAPI', {
      selectFolder: () => ipcRenderer.invoke('select-folder'),
      searchFiles: (options) => ipcRenderer.invoke('search-files', options),
      // ...
    });
    

    3. IPC通信の設計

    メインとレンダラーの通信は ipcMain.handle / ipcRenderer.invoke の組み合わせ(Promise ベース)を使う。古い send / on よりこちらが断然扱いやすい。

    メインプロセス側:

    ipcMain.handle('execute-replacement', async (event, config) => {
      try {
        const result = await replacementEngine.processFiles(/* ... */);
        return { success: true, ...result };
      } catch (error) {
        return { success: false, error: error.message };
      }
    });
    

    レンダラー側:

    const result = await window.electronAPI.executeReplacement(config);
    if (result.success) { /* 成功処理 */ }
    

    IPC で気をつけること:

    • 入力値は必ず検証する(パストラバーサル対策など)
    • エラー情報の露出をコントロールする(スタックトレースをそのまま返さない)
    • 戻り値の構造を { success: boolean, ... } で統一しておくと扱いやすい
    • 進捗通知は webContents.send でレンダラーにプッシュする

    4. パッケージ版(.app)ならではの落とし穴

    ここが最大のハマりポイントだった。 開発版(npm start)では動くのに .app にすると動かない、というパターンが複数発生した。

    4-1. 環境判定は app.isPackaged を使う

    // ❌ NODE_ENV 依存は .app ではうまく動かないことがある
    this.isDevelopment = process.env.NODE_ENV === 'development';
    
    // ✅ これが確実
    this.isDevelopment = !app.isPackaged;
    

    4-2. リソースファイルのパス解決

    設定ファイルなどをアプリに同梱する場合、パッケージ版では __dirname が変わるため固定パスが通らなくなる。

    // ❌ 固定パス(パッケージ版で失敗)
    static DEFAULT_CONFIG_PATH = path.join(__dirname, '../../config/default.json');
    
    // ✅ 動的パス解決
    static get DEFAULT_CONFIG_PATH() {
      if (app.isPackaged) {
        return path.join(process.resourcesPath, 'config/default.json');
      }
      return path.join(__dirname, '../../config/default.json');
    }
    

    package.jsonbuild.extraResources に同梱したいファイルを指定することも忘れずに:

    {
      "build": {
        "extraResources": ["config/**/*"]
      }
    }
    

    4-3. HTML の読み込みは loadFile で統一

    // ❌ パッケージ版でハマる複雑な条件分岐
    if (app.isPackaged) {
      await mainWindow.loadURL(`file://${absolutePath}`);
    } else {
      await mainWindow.loadFile(absolutePath);
    }
    
    // ✅ loadFile で統一(Electron が自動的にパス解決)
    await mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
    

    4-4. DevTools はパッケージ版では開かない

    mainWindow.once('ready-to-show', () => {
      mainWindow.show();
      if (!app.isPackaged) {
        mainWindow.webContents.openDevTools();
      }
    });
    

    5. ウィンドウ管理・ライフサイクルの注意点

    5-1. シングルインスタンス制御

    これを実装していないと、2回目の起動でウィンドウが出ない or 2つ起動して競合する。

    const gotTheLock = app.requestSingleInstanceLock();
    
    if (!gotTheLock) {
      app.quit();
      return;
    }
    
    app.on('second-instance', () => {
      if (mainWindow) {
        if (mainWindow.isMinimized()) mainWindow.restore();
        mainWindow.focus();
        mainWindow.show();
      }
    });
    

    5-2. イベントハンドラーの重複登録を避ける

    closed イベントなどを複数箇所で登録するとメモリリーク・動作不安定の原因になる。1か所のみに集約する。

    // ✅ 1か所のみ登録
    mainWindow.on('closed', () => {
      mainWindow = null;
    });
    

    5-3. macOS の activate イベント対応

    Dock クリック時にウィンドウを再表示する処理を忘れがち:

    app.on('activate', () => {
      if (BrowserWindow.getAllWindows().length === 0) {
        createMainWindow();
      } else if (mainWindow) {
        mainWindow.show();
        mainWindow.focus();
      }
    });
    

    5-4. ready-to-show が発火しない場合のフォールバック

    まれにイベントが発火しないケースに備えて保険をかけておく:

    mainWindow.once('ready-to-show', () => {
      mainWindow.show();
    });
    
    // フォールバック
    setTimeout(() => {
      if (mainWindow && !mainWindow.isVisible()) {
        mainWindow.show();
      }
    }, 1000);
    

    6. パフォーマンス最適化の考え方

    ファイル数・サイズに応じて処理戦略を動的に切り替えると、大量ファイル処理でも安定したパフォーマンスを維持できる。

    function determineStrategy(files) {
      const avgSize = totalSize / files.length;
    
      if (avgSize > 50 * 1024 * 1024) return 'stream';   // 大容量ファイル
      if (files.length > 100)         return 'batch';    // 多数ファイル
      return 'standard';                                  // 通常
    }
    

    それぞれの使い所:

    • stream 処理な50MB 超のファイル。1MB チャンク単位で処理してメモリを抑える
    • batch 処理な100 ファイル以上。最大同時処理数(例:10 ファイル並列)を制御
    • standard 処理なそれ以外のデフォルト

    実測では 1,000 ファイルを 1 秒以下で処理できた。並行数の調整(maxConcurrentFiles)が効いている。


    7. メモリ管理

    Electron アプリはメモリリークを起こしやすい。特に長時間起動するツールでは段階的なメモリ監視が重要。

    const THRESHOLDS = {
      warning:   150 * 1024 * 1024,  // 150MB - 軽度クリーンアップ
      critical:  200 * 1024 * 1024,  // 200MB - 積極的クリーンアップ
      emergency: 250 * 1024 * 1024,  // 250MB - 全面クリーンアップ
    };
    
    // 10秒ごとにチェック
    setInterval(() => checkMemory(), 10000);
    

    メモリリークの主な原因と対策:

    • EventListener の付け外し忘れ → コンポーネント破棄時に必ず removeEventListener
    • Timer の残留clearTimeout / clearInterval を確実に
    • 循環参照 → WeakMap / WeakRef の活用
    • 履歴データの無制限蓄積 → 一定件数を超えたら古いものを削除

    8. 非同期処理の徹底

    最初期のハマりどころその1。 async/await の使い方が中途半端だと SyntaxError: await is only valid in async functions が出る。

    // ❌ 関数が async でないのに await
    createMainWindow() {
      await logger.error('failed');  // SyntaxError!
    }
    
    // ✅ 関数全体を async に
    async createMainWindow() {
      await logger.error('failed');
    }
    

    IPC ハンドラーはほぼ全て async にする。UI をブロックしないよう、重い処理は全て非同期で実行する。


    9. 開発環境・ツール設定のはまりどころ

    ESLint の設定値は大文字小文字を厳密に

    // ❌ タイポ(大文字混入)
    ecmaVersion: 'laTEST'
    
    // ✅
    ecmaVersion: 'latest'
    

    Jest の設定プロパティは camelCase

    // ❌
    module.exports = {
      TESTEnvironment: 'node',  // Unknown option エラー
      TESTMatch: ['...']
    }
    
    // ✅
    module.exports = {
      testEnvironment: 'node',
      testMatch: ['<rootDir>/tests/**/*.test.js']
    }
    

    electron-builderdevDependencies

    dependencies に入れると本番ビルドに含まれてサイズが膨れる。

    {
      "devDependencies": {
        "electron-builder": "^24.0.0"
      }
    }
    

    10. テスト戦略

    開発版とパッケージ版の両方でテストする

    npm start で動いても .app で動かないことがある(前述の通り)。各 Task 完了時に必ず両方確認する習慣をつける:

    npm start              # 開発版
    npm run build:dev      # パッケージ版ビルド
    open dist/mac-arm64/App.app  # パッケージ版確認
    

    Jest のテストファイル命名

    *.test.js の形式でないと Jest が認識しない。

    テストコードと実装のエラーメッセージは完全一致が必要

    // ❌ 実装が 'Invalid path' を返すのに
    expect(error).rejects.toThrow('Invalid input');
    
    // ✅
    expect(error).rejects.toThrow('Invalid path');
    

    非同期テストのクリーンアップ

    afterEach(async () => {
      // タイマー・リスナー・DB接続など確実にクリア
      await cleanup();
    });
    

    11. ビルド・配布の設定

    package.jsonbuild セクションで主要な設定をまとめる:

    {
      "build": {
        "appId": "com.yourapp.name",
        "productName": "Your App Name",
        "copyright": "Copyright © 2025 Your Name",
        "compression": "maximum",
        "files": ["src/<strong>/*", "config/</strong>/*", "package.json"],
        "extraResources": ["config/**/*"],
        "mac": {
          "hardenedRuntime": true,
          "category": "public.app-category.developer-tools",
          "target": [
            { "target": "dmg", "arch": ["x64", "arm64"] },
            { "target": "zip", "arch": ["x64", "arm64"] }
          ]
        },
        "win": {
          "target": [{ "target": "nsis", "arch": ["x64"] }]
        }
      }
    }
    

    macOS の注意点:

    • hardenedRuntime: true を設定しておかないと Gatekeeper に引っかかる
    • 初回起動時は右クリック→「開く」が必要(codesign なしの場合)

    ビルドスクリプト:

    {
      "scripts": {
        "start": "electron .",
        "build": "electron-builder",
        "build:production": "NODE_ENV=production electron-builder"
      }
    }
    

    12. ロギング戦略

    デバッグしやすいアプリにするには、最初からロギング戦略を持つことが重要。特にパッケージング後の問題調査にログが効く。

    パッケージング状態をログに出す:

    console.log(`📦 Is packaged: ${app.isPackaged}`);
    console.log(`🗂️ Resources: ${process.resourcesPath || 'N/A'}`);
    console.log(`📁 Config path: ${resolvedPath}`);
    

    構造化ログを使う:

    単なる console.log ではなく、{ context: { ... }, level: 'info' } のような構造化データで出力しておくと、AI による分析やパフォーマンス統計の集計が容易になる。

    ログレベルを使い分ける:

    • debug → 詳細な内部状態
    • info → 主要な処理の開始・完了
    • warn → 想定内の問題(メモリ警告など)
    • error → 想定外の問題(ファイル読み込み失敗など)

    13. まとめ:次回への7つのチェックリスト

    Electron アプリを作るとき、最初から以下を意識しておけばほとんどのハマりポイントは回避できる。

    1. セキュリティ設定は Task 1 で完了させる
      nodeIntegration: false + contextIsolation: true + preload.js 経由の API 公開

    2. 環境判定は app.isPackaged で統一する
      NODE_ENV__dirname への依存は危険。動的なパス解決を標準化する

    3. HTML 読み込みは loadFile に統一する
      loadURL('file://...') との条件分岐は不要

    4. シングルインスタンス制御を最初から入れる
      app.requestSingleInstanceLock() を忘れると2回目起動で詰まる

    5. イベントハンドラーの重複登録を防ぐ
      closed などのウィンドウイベントは1か所のみ登録する

    6. 開発版・パッケージ版の両方でテストする
      各 Task 完了のたびに .app 動作確認を必須にする

    7. 非同期処理を徹底する
      ファイルI/O・IPC ハンドラー・ロガー呼び出しは全て async/await で統一する


    以上、実際のプロジェクトで発生したエラーと解決策をベースにまとめました。
    Electron は学習コストはあるが、Web 技術でネイティブアプリが作れて良いです。


    関連記事

    複数ルールで一括grep置換ツール「Multi Grep Replacer」 Web開発やプログラミングで、こんな経験はありませんか?🔄 同じ置換作業を何度も繰り返している😓 サクラエディタで5つの置換パターンを1つずつ実行するのが面倒めんどくさかったので、複数ルールで一括置換できる「Multi Grep Replacer」 を作ってみ...  続きを読む