LoginSignup
55
55

More than 1 year has passed since last update.

Electron + Reactでデスクトップアプリを作ろう!

Last updated at Posted at 2022-06-20

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をシンプルに表現できます。

https://reactjs.org/

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

アプリが無事に立ち上がりました。

スクリーンショット 2022-06-17 14.30.28.jpg

これでElectron + Reactの実行環境が完成しました!🎉

簡単ですね。ここまで所要時間は1分です。ホットリロードに対応しており、ソースコードに変更を行うと即座にアプリの表示も更新されます。

フォルダ構成

フォルダ構成はこのようになっています。

スクリーンショット 2022-06-17 14.35.01.jpg

/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で実行!

スクリーンショット 2022-06-20 11.05.24.jpg

うまく表示されました!

チェックボックスの切り替えや、新しいToDoの追加もできます。

スクリーンショット 2022-06-20 11.07.58.jpg

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リストの保存と読み込みが行われるようになりました。

アプリを開きなおしても終了時と同じデータを読み込めることが確認できました。

スクリーンショット 2022-06-20 11.53.27.jpg

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ファイルで保存されます

スクリーンショット 2022-06-20 13.36.53.jpg
electron-storeを使って保存したデータは、config.jsonというファイル名で保存されます。
ファイルの保存場所は、app.getPath('userData')で取得できるパスです。

Macでは以下に保存されていました。
/Users/ユーザー名/Library/Application Support/electron/config.json

完成したアプリをパッケージ化して配布する

Electron React Boilerplateで作成したアプリは、npm run packageを実行することでパッケージ化して配布することができるようになります。

npm run package

しばらく待つとパッケージが完成しました。

スクリーンショット 2022-06-20 13.22.50.jpg

出来上がった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

55
55
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
55
55