Electron React-app(todoアプリ)でElectronを理解する
はじめに
ボイラープレートで作成する記事が多かったのですが、内容が古くなっており、今回作成したアプリを開発に使いたいと考えているため
できる限り自分で環境構築することを目標にアプリを作りました
以下は備忘録としてまとめたものなので見にくくなっているかもしれませんがご了承ください
Electron アプリの基本
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) 通信とはmain
とrenderer
でデータのやり取りをすること
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)
#参考
公式
TODO アプリ化
作成したもの
Discussion