Skilore

Electron セットアップ

Vite + React + TypeScript 構成で Electron デスクトップアプリケーションの開発環境を構築し、ホットリロード・DevTools 統合まで完了させる。

86 分で読めます42,664 文字

Electron セットアップ

Vite + React + TypeScript 構成で Electron デスクトップアプリケーションの開発環境を構築し、ホットリロード・DevTools 統合まで完了させる。


この章で学ぶこと

  1. Electron のアーキテクチャ(Main / Renderer / Preload)を理解し、プロジェクトを正しく構成できるようになる
  2. Vite + React + TypeScript を使った現代的な開発環境をゼロから構築できるようになる
  3. ホットリロードと DevTools を活用した効率的な開発ワークフローを確立する

前提知識

このガイドを読む前に、以下の知識があると理解が深まります:

  • 基本的なプログラミングの知識
  • 関連する基礎概念の理解

1. Electron のアーキテクチャ

1.1 プロセスモデル

+----------------------------------------------------------+
|                    Electron アプリ                         |
+----------------------------------------------------------+
|                                                          |
|  +------------------------+                              |
|  |    Main Process        |  ← Node.js ランタイム        |
|  |    (main.ts)           |                              |
|  |                        |                              |
|  |  - BrowserWindow 管理  |                              |
|  |  - システム API        |                              |
|  |  - メニュー/トレイ     |                              |
|  |  - IPC ハンドラ        |                              |
|  +--------+---+-----------+                              |
|           |   |                                          |
|     IPC   |   |  IPC                                     |
|           |   |                                          |
|  +--------v---+--------+   +-------------------------+   |
|  |  Renderer Process   |   |  Renderer Process       |   |
|  |  (ウィンドウ 1)     |   |  (ウィンドウ 2)         |   |
|  |                     |   |                         |   |
|  |  +---------------+  |   |  +------------------+   |   |
|  |  | Preload       |  |   |  | Preload          |   |   |
|  |  | (preload.ts)  |  |   |  | (preload.ts)     |   |   |
|  |  +-------+-------+  |   |  +--------+---------+   |   |
|  |          |           |   |           |             |   |
|  |  +-------v-------+  |   |  +--------v---------+   |   |
|  |  | Web ページ     |  |   |  | Web ページ        |   |   |
|  |  | (React App)   |  |   |  | (React App)      |   |   |
|  |  +---------------+  |   |  +------------------+   |   |
|  +---------------------+   +-------------------------+   |
+----------------------------------------------------------+

1.2 各プロセスの役割

プロセス 実行環境 役割 セキュリティ
Main Node.js ウィンドウ管理、OS API、ファイル操作 フルアクセス
Preload Node.js (制限付き) Main ↔ Renderer の橋渡し contextBridge で制御
Renderer Chromium UI レンダリング(React/Vue 等) サンドボックス(Web と同等)

2. プロジェクト作成

2.1 electron-vite による構築(推奨)

コード例 1: プロジェクトの初期化

# electron-vite のスキャフォールディング(React + TypeScript テンプレート)
npm create @quick-start/electron@latest my-electron-app -- \
  --template react-ts
 
# ディレクトリに移動して依存関係をインストール
cd my-electron-app
npm install
 
# 開発サーバー起動(ホットリロード有効)
npm run dev

2.2 ディレクトリ構成

my-electron-app/
├── package.json
├── electron.vite.config.ts       ← Vite 設定(Main/Preload/Renderer 共通)
├── tsconfig.json                 ← TypeScript 設定(ルート)
├── tsconfig.node.json            ← TypeScript 設定(Main/Preload 用)
├── tsconfig.web.json             ← TypeScript 設定(Renderer 用)
│
├── src/
│   ├── main/                     ← Main プロセス
│   │   ├── index.ts              ← エントリポイント
│   │   └── ipc-handlers.ts       ← IPC ハンドラ定義
│   │
│   ├── preload/                  ← Preload スクリプト
│   │   ├── index.ts              ← contextBridge 定義
│   │   └── index.d.ts            ← 型定義
│   │
│   └── renderer/                 ← Renderer プロセス (React アプリ)
│       ├── index.html            ← HTML エントリポイント
│       ├── src/
│       │   ├── main.tsx          ← React エントリポイント
│       │   ├── App.tsx           ← ルートコンポーネント
│       │   ├── components/       ← UI コンポーネント
│       │   ├── hooks/            ← カスタムフック
│       │   └── assets/           ← 静的リソース
│       └── env.d.ts              ← Vite 環境型定義
│
├── resources/                    ← アイコン、ネイティブリソース
│   └── icon.png
├── build/                        ← ビルド設定
│   └── entitlements.mac.plist
└── out/                          ← ビルド出力

コード例 2: Main プロセス(index.ts)

// src/main/index.ts — Electron Main プロセスのエントリポイント
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
 
// メインウィンドウの参照をモジュールスコープで保持
let mainWindow: BrowserWindow | null = null
 
function createWindow(): void {
  // ブラウザウィンドウを作成
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    minWidth: 800,
    minHeight: 600,
    // macOS 用: ネイティブタブ対応
    tabbingIdentifier: 'my-app',
    show: false, // 準備完了まで非表示にしてちらつきを防止
    webPreferences: {
      // Preload スクリプトのパス
      preload: join(__dirname, '../preload/index.js'),
      // サンドボックスを有効化(セキュリティ推奨)
      sandbox: true,
      // コンテキスト分離(必須: Renderer から Node.js を直接使えなくする)
      contextIsolation: true,
      // Node.js 統合を無効化(セキュリティ推奨)
      nodeIntegration: false,
    },
  })
 
  // ウィンドウの準備が完了したら表示
  mainWindow.on('ready-to-show', () => {
    mainWindow?.show()
  })
 
  // 外部リンクはデフォルトブラウザで開く
  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
    shell.openExternal(url)
    return { action: 'deny' }
  })
 
  // 開発時は Vite Dev Server、本番時はビルド済み HTML を読み込む
  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
}
 
// Electron の初期化完了後にウィンドウを作成
app.whenReady().then(() => {
  // アプリ ID を設定(Windows の通知やタスクバーで使用)
  electronApp.setAppUserModelId('com.example.my-app')
 
  // 開発時: F12 で DevTools を開く、Ctrl+R でリロード
  app.on('browser-window-created', (_, window) => {
    optimizer.watchWindowShortcuts(window)
  })
 
  createWindow()
 
  // macOS: Dock アイコンクリック時にウィンドウを再作成
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow()
    }
  })
})
 
// macOS 以外: 全ウィンドウ閉鎖でアプリ終了
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

コード例 3: Preload スクリプト

// src/preload/index.ts — Renderer に公開する API を定義
import { contextBridge, ipcRenderer } from 'electron'
 
// contextBridge で安全に API を公開
// Renderer から window.electronAPI でアクセス可能になる
contextBridge.exposeInMainWorld('electronAPI', {
  // プラットフォーム情報
  platform: process.platform,
 
  // ファイル操作: Main プロセスに委譲
  openFile: (): Promise<string | null> =>
    ipcRenderer.invoke('dialog:openFile'),
 
  saveFile: (content: string): Promise<boolean> =>
    ipcRenderer.invoke('dialog:saveFile', content),
 
  // ストア操作
  getStoreValue: (key: string): Promise<unknown> =>
    ipcRenderer.invoke('store:get', key),
 
  setStoreValue: (key: string, value: unknown): Promise<void> =>
    ipcRenderer.invoke('store:set', key, value),
 
  // Main → Renderer のイベント受信
  onUpdateAvailable: (callback: (version: string) => void): void => {
    ipcRenderer.on('update-available', (_event, version) => {
      callback(version)
    })
  },
})
// src/preload/index.d.ts — Renderer 側で使う型定義
export interface ElectronAPI {
  platform: string
  openFile: () => Promise<string | null>
  saveFile: (content: string) => Promise<boolean>
  getStoreValue: (key: string) => Promise<unknown>
  setStoreValue: (key: string, value: unknown) => Promise<void>
  onUpdateAvailable: (callback: (version: string) => void) => void
}
 
declare global {
  interface Window {
    electronAPI: ElectronAPI
  }
}

コード例 4: Renderer(React アプリ)

// src/renderer/src/App.tsx — React ルートコンポーネント
import { useState } from 'react'
import './assets/main.css'
 
function App(): JSX.Element {
  const [fileContent, setFileContent] = useState<string | null>(null)
  const [platform] = useState(window.electronAPI.platform)
 
  // ファイルを開くボタンのハンドラ
  const handleOpenFile = async () => {
    // Preload で定義した API を呼び出す(型安全)
    const content = await window.electronAPI.openFile()
    if (content) {
      setFileContent(content)
    }
  }
 
  return (
    <div className="app">
      <header className="app-header">
        <h1>Electron + React + TypeScript</h1>
        <p>プラットフォーム: {platform}</p>
      </header>
 
      <main className="app-main">
        <button onClick={handleOpenFile} className="btn-primary">
          ファイルを開く
        </button>
 
        {fileContent && (
          <pre className="file-preview">
            {fileContent}
          </pre>
        )}
      </main>
    </div>
  )
}
 
export default App

3. Vite 設定

コード例 5: electron.vite.config.ts

// electron.vite.config.ts — Main/Preload/Renderer 統合 Vite 設定
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
 
export default defineConfig({
  // Main プロセス用設定
  main: {
    plugins: [
      // Node.js モジュールを外部化(バンドルに含めない)
      externalizeDepsPlugin()
    ],
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'src/main/index.ts')
        }
      }
    }
  },
 
  // Preload スクリプト用設定
  preload: {
    plugins: [externalizeDepsPlugin()],
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'src/preload/index.ts')
        }
      }
    }
  },
 
  // Renderer プロセス用設定(通常の Vite + React)
  renderer: {
    plugins: [react()],
    resolve: {
      alias: {
        // パスエイリアスの設定
        '@': resolve(__dirname, 'src/renderer/src')
      }
    },
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'src/renderer/index.html')
        }
      }
    }
  }
})

4. ホットリロードと DevTools

4.1 開発時の動作フロー

npm run dev 実行時:

  electron-vite dev
       |
       ├─→ Vite Dev Server 起動 (Renderer)
       |     localhost:5173
       |     HMR WebSocket 接続
       |
       ├─→ Main プロセスをビルド & 起動
       |     ファイル変更検知 → 自動再起動
       |
       └─→ Preload をビルド
             ファイル変更検知 → Renderer リロード

  変更の反映速度:
Renderer~50ms (HMR)
Main~1s (プロセス再起動)
Preload~500ms (リロード)

4.2 DevTools の活用

// 開発時のみ DevTools を自動で開く
if (is.dev) {
  mainWindow.webContents.openDevTools({ mode: 'right' })
}
 
// React DevTools の追加(開発時のみ)
// npm install --save-dev electron-devtools-installer
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'
 
app.whenReady().then(async () => {
  if (is.dev) {
    try {
      // React DevTools 拡張をインストール
      await installExtension(REACT_DEVELOPER_TOOLS)
      console.log('React DevTools をインストールしました')
    } catch (err) {
      console.error('DevTools インストールエラー:', err)
    }
  }
  createWindow()
})

5. IPC 通信のベストプラクティス

5.1 通信パターン

パターン API 方向 用途
invoke/handle ipcRenderer.invokeipcMain.handle Renderer → Main → 応答 データ取得・ダイアログ
send/on ipcRenderer.sendipcMain.on Renderer → Main (片方向) ログ送信・イベント通知
send/on webContents.sendipcRenderer.on Main → Renderer (片方向) 更新通知・状態変更

IPC ハンドラの定義

// src/main/ipc-handlers.ts — IPC ハンドラの集約定義
import { ipcMain, dialog, BrowserWindow } from 'electron'
import { readFile, writeFile } from 'fs/promises'
 
export function registerIpcHandlers(): void {
  // ファイルを開くダイアログ → ファイル内容を返す
  ipcMain.handle('dialog:openFile', async () => {
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [
        { name: 'テキストファイル', extensions: ['txt', 'md', 'json'] },
        { name: 'すべてのファイル', extensions: ['*'] },
      ],
    })
 
    if (canceled || filePaths.length === 0) return null
 
    // ファイルの内容を読み取って返す
    const content = await readFile(filePaths[0], 'utf-8')
    return content
  })
 
  // ファイルを保存
  ipcMain.handle('dialog:saveFile', async (_event, content: string) => {
    const { canceled, filePath } = await dialog.showSaveDialog({
      defaultPath: 'untitled.txt',
    })
 
    if (canceled || !filePath) return false
 
    await writeFile(filePath, content, 'utf-8')
    return true
  })
}

6. electron-store による設定管理

6.1 electron-store のセットアップ

# electron-store のインストール
npm install electron-store
// src/main/store.ts — アプリケーション設定の永続化
import Store from 'electron-store'
 
// 設定のスキーマ定義(型安全)
interface AppConfig {
  window: {
    width: number
    height: number
    x?: number
    y?: number
    isMaximized: boolean
  }
  theme: 'light' | 'dark' | 'system'
  language: string
  recentFiles: string[]
  editor: {
    fontSize: number
    fontFamily: string
    tabSize: number
    wordWrap: boolean
    lineNumbers: boolean
    minimap: boolean
    autoSave: boolean
    autoSaveInterval: number
  }
  updates: {
    autoCheck: boolean
    channel: 'stable' | 'beta'
  }
}
 
// デフォルト値の定義
const defaults: AppConfig = {
  window: {
    width: 1200,
    height: 800,
    isMaximized: false,
  },
  theme: 'system',
  language: 'ja',
  recentFiles: [],
  editor: {
    fontSize: 14,
    fontFamily: 'Consolas, "Courier New", monospace',
    tabSize: 2,
    wordWrap: true,
    lineNumbers: true,
    minimap: true,
    autoSave: true,
    autoSaveInterval: 30000,
  },
  updates: {
    autoCheck: true,
    channel: 'stable',
  },
}
 
// 型安全なストアの作成
export const store = new Store<AppConfig>({
  defaults,
  // スキーマバリデーション(オプション)
  schema: {
    theme: {
      type: 'string',
      enum: ['light', 'dark', 'system'],
    },
    'editor.fontSize': {
      type: 'number',
      minimum: 8,
      maximum: 72,
    },
    'editor.tabSize': {
      type: 'number',
      enum: [2, 4, 8],
    },
  },
  // 暗号化(機密情報を保存する場合)
  // encryptionKey: 'your-encryption-key',
  // マイグレーション(バージョン間のスキーマ変更対応)
  migrations: {
    '1.0.0': (store) => {
      // v1.0.0 へのマイグレーション
      store.set('editor.minimap', true)
    },
    '2.0.0': (store) => {
      // v2.0.0 へのマイグレーション
      store.set('updates', { autoCheck: true, channel: 'stable' })
    },
  },
})

6.2 IPC 経由での設定アクセス

// src/main/ipc-handlers.ts — 設定用 IPC ハンドラ
import { ipcMain } from 'electron'
import { store } from './store'
 
export function registerStoreHandlers(): void {
  // 設定値の取得
  ipcMain.handle('store:get', (_event, key: string) => {
    return store.get(key)
  })
 
  // 設定値の更新
  ipcMain.handle('store:set', (_event, key: string, value: unknown) => {
    store.set(key, value)
  })
 
  // 全設定の取得
  ipcMain.handle('store:getAll', () => {
    return store.store
  })
 
  // 設定のリセット
  ipcMain.handle('store:reset', () => {
    store.clear()
  })
 
  // 最近のファイル一覧に追加
  ipcMain.handle('store:addRecentFile', (_event, filePath: string) => {
    const recent = store.get('recentFiles', [])
    // 重複を除去し、先頭に追加、最大10件
    const updated = [filePath, ...recent.filter(f => f !== filePath)].slice(0, 10)
    store.set('recentFiles', updated)
    return updated
  })
}
// src/preload/index.ts — Renderer に設定 API を公開(追加分)
contextBridge.exposeInMainWorld('electronAPI', {
  // ... 既存の API ...
 
  // 設定 API
  store: {
    get: (key: string) => ipcRenderer.invoke('store:get', key),
    set: (key: string, value: unknown) => ipcRenderer.invoke('store:set', key, value),
    getAll: () => ipcRenderer.invoke('store:getAll'),
    reset: () => ipcRenderer.invoke('store:reset'),
    addRecentFile: (path: string) => ipcRenderer.invoke('store:addRecentFile', path),
  },
})
// src/renderer/src/hooks/useSettings.ts — React Hook で設定を管理
import { useState, useEffect, useCallback } from 'react'
 
interface EditorSettings {
  fontSize: number
  fontFamily: string
  tabSize: number
  wordWrap: boolean
  lineNumbers: boolean
  minimap: boolean
  autoSave: boolean
  autoSaveInterval: number
}
 
export function useSettings() {
  const [settings, setSettings] = useState<EditorSettings | null>(null)
  const [loading, setLoading] = useState(true)
 
  // 初期読み込み
  useEffect(() => {
    async function loadSettings() {
      const editor = await window.electronAPI.store.get('editor')
      setSettings(editor as EditorSettings)
      setLoading(false)
    }
    loadSettings()
  }, [])
 
  // 設定の更新
  const updateSetting = useCallback(async <K extends keyof EditorSettings>(
    key: K,
    value: EditorSettings[K]
  ) => {
    await window.electronAPI.store.set(`editor.${key}`, value)
    setSettings(prev => prev ? { ...prev, [key]: value } : null)
  }, [])
 
  return { settings, loading, updateSetting }
}

7. テスト環境の構築

7.1 テストツールの設定

# テストツールのインストール
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event jsdom
npm install --save-dev @vitest/coverage-v8
// vitest.config.ts — テスト設定
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
 
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/renderer/src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/main/',      // Main プロセスは別途テスト
        'src/preload/',   // Preload は E2E テスト
      ],
    },
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src/renderer/src'),
    },
  },
})
// src/renderer/src/test/setup.ts — テストセットアップ
import '@testing-library/jest-dom'
 
// window.electronAPI のモック
const mockElectronAPI = {
  platform: 'win32',
  openFile: vi.fn().mockResolvedValue(null),
  saveFile: vi.fn().mockResolvedValue(true),
  getStoreValue: vi.fn().mockResolvedValue(null),
  setStoreValue: vi.fn().mockResolvedValue(undefined),
  onUpdateAvailable: vi.fn(),
  store: {
    get: vi.fn().mockResolvedValue(null),
    set: vi.fn().mockResolvedValue(undefined),
    getAll: vi.fn().mockResolvedValue({}),
    reset: vi.fn().mockResolvedValue(undefined),
    addRecentFile: vi.fn().mockResolvedValue([]),
  },
}
 
Object.defineProperty(window, 'electronAPI', {
  value: mockElectronAPI,
  writable: true,
})

7.2 コンポーネントテストの例

// src/renderer/src/components/__tests__/FileExplorer.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FileExplorer } from '../FileExplorer'
 
describe('FileExplorer', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })
 
  it('ファイルを開くボタンを表示する', () => {
    render(<FileExplorer />)
    expect(screen.getByText('ファイルを開く')).toBeInTheDocument()
  })
 
  it('ファイルを開くダイアログを呼び出す', async () => {
    const user = userEvent.setup()
 
    window.electronAPI.openFile = vi.fn().mockResolvedValue('テストコンテンツ')
 
    render(<FileExplorer />)
    await user.click(screen.getByText('ファイルを開く'))
 
    expect(window.electronAPI.openFile).toHaveBeenCalledTimes(1)
    await waitFor(() => {
      expect(screen.getByText('テストコンテンツ')).toBeInTheDocument()
    })
  })
 
  it('ファイル選択をキャンセルした場合は何も表示しない', async () => {
    const user = userEvent.setup()
 
    window.electronAPI.openFile = vi.fn().mockResolvedValue(null)
 
    render(<FileExplorer />)
    await user.click(screen.getByText('ファイルを開く'))
 
    expect(screen.queryByTestId('file-content')).not.toBeInTheDocument()
  })
})

7.3 E2E テスト(Playwright)

// e2e/app.spec.ts — Electron E2E テスト
import { test, expect, _electron as electron } from '@playwright/test'
import { ElectronApplication, Page } from 'playwright'
 
let electronApp: ElectronApplication
let page: Page
 
test.beforeAll(async () => {
  // Electron アプリを起動
  electronApp = await electron.launch({
    args: ['.'],
    env: {
      ...process.env,
      NODE_ENV: 'test',
    },
  })
 
  // メインウィンドウを取得
  page = await electronApp.firstWindow()
 
  // ウィンドウの準備完了を待機
  await page.waitForLoadState('domcontentloaded')
})
 
test.afterAll(async () => {
  await electronApp.close()
})
 
test('アプリケーションが正常に起動する', async () => {
  const title = await page.title()
  expect(title).toBe('Electron + React + TypeScript')
})
 
test('ウィンドウのサイズが正しい', async () => {
  const windowState = await electronApp.evaluate(({ BrowserWindow }) => {
    const mainWindow = BrowserWindow.getAllWindows()[0]
    const { width, height } = mainWindow.getBounds()
    return { width, height }
  })
 
  expect(windowState.width).toBeGreaterThanOrEqual(800)
  expect(windowState.height).toBeGreaterThanOrEqual(600)
})
 
test('ファイルを開くボタンが機能する', async () => {
  await page.click('button:has-text("ファイルを開く")')
 
  // ダイアログはメインプロセスで処理されるため、
  // モックを使用するか、実際のファイルパスを注入する
})

8. ログ管理

// src/main/logger.ts — 構造化ログ管理
import log from 'electron-log'
import { app } from 'electron'
import path from 'path'
 
// ログファイルのパス設定
log.transports.file.resolvePathFn = () =>
  path.join(app.getPath('logs'), 'main.log')
 
// ログのフォーマット設定
log.transports.file.format = '{y}-{m}-{d} {h}:{i}:{s}.{ms} [{level}] {text}'
 
// ファイルサイズの制限(5MB でローテーション)
log.transports.file.maxSize = 5 * 1024 * 1024
 
// ログレベルの設定
if (app.isPackaged) {
  // 本番環境: warn 以上のみ
  log.transports.console.level = 'warn'
  log.transports.file.level = 'info'
} else {
  // 開発環境: 全てのログ
  log.transports.console.level = 'debug'
  log.transports.file.level = 'debug'
}
 
// カスタムログ関数
export const logger = {
  info: (message: string, data?: Record<string, unknown>) => {
    log.info(message, data ? JSON.stringify(data) : '')
  },
  warn: (message: string, data?: Record<string, unknown>) => {
    log.warn(message, data ? JSON.stringify(data) : '')
  },
  error: (message: string, error?: Error) => {
    log.error(message, error?.stack || '')
  },
  debug: (message: string, data?: Record<string, unknown>) => {
    log.debug(message, data ? JSON.stringify(data) : '')
  },
}
 
// 未捕捉エラーのハンドリング
process.on('uncaughtException', (error) => {
  logger.error('未捕捉の例外', error)
})
 
process.on('unhandledRejection', (reason) => {
  logger.error('未処理のPromise拒否', reason instanceof Error ? reason : new Error(String(reason)))
})
 
export default log

9. package.json の詳細設定

{
  "name": "my-electron-app",
  "version": "1.0.0",
  "description": "Electron + React + TypeScript デスクトップアプリ",
  "main": "./out/main/index.js",
  "author": "Your Name <your@email.com>",
  "license": "MIT",
  "homepage": "https://github.com/yourname/my-electron-app",
  "repository": {
    "type": "git",
    "url": "https://github.com/yourname/my-electron-app.git"
  },
  "scripts": {
    "dev": "electron-vite dev",
    "build": "electron-vite build",
    "preview": "electron-vite preview",
    "lint": "eslint src --ext .ts,.tsx",
    "lint:fix": "eslint src --ext .ts,.tsx --fix",
    "format": "prettier --write 'src/**/*.{ts,tsx,css}'",
    "typecheck": "tsc --noEmit",
    "test": "vitest",
    "test:coverage": "vitest --coverage",
    "test:e2e": "playwright test",
    "package:win": "electron-builder --win",
    "package:mac": "electron-builder --mac",
    "package:linux": "electron-builder --linux",
    "package:all": "electron-builder --win --mac --linux",
    "postinstall": "electron-builder install-app-deps"
  },
  "dependencies": {
    "electron-log": "^5.1.0",
    "electron-store": "^8.2.0",
    "electron-updater": "^6.1.0"
  },
  "devDependencies": {
    "@electron-toolkit/eslint-config-ts": "^1.0.0",
    "@electron-toolkit/utils": "^3.0.0",
    "@electron/notarize": "^2.3.0",
    "@quick-start/electron": "^2.0.0",
    "@testing-library/jest-dom": "^6.4.0",
    "@testing-library/react": "^14.2.0",
    "@testing-library/user-event": "^14.5.0",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@vitejs/plugin-react": "^4.2.0",
    "@vitest/coverage-v8": "^1.3.0",
    "electron": "^28.0.0",
    "electron-builder": "^24.13.0",
    "electron-vite": "^2.0.0",
    "eslint": "^8.56.0",
    "prettier": "^3.2.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.22.0",
    "typescript": "^5.3.0",
    "vitest": "^1.3.0"
  },
  "build": {
    "appId": "com.example.my-electron-app",
    "productName": "My Electron App",
    "copyright": "Copyright (C) 2024 Your Name",
    "directories": {
      "output": "dist",
      "buildResources": "build"
    },
    "files": [
      "out/**/*",
      "!node_modules/**/*"
    ],
    "win": {
      "target": ["nsis", "portable"],
      "icon": "resources/icon.ico"
    },
    "mac": {
      "target": ["dmg", "zip"],
      "icon": "resources/icon.icns",
      "category": "public.app-category.productivity"
    },
    "linux": {
      "target": ["AppImage", "deb"],
      "icon": "resources/icons",
      "category": "Utility"
    }
  }
}

10. セキュリティチェックリスト

// セキュリティ設定の検証ユーティリティ
import { BrowserWindow } from 'electron'
 
function validateSecurityConfig(win: BrowserWindow): void {
  const webPreferences = win.webContents.getWebPreferences()
 
  // 必須: コンテキスト分離が有効であること
  if (!webPreferences.contextIsolation) {
    console.error('[セキュリティ] contextIsolation が無効です!')
  }
 
  // 必須: Node.js 統合が無効であること
  if (webPreferences.nodeIntegration) {
    console.error('[セキュリティ] nodeIntegration が有効です!')
  }
 
  // 推奨: サンドボックスが有効であること
  if (!webPreferences.sandbox) {
    console.warn('[セキュリティ] sandbox が無効です')
  }
 
  // 推奨: webSecurity が有効であること
  if (webPreferences.webSecurity === false) {
    console.error('[セキュリティ] webSecurity が無効です!')
  }
}
 
// CSP (Content Security Policy) の設定
function setupCSP(win: BrowserWindow): void {
  win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
    callback({
      responseHeaders: {
        ...details.responseHeaders,
        'Content-Security-Policy': [
          "default-src 'self'",
          "script-src 'self'",
          "style-src 'self' 'unsafe-inline'",
          "img-src 'self' data: https:",
          "font-src 'self' data:",
          "connect-src 'self' https://api.example.com",
        ].join('; '),
      },
    })
  })
}
 
// 外部リンクのナビゲーションを防止
function preventNavigation(win: BrowserWindow): void {
  // ウィンドウ内でのナビゲーションを制限
  win.webContents.on('will-navigate', (event, url) => {
    const appUrl = new URL(win.webContents.getURL())
    const targetUrl = new URL(url)
 
    // 異なるオリジンへのナビゲーションを防止
    if (targetUrl.origin !== appUrl.origin) {
      event.preventDefault()
      // 外部ブラウザで開く
      require('electron').shell.openExternal(url)
    }
  })
 
  // 新しいウィンドウの作成を制限
  win.webContents.setWindowOpenHandler(({ url }) => {
    require('electron').shell.openExternal(url)
    return { action: 'deny' }
  })
}

11. アンチパターン

アンチパターン 1: nodeIntegration を有効にする

// NG: Renderer で Node.js API に直接アクセス可能にする
const win = new BrowserWindow({
  webPreferences: {
    nodeIntegration: true,       // 危険: Renderer から fs, child_process 等が使える
    contextIsolation: false,     // 危険: Preload と Renderer のコンテキストが共有
  }
})
// OK: contextIsolation + Preload で安全に API を公開
const win = new BrowserWindow({
  webPreferences: {
    nodeIntegration: false,      // Node.js 統合を無効化
    contextIsolation: true,      // コンテキスト分離を有効化
    sandbox: true,               // サンドボックスを有効化
    preload: join(__dirname, 'preload.js'),
  }
})

アンチパターン 2: IPC チャネル名をハードコードで散在させる

// NG: 文字列リテラルが Main/Preload/Renderer に散在 → タイポの温床
// main.ts
ipcMain.handle('get-user-data', ...)
// preload.ts
ipcRenderer.invoke('get-userData')  // タイポに気づけない
// OK: チャネル名を定数として一元管理
// src/shared/ipc-channels.ts
export const IPC_CHANNELS = {
  GET_USER_DATA: 'user:getData',
  SET_USER_DATA: 'user:setData',
  OPEN_FILE: 'dialog:openFile',
  SAVE_FILE: 'dialog:saveFile',
} as const
 
// 型安全に使用
import { IPC_CHANNELS } from '../shared/ipc-channels'
ipcMain.handle(IPC_CHANNELS.GET_USER_DATA, ...)
ipcRenderer.invoke(IPC_CHANNELS.GET_USER_DATA)

12. デバッグとトラブルシューティング

12.1 Main プロセスのデバッグ

// launch.json — VS Code でのデバッグ設定
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug Main Process",
      "type": "node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron-vite",
      "args": ["dev", "--inspect=5858"],
      "sourceMaps": true,
      "outFiles": ["${workspaceFolder}/out/**/*.js"],
      "console": "integratedTerminal",
      "env": {
        "NODE_ENV": "development"
      }
    },
    {
      "name": "Debug Renderer Process",
      "type": "chrome",
      "request": "attach",
      "port": 9222,
      "webRoot": "${workspaceFolder}/src/renderer/src",
      "sourceMapPathOverrides": {
        "webpack:///./src/*": "${webRoot}/*"
      }
    }
  ],
  "compounds": [
    {
      "name": "Debug All",
      "configurations": ["Debug Main Process", "Debug Renderer Process"]
    }
  ]
}

12.2 よくあるエラーと解決策

// エラー 1: "Cannot use import statement outside a module"
// 原因: Main プロセスの ESM/CJS 設定の不整合
// 解決: electron.vite.config.ts で正しい設定を行う
 
// electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
 
export default defineConfig({
  main: {
    plugins: [externalizeDepsPlugin()],
    build: {
      rollupOptions: {
        output: {
          format: 'cjs', // Main プロセスは CJS を使用
        },
      },
    },
  },
  preload: {
    plugins: [externalizeDepsPlugin()],
    build: {
      rollupOptions: {
        output: {
          format: 'cjs', // Preload も CJS
        },
      },
    },
  },
  renderer: {
    // Renderer は ESM で問題なし
  },
})
// エラー 2: "contextBridge API can only be used when contextIsolation is enabled"
// 原因: BrowserWindow の webPreferences で contextIsolation が false
// 解決: 必ず contextIsolation: true を設定する
 
// エラー 3: "Electron Security Warning (Insecure Content-Security-Policy)"
// 原因: CSP が設定されていない
// 解決: セクション10の CSP 設定を適用する
 
// エラー 4: IPC ハンドラが undefined を返す
// 原因: handle の登録前に invoke が呼ばれている
// 解決: app.whenReady() の中でハンドラを登録する
import { app, ipcMain } from 'electron'
 
app.whenReady().then(() => {
  // IPC ハンドラは app.whenReady() 内で登録する
  ipcMain.handle('channel', async (_event, ...args) => {
    // ハンドラの処理
    return result
  })
 
  // ウィンドウの作成もここで行う
  createWindow()
})

12.3 パフォーマンスプロファイリング

// src/main/performance.ts — パフォーマンス計測ユーティリティ
import { performance, PerformanceObserver } from 'perf_hooks'
import { logger } from './logger'
 
// パフォーマンス計測の開始
export function startMeasure(name: string): void {
  performance.mark(`${name}-start`)
}
 
// パフォーマンス計測の終了とログ出力
export function endMeasure(name: string): number {
  performance.mark(`${name}-end`)
  performance.measure(name, `${name}-start`, `${name}-end`)
 
  const entries = performance.getEntriesByName(name)
  const duration = entries[entries.length - 1]?.duration ?? 0
 
  logger.info(`[Performance] ${name}: ${duration.toFixed(2)}ms`)
 
  // マークをクリーンアップ
  performance.clearMarks(`${name}-start`)
  performance.clearMarks(`${name}-end`)
  performance.clearMeasures(name)
 
  return duration
}
 
// 起動時間の計測例
export function measureStartupTime(): void {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      logger.info(`[Startup] ${entry.name}: ${entry.duration.toFixed(2)}ms`)
    }
  })
 
  observer.observe({ entryTypes: ['measure'] })
 
  performance.mark('app-start')
 
  app.on('ready', () => {
    performance.mark('app-ready')
    performance.measure('App Ready Time', 'app-start', 'app-ready')
  })
}

13. 環境変数と設定の管理

// src/main/env.ts — 環境変数の型安全な管理
import { app } from 'electron'
import path from 'path'
 
interface AppEnvironment {
  isDev: boolean
  isProd: boolean
  isTest: boolean
  appVersion: string
  platform: NodeJS.Platform
  arch: string
  userDataPath: string
  logPath: string
  tempPath: string
}
 
export function getAppEnvironment(): AppEnvironment {
  return {
    isDev: !app.isPackaged,
    isProd: app.isPackaged,
    isTest: process.env.NODE_ENV === 'test',
    appVersion: app.getVersion(),
    platform: process.platform,
    arch: process.arch,
    userDataPath: app.getPath('userData'),
    logPath: app.getPath('logs'),
    tempPath: app.getPath('temp'),
  }
}
 
// .env ファイルの読み込み(開発環境用)
import { config } from 'dotenv'
 
if (!app.isPackaged) {
  config({
    path: path.join(app.getAppPath(), '.env.development'),
  })
}
 
// 環境変数のバリデーション
function validateEnv(): void {
  const required = ['API_BASE_URL'] as const
 
  for (const key of required) {
    if (!process.env[key]) {
      throw new Error(`環境変数 ${key} が設定されていません`)
    }
  }
}

実践演習

演習1: 基本的な実装

以下の要件を満たすコードを実装してください。

要件:

  • 入力データの検証を行うこと
  • エラーハンドリングを適切に実装すること
  • テストコードも作成すること
# 演習1: 基本実装のテンプレート
class Exercise1:
    """基本的な実装パターンの演習"""
 
    def __init__(self):
        self.data = []
 
    def validate_input(self, value):
        """入力値の検証"""
        if value is None:
            raise ValueError("入力値がNoneです")
        return True
 
    def process(self, value):
        """データ処理のメインロジック"""
        self.validate_input(value)
        self.data.append(value)
        return self.data
 
    def get_results(self):
        """処理結果の取得"""
        return {
            'count': len(self.data),
            'data': self.data
        }
 
# テスト
def test_exercise1():
    ex = Exercise1()
    assert ex.process(1) == [1]
    assert ex.process(2) == [1, 2]
    assert ex.get_results()['count'] == 2
 
    try:
        ex.process(None)
        assert False, "例外が発生するべき"
    except ValueError:
        pass
 
    print("全テスト合格!")
 
test_exercise1()

演習2: 応用パターン

基本実装を拡張して、以下の機能を追加してください。

# 演習2: 応用パターン
from typing import List, Dict, Optional
from datetime import datetime
 
class AdvancedExercise:
    """応用パターンの演習"""
 
    def __init__(self, max_size: int = 100):
        self._items: List[Dict] = []
        self._max_size = max_size
        self._created_at = datetime.now()
 
    def add(self, key: str, value: any) -> bool:
        """アイテムの追加(サイズ制限付き)"""
        if len(self._items) >= self._max_size:
            return False
        self._items.append({
            'key': key,
            'value': value,
            'timestamp': datetime.now().isoformat()
        })
        return True
 
    def find(self, key: str) -> Optional[Dict]:
        """キーによる検索"""
        for item in reversed(self._items):
            if item['key'] == key:
                return item
        return None
 
    def remove(self, key: str) -> bool:
        """キーによる削除"""
        for i, item in enumerate(self._items):
            if item['key'] == key:
                self._items.pop(i)
                return True
        return False
 
    def stats(self) -> Dict:
        """統計情報"""
        return {
            'total_items': len(self._items),
            'max_size': self._max_size,
            'usage_percent': len(self._items) / self._max_size * 100,
            'uptime': str(datetime.now() - self._created_at)
        }
 
# テスト
def test_advanced():
    ex = AdvancedExercise(max_size=3)
    assert ex.add("a", 1) == True
    assert ex.add("b", 2) == True
    assert ex.add("c", 3) == True
    assert ex.add("d", 4) == False  # サイズ制限
    assert ex.find("b")['value'] == 2
    assert ex.remove("b") == True
    assert ex.find("b") is None
    stats = ex.stats()
    assert stats['total_items'] == 2
    print("応用テスト全合格!")
 
test_advanced()

演習3: パフォーマンス最適化

以下のコードのパフォーマンスを改善してください。

# 演習3: パフォーマンス最適化
import time
from functools import lru_cache
 
# 最適化前(O(n^2))
def slow_search(data: list, target: int) -> int:
    """非効率な検索"""
    for i in range(len(data)):
        for j in range(i + 1, len(data)):
            if data[i] + data[j] == target:
                return (i, j)
    return (-1, -1)
 
# 最適化後(O(n))
def fast_search(data: list, target: int) -> tuple:
    """ハッシュマップを使った効率的な検索"""
    seen = {}
    for i, num in enumerate(data):
        complement = target - num
        if complement in seen:
            return (seen[complement], i)
        seen[num] = i
    return (-1, -1)
 
# ベンチマーク
def benchmark():
    import random
    data = list(range(5000))
    random.shuffle(data)
    target = data[100] + data[4000]
 
    start = time.time()
    result1 = slow_search(data, target)
    slow_time = time.time() - start
 
    start = time.time()
    result2 = fast_search(data, target)
    fast_time = time.time() - start
 
    print(f"非効率版: {slow_time:.4f}秒")
    print(f"効率版:   {fast_time:.6f}秒")
    print(f"高速化率: {slow_time/fast_time:.0f}倍")
 
benchmark()

ポイント:

  • アルゴリズムの計算量を意識する
  • 適切なデータ構造を選択する
  • ベンチマークで効果を測定する

設計判断ガイド

選択基準マトリクス

技術選択を行う際の判断基準を以下にまとめます。

判断基準 重視する場合 妥協できる場合
パフォーマンス リアルタイム処理、大規模データ 管理画面、バッチ処理
保守性 長期運用、チーム開発 プロトタイプ、短期プロジェクト
スケーラビリティ 成長が見込まれるサービス 社内ツール、固定ユーザー
セキュリティ 個人情報、金融データ 公開データ、社内利用
開発速度 MVP、市場投入スピード 品質重視、ミッションクリティカル

アーキテクチャパターンの選択

アーキテクチャ選択フロー
① チーム規模は?
├─ 小規模(1-5人)→ モノリス
└─ 大規模(10人+)→ ②へ
② デプロイ頻度は?
├─ 週1回以下 → モノリス + モジュール分割
└─ 毎日/複数回 → ③へ
③ チーム間の独立性は?
├─ 高い → マイクロサービス
└─ 中程度 → モジュラーモノリス

トレードオフの分析

技術的な判断には必ずトレードオフが伴います。以下の観点で分析を行いましょう:

1. 短期 vs 長期のコスト

  • 短期的に速い方法が長期的には技術的負債になることがある
  • 逆に、過剰な設計は短期的なコストが高く、プロジェクトの遅延を招く

2. 一貫性 vs 柔軟性

  • 統一された技術スタックは学習コストが低い
  • 多様な技術の採用は適材適所が可能だが、運用コストが増加

3. 抽象化のレベル

  • 高い抽象化は再利用性が高いが、デバッグが困難になる場合がある
  • 低い抽象化は直感的だが、コードの重複が発生しやすい
# 設計判断の記録テンプレート
class ArchitectureDecisionRecord:
    """ADR (Architecture Decision Record) の作成"""
 
    def __init__(self, title: str):
        self.title = title
        self.context = ""
        self.decision = ""
        self.consequences = []
        self.alternatives = []
 
    def set_context(self, context: str):
        """背景と課題の記述"""
        self.context = context
        return self
 
    def set_decision(self, decision: str):
        """決定内容の記述"""
        self.decision = decision
        return self
 
    def add_consequence(self, consequence: str, positive: bool = True):
        """結果の追加"""
        self.consequences.append({
            'description': consequence,
            'type': 'positive' if positive else 'negative'
        })
        return self
 
    def add_alternative(self, name: str, reason_rejected: str):
        """却下した代替案の追加"""
        self.alternatives.append({
            'name': name,
            'reason_rejected': reason_rejected
        })
        return self
 
    def to_markdown(self) -> str:
        """Markdown形式で出力"""
        md = f"# ADR: {self.title}\n\n"
        md += f"## 背景\n{self.context}\n\n"
        md += f"## 決定\n{self.decision}\n\n"
        md += "## 結果\n"
        for c in self.consequences:
            icon = "✅" if c['type'] == 'positive' else "⚠️"
            md += f"- {icon} {c['description']}\n"
        md += "\n## 却下した代替案\n"
        for a in self.alternatives:
            md += f"- **{a['name']}**: {a['reason_rejected']}\n"
        return md

14. FAQ

Q1: electron-vite と electron-forge + Vite の違いは何か?

A: electron-vite は Vite を Electron 向けに最適化した統合ツールであり、Main/Preload/Renderer の3プロセスを1つの設定ファイルで管理できる。electron-forge は Electron 公式のビルドツールチェーンであり、パッケージング・署名・配布まで含むフルスタックツールである。新規プロジェクトでは開発体験の良い electron-vite で開発し、ビルド・配布には electron-forge または electron-builder を併用する構成が多い。

Q2: Electron アプリのメモリ使用量が大きいのはなぜか?

A: Electron は Chromium を同梱しているため、最低でも約 80-100MB のメモリを消費する。各ウィンドウが独立した Renderer プロセスを持つことも要因の一つである。対策としては、(1) 不要なウィンドウの遅延生成、(2) バックグラウンドウィンドウの backgroundThrottling 有効化、(3) V8 スナップショットの活用が挙げられる。

Q3: Electron で React 以外のフレームワーク(Vue, Svelte)は使えるか?

A: はい。Renderer プロセスは通常の Web アプリと同じであるため、任意のフレームワークが使用可能である。electron-vite は React / Vue / Svelte / Solid のテンプレートを公式に提供している。

Q4: Electron アプリの起動速度を改善するにはどうすればよいか?

A: 主な対策として以下が挙げられる。(1) Preload スクリプトの最小化 -- 不要なモジュールの読み込みを避ける。(2) メインウィンドウの show: false 設定と ready-to-show イベントでの表示 -- 白い画面のちらつきを防ぐ。(3) ネイティブモジュールの遅延読み込み -- 起動時に全モジュールをロードしない。(4) V8 コードキャッシュの活用 -- v8-compile-cache パッケージの使用。(5) スプラッシュスクリーンの活用 -- 体感速度の向上。

Q5: Electron アプリのバイナリサイズを削減するには?

A: Electron アプリは Chromium を同梱するため、最低でも 50-80MB 程度のサイズになる。削減策としては、(1) electron-builder の asar アーカイブを有効化する、(2) 不要な node_modules を除外する(files オプションで制御)、(3) devDependencies がバンドルに含まれないことを確認する、(4) プラットフォーム固有のビルドで不要な OS のコードを排除する、(5) サイズが気になる場合は Tauri への移行を検討する(バイナリサイズが 2-10MB 程度)。

Q6: 自動更新の仕組みはどうなっているか?

A: electron-updater パッケージを使用する。更新ファイルを GitHub Releases、S3、またはプライベートサーバーにホストし、アプリ起動時に更新チェックを行う。Windows では NSIS インストーラ、macOS では DMG/ZIP の差分更新に対応している。コード署名が正しく設定されていれば、ユーザーにセキュリティ警告を表示せずに更新が可能である。


FAQ

Q1: このトピックを学ぶ上で最も重要なポイントは何ですか?

実践的な経験を積むことが最も重要です。理論だけでなく、実際にコードを書いて動作を確認することで理解が深まります。

Q2: 初心者がよく陥る間違いは何ですか?

基礎を飛ばして応用に進むことです。このガイドで説明している基本概念をしっかり理解してから、次のステップに進むことをお勧めします。

Q3: 実務ではどのように活用されていますか?

このトピックの知識は、日常的な開発業務で頻繁に活用されます。特にコードレビューやアーキテクチャ設計の際に重要になります。


15. まとめ

トピック キーポイント
アーキテクチャ Main(Node.js)+ Renderer(Chromium)+ Preload(橋渡し)
プロジェクト作成 create @quick-start/electron で React+TS テンプレートを生成
Vite 統合 electron-vite が Main/Preload/Renderer を一括管理
ホットリロード Renderer は HMR(~50ms)、Main は自動再起動(~1s)
IPC 通信 invoke/handle パターンが推奨。チャネル名は定数化
設定管理 electron-store でスキーマバリデーション付き永続化
テスト Vitest(Unit)+ Playwright(E2E)の二層構成
ログ管理 electron-log でファイルローテーション付きログ出力
セキュリティ contextIsolation: true + sandbox: true + CSP 設定が必須
デバッグ VS Code 統合デバッガで Main/Renderer 両プロセスをデバッグ
DevTools 開発時は自動オープン + React DevTools 拡張

次に読むべきガイド


参考文献

  1. Electron, "Official Documentation", https://www.electronjs.org/docs/latest/
  2. electron-vite, "Getting Started", https://electron-vite.org/guide/
  3. Electron, "Security Best Practices", https://www.electronjs.org/docs/latest/tutorial/security
  4. Electron, "Process Model", https://www.electronjs.org/docs/latest/tutorial/process-model