😽

Electron React-app(todoアプリ)でElectronを理解する

2025/03/12に公開

はじめに

ボイラープレートで作成する記事が多かったのですが、内容が古くなっており、今回作成したアプリを開発に使いたいと考えているため
できる限り自分で環境構築することを目標にアプリを作りました

https://github.com/nto300002/electron-react

以下は備忘録としてまとめたものなので見にくくなっているかもしれませんがご了承ください

Electron アプリの基本

https://www.electronjs.org/ja/docs/latest/tutorial/tutorial-first-app

Node.js でデスクトップアプリを作成するための開発フレームワーク

仕様

Electron

  • レンダラープロセス
    フロントエンドを担当、preload.ts で公開された API を除く Electron の API への直接的なアクセスは制限されている
  • メインプロセス
    バックエンドを担当、DB のアクセスやアプリの起動、ウインドウの管理などを行う
    IPC通信を用いてレンダラーからのリクエストを処理する
  • IPC通信
    Inter-Process Communication
    preload.tsファイルで公開する API を定義し、それのみにアクセスできる

React

  • src
    React の基本的な構造として src を配下として展開する
  • dist
    src の内容をコンパイルして dist 配下の要素が作成される(build)
    これを読み込んでアプリケーションが動作する
  • エントリーポイント
    dist/main/main.js package.json にも記載されている
    これを実行することでアプリケーションが起動
  • renderer
    レンダラープロセスを司る
    基本的には index.html が読み込まれる

基本構造

ELECTRON-REACT/
├── dist/
│   ├── main/
│   ├── preload/
│   └── renderer/
│       ├── index.html
│       ├── renderer.js
│       └── renderer.js.map
├── node_modules/
├── src/
│   ├── main/
│   ├── preload/
│   │   └── preload.ts
│   └── renderer/
│       ├── components/
│       ├── contexts/
│       ├── pages/
│       ├── styles/
│       ├── App.tsx
│       ├── index.html
│       ├── index.tsx
│       └── types.d.ts
│   └── types/
├── .gitignore
├── package-lock.json
├── package.json
├── README.md
├── tsconfig.electron.json
├── tsconfig.json
└── webpack.config.js

メインプロセス

エントリーポイント

{
  ...
  "main": "dist/main/main.js",
  ...
}

描画の際は、build した際に立ち上がった dist ディレクトリに/main/main.jsが生成され、それが読み込まれる
src 配下の main.ts に対応

main.ts

import path from 'path';
import { app, BrowserWindow, shell, ipcMain } from 'electron';
import { autoUpdater } from 'electron-updater';
import log from 'electron-log';
import MenuBuilder from './menu';
import { resolveHtmlPath } from './util';
import Store, { Schema } from 'electron-store';

class AppUpdater {
  constructor() {
    log.transports.file.level = 'info';
    autoUpdater.logger = log;
    autoUpdater.checkForUpdatesAndNotify();
  }
}

let mainWindow: BrowserWindow | null = null;

ipcMain.on('ipc-example', async (event, arg) => {
  const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`;
  console.log(msgTemplate(arg));
  event.reply('ipc-example', msgTemplate('pong'));
});

if (process.env.NODE_ENV === 'production') {
  const sourceMapSupport = require('source-map-support');
  sourceMapSupport.install();
}

const isDebug =
  process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';

if (isDebug) {
  require('electron-debug')();
}

const installExtensions = async () => {
  const installer = require('electron-devtools-installer');
  const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
  const extensions = ['REACT_DEVELOPER_TOOLS'];

  return installer
    .default(
      extensions.map((name) => installer[name]),
      forceDownload
    )
    .catch(console.log);
};

const createWindow = async () => {
  if (isDebug) {
    await installExtensions();
  }

  const RESOURCES_PATH = app.isPackaged
    ? path.join(process.resourcesPath, 'assets')
    : path.join(__dirname, '../../assets');

  const getAssetPath = (...paths: string[]): string => {
    return path.join(RESOURCES_PATH, ...paths);
  };

  mainWindow = new BrowserWindow({
    show: false,
    width: 1024,
    height: 728,
    icon: getAssetPath('icon.png'),
    webPreferences: {
      preload: app.isPackaged
        ? path.join(__dirname, 'preload.js')
        : path.join(__dirname, '../../.erb/dll/preload.js'),
    },
  });

  mainWindow.loadURL(resolveHtmlPath('index.html'));

  mainWindow.on('ready-to-show', () => {
    if (!mainWindow) {
      throw new Error('"mainWindow" is not defined');
    }
    if (process.env.START_MINIMIZED) {
      mainWindow.minimize();
    } else {
      mainWindow.show();
    }
  });

  mainWindow.on('closed', () => {
    mainWindow = null;
  });

  const menuBuilder = new MenuBuilder(mainWindow);
  menuBuilder.buildMenu();

  // Open urls in the user's browser
  mainWindow.webContents.setWindowOpenHandler((edata) => {
    shell.openExternal(edata.url);
    return { action: 'deny' };
  });

  // Remove this if your app does not use auto updates
  // eslint-disable-next-line
  new AppUpdater();
};

/**
 * Add event listeners...
 */

app.on('window-all-closed', () => {
  // Respect the OSX convention of having the application in memory even
  // after all windows have been closed
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app
  .whenReady()
  .then(() => {
    createWindow();
    app.on('activate', () => {
      // On macOS it's common to re-create a window in the app when the
      // dock icon is clicked and there are no other windows open.
      if (mainWindow === null) createWindow();
    });
  })
  .catch(console.log);

// ---- 新規追加 ----

// Todo型の定義
interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: string;
}

// Store型の定義
interface StoreSchema {
  todoList: Todo[];
}

// 型付きStoreの作成
const storeData = new Store<StoreSchema>();

// 型アサーション - Storeインスタンスに対してgetとsetメソッドを持つことを明示
const typedStore = storeData as unknown as {
  get: <T>(key: keyof StoreSchema, defaultValue?: T) => T;
  set: <T>(key: keyof StoreSchema, value: T) => void;
};

ipcMain.handle('loadTodoList', async (event, data) => {
  return typedStore.get('todoList', []);
});

ipcMain.handle('storeTodoList', async (event, data) => {
  typedStore.set('todoList', data);
});

ipcMain.handle('deleteTodoList', async (event, id) => {
  const todoList = typedStore.get<Todo[]>('todoList', []);
  const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
  typedStore.set('todoList', updateTodoList);
  console.log('updateList : ' + JSON.stringify(updateTodoList));
  return updateTodoList;
});

main.ts モジュールのインポート

import path from 'path';
import { app, BrowserWindow, shell, ipcMain } from 'electron';
import { autoUpdater } from 'electron-updater';
import log from 'electron-log';
import MenuBuilder from './menu'; //別ファイル
import { resolveHtmlPath } from './util';
import Store, { Schema } from 'electron-store';

app:アプリのライフサイクル(アプリが起動してから終了するまでのイベント)を制御
BrowserWindow:デスクトップアプリらしくウインドウの作成、管理
shell:ファイルを開く、url を開くなどの操作
ipcMain:main と renderer プロセスをつなぐ

autoUpdater:その名の通り

log:ロギング(ログを残す)機能

electron-store:データ永続化

アプリケーションの自動更新機能

class AppUpdater {
  constructor() {
    log.transports.file.level = 'info';
    autoUpdater.logger = log;
    autoUpdater.checkForUpdatesAndNotify();
  }
}

新しいバージョンが利用可能になれば通知が来る

ウインドウの定義

let mainWindow: BrowserWindow | null = null;

閉じると null になる

IPC 通信

IPC (Inter-Process Communication) 通信とはmainrendererでデータのやり取りをすること
main プロセス:アプリケーション側の仕組み

  • Node.js を利用
  • GUI(ウインドウ)を管理する
ipcMain.on('ipc-example', async (event, arg) => {
  const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`;
  console.log(msgTemplate(arg));
  event.reply('ipc-example', msgTemplate('pong'));
});

renderer:ipc-example->main

開発モードの処理

if (process.env.NODE_ENV === 'production') {
  const sourceMapSupport = require('source-map-support');
  sourceMapSupport.install();
}

const isDebug =
  process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';

if (isDebug) {
  require('electron-debug')();
}

本番環境と開発環境でのモジュールの違い

開発ツール

const installExtensions = async () => {
  const installer = require('electron-devtools-installer');
  const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
  const extensions = ['REACT_DEVELOPER_TOOLS'];

  return installer
    .default(
      extensions.map((name) => installer[name]),
      forceDownload
    )
    .catch(console.log);
};

開発モードでインストールするツール群

ウィンドウの作成

const createWindow = async () => {
  // ... (開発ツールのインストール)

  const RESOURCES_PATH = app.isPackaged
    ? path.join(process.resourcesPath, 'assets')
    : path.join(__dirname, '../../assets');

  const getAssetPath = (...paths: string[]): string => {
    return path.join(RESOURCES_PATH, ...paths);
  };

  mainWindow = new BrowserWindow({
    show: false,
    width: 1024,
    height: 728,
    icon: getAssetPath('icon.png'),
    webPreferences: {
      preload: app.isPackaged
        ? path.join(__dirname, 'preload.js')
        : path.join(__dirname, '../../.erb/dll/preload.js'),
    },
  });

  mainWindow.loadURL(resolveHtmlPath('index.html'));

  // ... (ウィンドウイベントのハンドラ設定)

  const menuBuilder = new MenuBuilder(mainWindow);
  menuBuilder.buildMenu();

  // ... (URLを外部ブラウザで開く設定)

  // ... (自動更新機能のインスタンス化)
};
  • preload: レンダラー(WEB ページ)が読み込まれる前に実行されるスクリプト(ブロードスクリプト)
  • app.isPackaged: パッケージ化されているか

アプリケーションのライフサイクルイベント

app.on('window-all-closed', () => {
  // ...
});

app
  .whenReady() //初期化
  .then(() => {
    createWindow();
    app.on('activate', () => {
      // ...
    });
  })
  .catch(console.log);

メインプロセス

// Todo型の定義
interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: string;
}

// Store型の定義
interface StoreSchema {
  todoList: Todo[];
}

// 型付きStoreの作成
const storeData = new Store<StoreSchema>();

// 型アサーション - Storeインスタンスに対してgetとsetメソッドを持つことを明示
const typedStore = storeData as unknown as {
  get: <T>(key: keyof StoreSchema, defaultValue?: T) => T;
  set: <T>(key: keyof StoreSchema, value: T) => void;
};

ipcMain.handle('loadTodoList', async (event, data) => {
  return typedStore.get('todoList', []);
});

ipcMain.handle('storeTodoList', async (event, data) => {
  typedStore.set('todoList', data);
});

ipcMain.handle('deleteTodoList', async (event, id) => {
  const todoList = typedStore.get<Todo[]>('todoList', []);
  const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
  typedStore.set('todoList', updateTodoList);
  console.log('updateList : ' + JSON.stringify(updateTodoList));
  return updateTodoList;
});

ローカルに todo データを保存する(メインプロセス)

preload.ts

renderer<->main

// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';

export type Channels = 'ipc-example';

//レンダープロセスのAPIを定義
const electronHandler = {
  ipcRenderer: {
    //型を提供
    sendMessage(channel: Channels, ...args: unknown[]) {
      //->main メッセージ送信
      ipcRenderer.send(channel, ...args);
    },
    on(channel: Channels, func: (...args: unknown[]) => void) {
      //main-> メッセージをリッスン
      const subscription = (_event: IpcRendererEvent, ...args: unknown[]) =>
        func(...args);
      ipcRenderer.on(channel, subscription);

      return () => {
        ipcRenderer.removeListener(channel, subscription);
      };
    },
    once(channel: Channels, func: (...args: unknown[]) => void) {
      //*once 一度だけリッスン
      ipcRenderer.once(channel, (_event, ...args) => func(...args));
    },
  },
};

export type ElectronHandler = typeof electronHandler;

// ---- Todo ----
contextBridge.exposeInMainWorld('electron', electronHandler); //electronHandler->window.electron  レンダラーでAPIを呼び出せるようにする

contextBridge.exposeInMainWorld('db', {
  loadTodoList: () => ipcRenderer.invoke('loadTodoList'),
  storeTodoList: (todoList: Array<object>) =>
    ipcRenderer.invoke('storeTodoList', todoList),
  deleteTodoList: (id: number) => ipcRenderer.invoke('deleteTodoList', id), //ipcRenderer.invoke  メインプロセスに非同期メッセージ送信
});
// ---- Todo ----
  • リッスン(listen): 接続を受け取る

環境構築

  • プロジェクトの初期化
   mkdir electron_todo_auth
   cd electron_todo_auth
   npm init -y
  • TypeScript のセットアップ
   npm install -D typescript @types/node @types/react @types/react-dom ts-node
   npx tsc --init

tsconfig.json と tsconfig.electron.json(Electron 用の設定)を作成

  • Electron のインストール
   npm install -D electron electron-builder

builder

  • React とそのルーターのインストール
   npm install react react-dom react-router-dom
  • ビルドツールの設定(Webpack)
   npm install -D webpack webpack-cli webpack-dev-server ts-loader css-loader style-loader html-webpack-plugin
  • その他の依存関係のインストール
   npm install electron-log electron-updater electron-store googleapis dotenv
   npm install -D concurrently @types/semver
  • package.json
{
  "name": "electron_todo_auth",
  "version": "1.0.0",
  "description": "",
  "main": "dist/main/main.js",
  "scripts": {
    "start": "electron .",
    "build": "webpack && tsc -p tsconfig.electron.json",
    "dev": "concurrently \"webpack --watch\" \"tsc -p tsconfig.electron.json --watch\" \"electron .\"",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "dotenv": "^16.4.7",
    "electron-log": "^5.3.2",
    "electron-store": "^8.1.0",
    "electron-updater": "^6.3.9",
    "googleapis": "^146.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-router-dom": "^7.3.0"
  },
  "devDependencies": {
    "@types/node": "^22.13.10",
    "@types/react": "^19.0.10",
    "@types/react-dom": "^19.0.4",
    "@types/semver": "^7.5.8",
    "concurrently": "^9.1.2",
    "css-loader": "^7.1.2",
    "electron": "^35.0.1",
    "electron-builder": "^25.1.8",
    "html-webpack-plugin": "^5.6.3",
    "style-loader": "^4.0.0",
    "ts-loader": "^9.5.2",
    "ts-node": "^10.9.2",
    "typescript": "^5.8.2",
    "webpack": "^5.98.0",
    "webpack-cli": "^6.0.1",
    "webpack-dev-server": "^5.2.0"
  }
}

今後の展望

本格的にアプリ開発に入るためのデータ設計
認証機能(普通&Google)

#参考
公式
https://www.electronjs.org/ja/docs/latest/tutorial/tutorial-first-app

TODO アプリ化
https://zenn.dev/takudooon/articles/0a8474b2129a02

作成したもの
https://github.com/nto300002/electron-react

GitHubで編集を提案

Discussion