Electron + Reactをさわってみる(ToDoリスト)
はじめに
Electronとは
JavaScript、HTML、CSS によるデスクトップアプリケーションを構築するフレームワーク
です。興味はありましたが、あまり触ったことなかったので改めて入門してみました。
ついでに、UIの実装にはReactを使います。
こちらも理解を深めてみました。
試したみたこと
要素の追加、削除、修正できるToDoリストをつくりました。
下記の記事を参考にして、削除・修正の要素を加えています。
ElectronでReactをあつかう
Electron React Boilerplate
をクローンして使います。
ソースコード全体
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);
// ---- 新規追加 ----
const storeData = new Store();
ipcMain.handle('loadTodoList', async (event, data) => {
return storeData.get('todoList');
});
ipcMain.handle('storeTodoList', async (event, data) => {
storeData.set('todoList', data);
});
ipcMain.handle('deleteTodoList', async (event, id) => {
const todoList = storeData.get('todoList');
const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
storeData.set('todoList', updateTodoList);
console.log("updateList : " + JSON.stringify(updateTodoList))
return updateTodoList;
})
// ---- 新規追加 ----
preload.ts
preload.ts
// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
export type Channels = 'ipc-example';
const electronHandler = {
ipcRenderer: {
sendMessage(channel: Channels, ...args: unknown[]) {
ipcRenderer.send(channel, ...args);
},
on(channel: Channels, func: (...args: unknown[]) => void) {
const subscription = (_event: IpcRendererEvent, ...args: unknown[]) =>
func(...args);
ipcRenderer.on(channel, subscription);
return () => {
ipcRenderer.removeListener(channel, subscription);
};
},
once(channel: Channels, func: (...args: unknown[]) => void) {
ipcRenderer.once(channel, (_event, ...args) => func(...args));
},
},
};
export type ElectronHandler = typeof electronHandler;
// ---- 新規追加 ----
contextBridge.exposeInMainWorld('electron', electronHandler);
contextBridge.exposeInMainWorld('db', {
loadTodoList: () => ipcRenderer.invoke('loadTodoList'),
storeTodoList: (todoList: Array<object>) =>
ipcRenderer.invoke('storeTodoList', todoList),
deleteTodoList: (id: number) => ipcRenderer.invoke('deleteTodoList', id),
});
// ---- 新規追加 ----
App.tsx
App.tsx
import { MemoryRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import './App.css';
// データ型を定義
interface Todo{
id: number;
text: string;
completed: boolean;
}
interface ElectronWindow extends Window {
db: {
loadTodoList: () => Promise<Array<Todo> | null>;
storeTodoList: (todoList: Array<Todo>) => Promise<void>;
deleteTodoList: (id: number) => Promise<Array<Todo> | null>;
}
}
declare const window: ElectronWindow;
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<HomeScreen />} />
</Routes>
</Router>
);
}
// データ操作
// ToDoリストを読み込み
const loadTodoList = async() : Promise<Array<Todo> | null> => {
const todoList = await window.db.loadTodoList();
return todoList
};
// Todoリストを保存
const storeTodoList = async (todoList: Array<Todo>): Promise<void> => {
await window.db.storeTodoList(todoList);
};
const HomeScreen = () => {
const [text, setText] = useState<string>('');
const [todoList, setTodoList] = useState<Array<Todo>>([]);
useEffect(() => {
loadTodoList().then((todoList) => {
if(todoList){
setTodoList(todoList);
}
});
}, []);
const onSubmit = () => {
// ボタンクリック時にtodoListに新しいToDOを追加
if(text !== ''){
const newTodoList: Array<Todo> = [
{
id: new Date().getTime(),
text: text,
completed: false
},
...todoList, // スプレッド構文
];
console.log("newToDoList : " + JSON.stringify(newTodoList))
setTodoList(newTodoList);
storeTodoList(newTodoList);
// テキストフィールドを空にする
setText('');
}
}
const onCheck = (newTodo: Todo) => {
const newTodoList = todoList.map((todo) => {
return todo.id == newTodo.id
?{...newTodo, completed: !newTodo.completed}
: todo;
});
console.log("newTodoList : " + newTodoList);
setTodoList(newTodoList);
storeTodoList(newTodoList);
}
const onDelete = (id: number) => {
const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
console.log("updateToDoList : " + updateTodoList);
setTodoList(updateTodoList)
storeTodoList(updateTodoList)
}
const onUpdate = (id: number, text: string) => {
const updateTodoList = todoList.map((todo) => {
return todo.id == id
? {...todo, text: text}
: todo
});
console.log("updateToDoList : " + JSON.stringify(updateTodoList));
setTodoList(updateTodoList)
storeTodoList(updateTodoList)
}
return(
<div>
<div className='container'>
<div className='input-field'>
<input
type='text'
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button onClick={onSubmit} className='common-button add-todo-button'>
追加
</button>
</div>
<ul className='todo-list'>
{todoList?.map((todo) => {
return <Todo key ={todo.id}
todo={todo}
onCheck={onCheck}
onDelete={onDelete}
onUpdate={onUpdate}
/>;
})}
</ul>
</div>
</div>
)
}
const Todo = (props: { todo: Todo; onCheck: Function; onDelete: (id: number) => void;
onUpdate: Function }) => {
const {todo, onCheck, onDelete, onUpdate} = props;
const [editText, setEditText] = useState(todo.text);
// チェック
const onCheckHandler = () => {
onCheck(todo);
}
// 削除
const onDeleteHandler = () => {
onDelete(todo.id);
}
// 編集
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditText(e.target.value)
onUpdate(todo.id, editText)
}
return (
<li>
<label>
<input
type='checkbox'
checked={todo.completed}
onChange={onCheckHandler}
></input>
<input
id={`input-content-${todo.id}`}
className='input-content'
type='text'
value={editText} // 編集中のテキスト
onChange={handleEditChange}
disabled={todo.completed}
/>
<button
className='common-button add-delete-button'
onClick={onDeleteHandler}>削除</button>
</label>
</li>
);
};
App.css
App.css
body {
position: relative;
color: white;
height: 100vh;
background: linear-gradient(
200.96deg,
#fedc2a -29.09%,
#dd5789 51.77%,
#7a2c9e 129.35%
);
font-family: sans-serif;
overflow-y: hidden;
display: flex;
justify-content: center;
align-items: center;
}
button {
background-color: white;
padding: 10px 20px;
border-radius: 10px;
border: none;
appearance: none;
font-size: 1.3rem;
box-shadow: 0px 8px 28px -6px rgba(24, 39, 75, 0.12),
0px 18px 88px -4px rgba(24, 39, 75, 0.14);
transition: all ease-in 0.1s;
cursor: pointer;
opacity: 0.9;
}
button:hover {
transform: scale(1.05);
opacity: 1;
}
li {
list-style: none;
}
a {
text-decoration: none;
height: fit-content;
width: fit-content;
margin: 10px;
}
a:hover {
opacity: 1;
text-decoration: none;
}
.common-button{
background-color: #da568a;
color: white;
border: 1px solid #fff;
padding: 8px 12px;
border-radius: 4px;
appearance: none;
font-size: 12px;
transition: all ease-in 0.1s;
cursor: pointer;
opacity: 0.9;
line-height: 1;
min-width: 60px;
}
.fix-button {
margin-left: 10px;
}
.common-button:hover{
background-color: #e47474;
opacity: 1;
}
input[type='text']{
border: none;
border-radius: 4px;
min-height: 30px;
margin-right: 12px;
opacity: 0.9;
padding: 4px 12px;
width: 100%;
}
.input-field{
width: 100%;
display: flex;
opacity: 0.95;
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
max-width: 400px;
margin: 40px auto;
}
.todo-list li{
line-height: 2;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.todo-list li input,
.todo-list li span{
cursor: pointer;
padding: 8px 12px;
display: inline-block;
}
/* テキスト部分をスペースに応じて伸縮 */
.todo-list li span {
flex-grow: 1;
}
ul {
padding: 0;
}
li {
list-style: none;
}
a {
text-decoration: none;
height: fit-content;
width: fit-content;
margin: 10px;
}
a:hover {
opacity: 1;
text-decoration: none;
}
.checked {
text-decoration: line-through;
}
label {
display: flex;
align-items: center;
gap: 8px;
}
詳細
main.ts
electron-store
を使って、ToDoリストをローカルに保存します。
メインプロセスからelectron-store
を扱うため、ReactのcontentBridge
とIPC(Inter-Process Communication)通信
をつかって、レンダラープロセスと安全にデータのやり取りを行います。
ipcMain.handle
をつかって、invoke
可能な IPCのハンドラを追加します。こうすると、レンダープロセスから
const storeData = new Store();
// ToDoリストを読み込み
ipcMain.handle('loadTodoList', async (event, data) => {
return storeData.get('todoList');
});
// ToDoリストを保存
ipcMain.handle('storeTodoList', async (event, data) => {
storeData.set('todoList', data);
});
// 指定したidのToDoを削除
ipcMain.handle('deleteTodoList', async (event, id) => {
const todoList = storeData.get('todoList');
const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
storeData.set('todoList', updateTodoList);
return updateTodoList;
})
preload.ts
ElectronではcontextIsolation
がデフォルトで有効になっているため、レンダラープロセスから直接ipcRender
にアクセスできません。そのためcontextBridge
をつかって、安全にメインプロセスと通信できるAPIを提供します。
contextBridge.exposeInMainWorld('db', {
loadTodoList: () => ipcRenderer.invoke('loadTodoList'),
storeTodoList: (todoList: Array<object>) =>
ipcRenderer.invoke('storeTodoList', todoList),
deleteTodoList: (id: number) => ipcRenderer.invoke('deleteTodoList', id),
});
export type ElectronHandler = typeof electronHandler;
App.tsx
まず、HomeScreen
関数での処理を説明します。
データ操作
preload.ts
でwindow.db
に公開したAPIを使って、Reactから ToDoリストを管理します。これでToDoリストへの読み込みや保存、削除ができるようになります。
interface ElectronWindow extends Window {
db: {
loadTodoList: () => Promise<Array<Todo> | null>;
storeTodoList: (todoList: Array<Todo>) => Promise<void>;
deleteTodoList: (id: number) => Promise<Array<Todo> | null>;
}
}
// ToDoリストを読み込み
const loadTodoList = async() : Promise<Array<Todo> | null> => {
const todoList = await window.db.loadTodoList();
return todoList
};
// Todoリストを保存
const storeTodoList = async (todoList: Array<Todo>): Promise<void> => {
await window.db.storeTodoList(todoList);
};
Todoリストの状態管理
Todoリストを管理するためuseState
を定義しています。
const [text, setText] = useState<string>('');
const [todoList, setTodoList] = useState<Array<Todo>>([]);
データの読み込み
useEffect
を使って、アプリが初回レンダリングされたときに、Todoリストを読み込みます。useEffect
はコンポーネントがレンダリングされたとき実行されるhook
です。
useEffect(() => {
loadTodoList().then((todoList) => {
if(todoList){
setTodoList(todoList);
}
});
}, []);
ToDoの追加
新しいToDo をリストに追加する関数です。
UI側のボタンが押されたときに実行されます。
スプレッド構文をつかって、新しいToDoをtodoList
の先頭に追加します。追加後はuseState
で定義したsetText
をつかって、入力フィールドをリセットします。
const onSubmit = () => {
if (text !== '') {
const newTodoList: Array<Todo> = [
{
id: new Date().getTime(),
text: text,
completed: false
},
...todoList, // スプレッド構文
];
console.log("newToDoList : " + JSON.stringify(newTodoList));
setTodoList(newTodoList);
storeTodoList(newTodoList);
setText(''); // 入力フィールドをリセット
}
};
ToDoの完了状態を切り替え
チェックボックスがクリックされたとき、completed(完了状態)を反転させる関数です。反転して、新しいリストをstoreTodoList
で保存しています。
const onCheck = (newTodo: Todo) => {
const newTodoList = todoList.map((todo) => {
return todo.id === newTodo.id
? { ...newTodo, completed: !newTodo.completed } // completed を切り替え
: todo;
});
setTodoList(newTodoList);
storeTodoList(newTodoList);
};
ToDoの削除
指定したidのToDoをリストから削除する関数です。
const onDelete = (id: number) => {
const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
setTodoList(updateTodoList);
storeTodoList(updateTodoList);
};
ToDoの更新
指定したidのToDoのtextを更新する関数です。
const onUpdate = (id: number, text: string) => {
const updateTodoList = todoList.map((todo) => {
return todo.id === id
? { ...todo, text: text }
: todo;
});
setTodoList(updateTodoList);
storeTodoList(updateTodoList);
};
コンポーネントのJSX
コンポーネントのJSXは下記のようになっていて、todoListをループしてToDoコンポーネントを描画します。
return (
<div>
<div className='container'>
<div className='input-field'>
<input
type='text'
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button onClick={onSubmit} className='common-button add-todo-button'>
追加
</button>
</div>
<ul className='todo-list'>
{todoList?.map((todo) => {
return (
<Todo
key={todo.id}
todo={todo}
onCheck={onCheck}
onDelete={onDelete}
onUpdate={onUpdate}
/>
);
})}
</ul>
</div>
</div>
);
おわりに
ElectronではcontextBridge
という仕組みがあることを初めて知りました。ToDoリストをつくってみてReactとElectronの理解が少し深まりました。
参考
Discussion