Electronとは?
Electronは、HTML、CSS、JavaScriptを使って、MacとWindowsの両方で動くデスクトップアプリを作ることができるフレームワークです。ElectronはChromiumとNode.jsがベースとなっており、Chromeで動くページであればそのままデスクトップアプリ化させることも可能です。
作成したデスクトップアプリは、MacのAppStoreやMicrosoftのストアで公開することも可能です。
■前回の記事
Electronを使ってMacとWindowsで動くアプリを作ってみる
https://qiita.com/udayaan/items/dfb324bc6cadeb9a8f6f
Reactとは?
Reactは、UIを作るためのJavaScriptのライブラリーです。Vue.jsと同じく、要素をコンポーネント化してUIを作れるため、メンテナンスが行いやすいです。また、UI上の表示とJavaScirptで持つデータを同期できるため、複雑なUIをシンプルに表現できます。
ElectronとReactを使うメリット
- JavaScriptを使って複雑なUIを持つデスクトップアプリケーションが作れる
- Web系のエンジニアでもアプリのメンテナンスがしやすい
- Web用に作成したReactプロジェクトをデスクトップアプリにできる
Electron React Boilerplateを使って、Electron + Reactの実行環境を作成する
ElectronでReactを使うには手間が必要ですが、「Electron React Boilerplate」を使えば、細かな設定を行わなくとも簡単にElectron + Reactの実行環境が作れます。
今回はElectron React Boilerplateを使って、ToDo管理アプリを作ってみます。
プロジェクトをcloneしてnpm installを実行します
git clone --depth=1 \
https://github.com/electron-react-boilerplate/electron-react-boilerplate \
my-todo-app
cd my-todo-app
npm install
開発環境でアプリを実行します
npm start
アプリが無事に立ち上がりました。
これでElectron + Reactの実行環境が完成しました!🎉
簡単ですね。ここまで所要時間は1分です。ホットリロードに対応しており、ソースコードに変更を行うと即座にアプリの表示も更新されます。
フォルダ構成
フォルダ構成はこのようになっています。
/srcディレクトリにメインの処理を書いていきます。
/src/mainにはElectronやnode.js周りの処理を書いて、
/src/renderer内にReactのコンポーネントを書きます。
/assets内には画像やアイコンなどを置いておきます。
/release内にはビルドしたアプリが保存されます。
TypeScriptが使える
また、「Electron React Boilerplate」では、最初からTypeScriptが使えるようになっています。TypeScriptを使うと型チェックができたりコードエディターで補完が効くようになるため、複雑で大規模な開発でもデバッグしやすくメンテナンスが楽になります。
TypeScriptファイルの拡張子には、.tsと.tsxの2つが存在します。
.tsは純粋なTypeScriptを含むファイルに使われ、
.tsxは、JSXに対応したTypeScriptのファイルに使われます。
Electron + ReactでToDoアプリを作る
/src/renderer/app.tsxを変更して、ToDoリストアプリを作ります。
ReactでToDoアプリを作り、Electronを使ってToDoデータをローカルに保存できるようにします。
App.tsx > App
import { useState, useEffect } from 'react';
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
import './App.css';
// データ型を定義
interface Todo {
id: number;
text: string;
completed: boolean;
}
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<HomeScreen />} />
</Routes>
</Router>
);
}
Routerが最初から設定されているため、後からページやスクリーンを楽に追加できます。
elementに指定したコンポーネントが表示されます。
App.tsx > HomeScreen
const HomeScreen = () => {
// stateを定義
const [text, setText] = useState<string>('');
const [todoList, setTodoList] = useState<Array<Todo>>([]);
useEffect(() => {
// 初回レンダー時にデフォルトのデータをセット
const defaultTodoList = [
{
id: 1,
text: '宿題をやる',
completed: false,
},
{
id: 2,
text: '部屋を片付ける',
completed: true,
},
{
id: 3,
text: 'メールを送る',
completed: false,
},
];
setTodoList(defaultTodoList);
}, []);
const onSubmit = () => {
// ボタンクリック時にtodoListに新しいToDoを追加
if (text !== '') {
const newTodoList: Array<Todo> = [
{
id: new Date().getTime(),
text: text,
completed: false,
},
...todoList,
];
setTodoList(newTodoList);
// テキストフィールドを空にする
setText('');
}
};
const onCheck = (newTodo: Todo) => {
// チェック時にcompletedの値を書き換える
const newTodoList = todoList.map((todo) => {
return todo.id == newTodo.id
? { ...newTodo, completed: !newTodo.completed }
: todo;
});
setTodoList(newTodoList);
};
return (
<div>
<div className="container">
<div className="input-field">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button onClick={onSubmit} className="add-todo-button">
追加
</button>
</div>
<ul className="todo-list">
{todoList?.map((todo) => {
return <Todo key={todo.id} todo={todo} onCheck={onCheck} />;
})}
</ul>
</div>
</div>
);
};
App.tsx >Todo
const Todo = (props: { todo: Todo; onCheck: Function }) => {
const { todo, onCheck } = props;
const onCheckHandler = () => {
onCheck(todo);
};
return (
<li className={todo.completed ? 'checked' : ''}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={onCheckHandler}
></input>
<span>{todo.text}</span>
</label>
</li>
);
};
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;
}
.add-todo-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;
}
.add-todo-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;
}
.todo-list li input,
.todo-list li span {
cursor: pointer;
}
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;
}
.Hello {
display: flex;
justify-content: center;
align-items: center;
margin: 20px 0;
}
.checked {
text-decoration: line-through;
opacity: 0.5;
}
npm run startで実行!
うまく表示されました!
チェックボックスの切り替えや、新しいToDoの追加もできます。
electron-storeを使って、データを保存させる
ここまでで、ToDoを追加したりチェックボックをチェックできるようになりましたが、アプリのウインドウを閉じるとデータが消えてしまいます。
そこで、ウインドウを閉じてもデータが保存されるように、electron-storeを使います。
electron-storeを使うと、データをjson形式で保存したり読み込んだりできます。
https://github.com/sindresorhus/electron-store
contextBridgeの設定が必要
electron-storeはElectronのメインプロセス側で動作します。
メインでもレンダープロセスのどちらでも動作するようです。
Electronでは、レンダラーからメインプロセスの処理を呼び出すには「contextBridge」というモジュールを使う必要があります。
今回はmain.tsにメインの処理を追加して、preload.tsでcontextBridgeの設定を行います。
詳しくは、前回の記事を参考にしてください。
https://qiita.com/udayaan/items/dfb324bc6cadeb9a8f6f#contextbridge
electron-storeをインストール
まずは、electron-storeをインストールします。
npm install electron-store
main.ts
main.tsに処理を追加します。
ToDoを読み込む「loadTodoList」と、データを保存する「storeTodoList」を用意します。
import Store, { Schema } from 'electron-store';
ipcMain.handle('loadTodoList', async (event, data) => {
return storeData.get('todoList');
});
ipcMain.handle('storeTodoList', async (event, data) => {
storeData.set('todoList', data);
});
preload.ts
次に、preload.tsで、レンダラーへ開放するAPIの設定をします。
これで、React側から、window.db.loadTodoList();のような書き方で、main.tsに書いた処理を実行できます。
contextBridge.exposeInMainWorld('db', {
loadTodoList: () => ipcRenderer.invoke('loadTodoList'),
storeTodoList: (todoList: Array<object>) =>
ipcRenderer.invoke('storeTodoList', todoList),
});
app.tsx
最後にapp.tsxに処理を追加します。
// データ操作
// 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);
};
アプリ起動時に、ローカルに保存されたデータが読み込まれるようにしたいので、
HomeScreenのuseEffectを書き換えてloadTodoList()を呼び出します。
useEffect(() => {
// 初回レンダー時にデフォルトデータをセット
// const defaultTodoList = [
// {
// id: 1,
// text: '宿題をやる',
// completed: false,
// },
// {
// id: 2,
// text: '部屋を片付ける',
// completed: false,
// },
// {
// id: 3,
// text: 'メールを送る',
// completed: false,
// },
// ];
// setTodoList(defaultTodoList);
loadTodoList().then((todoList) => {
if (todoList) {
setTodoList(todoList);
}
});
}, []);
また、ToDoの追加時とチェックボックスを切り替えた場合にデータを保存したいので、onSubmitとonCheck実行時に、storeTodoList()を呼び出します。
const onSubmit = () => {
if (text !== '') {
const newTodoList: Array<Todo> = [
{
id: new Date().getTime(),
text: text,
completed: false,
},
...todoList,
];
setTodoList(newTodoList);
storeTodoList(newTodoList); // これを追加
setText('');
}
};
const onCheck = (newTodo: Todo) => {
const newTodoList = todoList.map((todo) => {
return todo.id == newTodo.id
? { ...newTodo, completed: !newTodo.completed }
: todo;
});
setTodoList(newTodoList);
storeTodoList(newTodoList);// これを追加
};
このままだと、window.dbの行が型エラーとなるので、以下を追加します。
interface ElectronWindow extends Window {
db: {
loadTodoList: () => Promise<Array<Todo> | null>;
storeTodoList: (todoList: Array<Todo>) => Promise<void>;
};
}
declare const window: ElectronWindow;
これでToDoリストの保存と読み込みが行われるようになりました。
アプリを開きなおしても終了時と同じデータを読み込めることが確認できました。
app.tsx(全体)
import { useState, useEffect } from 'react';
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
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>;
};
}
declare const window: ElectronWindow;
// データ操作
// 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);
};
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<HomeScreen />} />
</Routes>
</Router>
);
}
const HomeScreen = () => {
// stateを定義
const [text, setText] = useState<string>('');
const [todoList, setTodoList] = useState<Array<Todo>>([]);
useEffect(() => {
loadTodoList().then((todoList) => {
if (todoList) {
setTodoList(todoList);
console.log(todoList);
}
});
}, []);
const onSubmit = () => {
// ボタンクリック時にtodoListに新しいToDoを追加
if (text !== '') {
const newTodoList: Array<Todo> = [
{
id: new Date().getTime(),
text: text,
completed: false,
},
...todoList,
];
setTodoList(newTodoList);
storeTodoList(newTodoList);
// テキストフィールドを空にする
setText('');
}
};
const onCheck = (newTodo: Todo) => {
// チェック時にcompletedの値を書き換える
const newTodoList = todoList.map((todo) => {
return todo.id == newTodo.id
? { ...newTodo, completed: !newTodo.completed }
: todo;
});
setTodoList(newTodoList);
storeTodoList(newTodoList);
};
return (
<div>
<div className="container">
<div className="input-field">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button onClick={onSubmit} className="add-todo-button">
追加
</button>
</div>
<ul className="todo-list">
{todoList?.map((todo) => {
return <Todo key={todo.id} todo={todo} onCheck={onCheck} />;
})}
</ul>
</div>
</div>
);
};
const Todo = (props: { todo: Todo; onCheck: Function }) => {
const { todo, onCheck } = props;
const onCheckHandler = () => {
onCheck(todo);
};
return (
<li className={todo.completed ? 'checked' : ''}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={onCheckHandler}
></input>
<span>{todo.text}</span>
</label>
</li>
);
};
データはjsonファイルで保存されます
electron-storeを使って保存したデータは、config.jsonというファイル名で保存されます。
ファイルの保存場所は、app.getPath('userData')で取得できるパスです。
Macでは以下に保存されていました。
/Users/ユーザー名/Library/Application Support/electron/config.json
完成したアプリをパッケージ化して配布する
Electron React Boilerplateで作成したアプリは、npm run packageを実行することでパッケージ化して配布することができるようになります。
npm run package
しばらく待つとパッケージが完成しました。
出来上がったzipファイルを配布すれば、他のユーザーも使えるようになります。
関連記事
よかったら、こちらの記事も読んでみてください。
reactでポケモン風RPGゲームを作ってみよう!戦闘画面編
https://qiita.com/udayaan/items/38680c63ed034503eac0
話題の最新フロントエンドフレームワーク「Astro」を使ってみた
https://qiita.com/udayaan/items/24ecb2f5f4608fc1df4c
Electronを使ってMacとWindowsで動くアプリを作ってみる
https://qiita.com/udayaan/items/dfb324bc6cadeb9a8f6f