2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【electron】electron+typescript+reactでデスクトップアプリを作ってみる

Posted at

初書:2021/07/14→2021/11/25
mac : 11.4
npm: 8.1.0
node: 16.13.0
electron: 15.3.0

前書き

久しぶりにelectronを使うので、初期の設定から最低限の起動までをメモしておく。
最近Typescriptをよく使うので、せっかくならTypescriptと、あとReactも使えるみたいなのでまとめて使ってみる

参考サイト

今回こちらの方をかなり参考にさせてもらった。

Electron + TypeScript + React の環境構築 (Summer 2021)

使うモジュール一覧

ElectronはNode.jsで動くので、npmを使う。
そしてその時に色々と使うので、簡単に仕様などをメモしておく

Electron

JavaScript, HTML, CSS でクロスプラットフォームなデスクトップアプリ開発

が出来るオープンソフトウェアフレームワーク。
今回のベースとなるので詳しい内容は既にご存知でしょう(丸投げ)。

Electron | JavaScript, HTML, CSSによるクロスプラットフォームなデスクトップアプリ開発

Typescript

Javascriptの型を厳密にした言語。コンパイルでjavascriptになるのでベースはjs。
適切に使うと型の恩恵を受けれる他、入力補完も出来るようになるので便利。

React

ユーザインタフェース構築のためのJavaScriptライブラリ。今回はレンダラープロセス側で使う。
Reactにはクラスベースと関数ベースがあり、最近は関数ベースが主流になりつつあるが、
個人的にはクラスベースの方がやっていることが可視化しやすい感じがしてクラスベースを使っている。
下のコードもクラスベースなので、関数ベースで書きたい人は適宜書き換えてください。

webpack

モジュールバンドラー。今回は複数のファイルをまとめて1つのファイルにしたり、コンパイル周りをしたりするために使う。

npm-run-all

npmで複数のコマンドを連続で実行するためのもの。普段は&&|を使っていたが、windowsでは使えない?らしい。
何かその辺をうまくやってくれるモジュール。

rimraf

rm -rfをするもの。これもwindows関連でうまくやってくれるやつ。
今回はコンパイル先で不具合を起こさないように毎回消すために使う。

cross-env

実行時に任意の環境変数を設定できる。

インストール

まずはディレクトリを作成し、その中でnpm init -y(-yはデフォルト値で決めてくれる)

electronのインストール

% npm install --save-dev electron

typescriptのインストール

% npm install --save-dev typescript ts-node @types/node

ts-nodeはtypescriptのまま実行できるライブラリ

Reactのインストール

% npm install -save react react-dom
% npm install --save-dev @types/react @types/react-dom

webpackのインストール

% npm install --save-dev webpack webpack-cli

webpack-cliはwebpackコマンドを実行できるようにするライブラリ

webpackでバンドルする時に必要なものをインストール

% npm install --save-dev ts-loader css-loader mini-css-extract-plugin html-webpack-plugin  @types/mini-css-extract-plugin

ts、html、cssをそれぞれバンドルする時に必要

バンドルから実行に必要なものをインストール

% npm install --save-dev rimraf cross-env npm-run-all

以上。ちなみにこの上にeslintを入れる予定だったのだが、想像以上に衝突が起こってしまい1、例外を作っていくのが大変だったので導入を断念した。

各種設定

無事にインストールが終われば各種設定を行う。gitとフォーマッタの設定は各自で。

Typescript

% npx tsc --init

これでtsconfig.jsonが出来たら、中身を少し弄る。

"target": "es2020",
"lib": ["DOM", "ES2020"],
"jsx": "react-jsx",
"sourceMap": true,
"outDir": "./dist",

libに関しては必要なのかいまいち分かってない。targetと同じでdomだけなので不要な気もしている。

Webpack

コードは参考サイトからほとんどお借りしている。

webpack.config.ts
import path from 'path';

/** エディタで補完を効かせるために型定義をインポート */
import { Configuration, DefinePlugin } from 'webpack';

import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';

const isDev = process.env.NODE_ENV === 'development';

/** 共通設定 */
const base: Configuration = {
  mode: isDev ? 'development' : 'production',
  // メインプロセスで __dirname でパスを取得できるようにする
  node: {
    __dirname: false,
    __filename: false,
  },
  resolve: {
    extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'],
  },
  output: {
    // バンドルファイルの出力先(ここではプロジェクト直下の 'dist' ディレクトリ)
    path: path.resolve(__dirname, 'dist'),
    // webpack@5.x + electron では必須の設定
    publicPath: './',
    filename: '[name].js',
    // 画像などのアセットは 'images' フォルダへ配置する
    assetModuleFilename: 'images/[name][ext]',
  },
  module: {
    rules: [
      {
        /**
         * 拡張子 '.ts' または '.tsx' (正規表現)のファイルを 'ts-loader' で処理
         * node_modules ディレクトリは除外する
         */
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: 'ts-loader',
      },
      {
        /** 拡張子 '.css' (正規表現)のファイル */
        test: /\.css$/,
        /** use 配列に指定したローダーは *最後尾から* 順に適用される */
        use: [
          /* セキュリティ対策のため(後述)style-loader は使用しない */
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: { sourceMap: isDev },
          },
        ],
      },
      {
        /** 画像やフォントなどのアセット類 */
        test: /\.(bmp|ico|gif|jpe?g|png|svg|ttf|eot|woff?2?)$/,
        /** アセット類も同様に asset/inline は使用しない */
        /** なお、webpack@5.x では file-loader or url-loader は不要になった */
        type: 'asset/resource',
      },
    ],
  },
  /**
   * developmentモードではソースマップを付ける
   *
   * レンダラープロセスでは development モード時に
   * ソースマップがないと electron のデベロッパーコンソールに
   * 'Uncaught EvalError' が表示されてしまうことに注意
   */
  devtool: isDev ? 'inline-source-map' : false,
  plugins : [
    new DefinePlugin({
      'process.env.VERSION_ENV': `"${require('./package.json').version}"`,
    })
  ]
};

// メインプロセス用の設定
const main: Configuration = {
  // 共通設定の読み込み
  ...base,
  target: 'electron-main',
  entry: {
    main: './src/main.ts',
  },
};

// プリロード・スクリプト用の設定
const preload: Configuration = {
  ...base,
  target: 'electron-preload',
  entry: {
    preload: './src/preload.ts',
  },
};

// レンダラープロセス用の設定
const renderer: Configuration = {
  ...base,
  // セキュリティ対策として 'electron-renderer' ターゲットは使用しない
  target: 'web',
  entry: {
    renderer: './src/renderer.tsx',
  },
  plugins: [
    /**
     * バンドルしたJSファイルを <script></script> タグとして差し込んだ
     * HTMLファイルを出力するプラグイン
     */
    new HtmlWebpackPlugin({
      template: './src/index.html',
      minify: !isDev,
      inject: 'body',
      filename: 'index.html',
      scriptLoading: 'blocking',
    }),
    new MiniCssExtractPlugin(),
  ],
};

/**
 * メイン,プリロード,レンダラーそれぞれの設定を
 * 配列に入れてエクスポート
 */
export default [main, preload, renderer];

コンパイルの中にcssをパックするファイルがあるが、
tsxの中でcssをimportして使っているので不要なのかもしれない。
この辺りはwebpackやcss-importをもう少し理解して換える必要があると思われ。

package.json

実行コマンドを追加しておく。
またメインプロセスをdist/main.jsに変更しておく

package.json
  "scripts": {
    "start": "run-s clean build serve",
    "clean": "rimraf dist",
    "build": "cross-env NODE_ENV=\"development\" webpack --progress",
    "serve": "electron ."
  },
  "main": "dist/main.js",

cleanはdistディレクトリとその中身を削除するコマンド
buildはwebpackを使用してコンパイルするコマンド
serveはElectronを起動するコマンド
startがそれらを順番にまとめて実行するコマンド

ちなみに(余談)
buildコマンドにて、cross-envでNODE_ENV="development"を指定して、build後のコードを見てみると、
process.env.NODE_ENV === "development"がtrueに置き換わっているのが分かる。
これは、cross-envで指定したNODE_ENVの値を反映して置き換わっているのではなく、
webpackの仕様によって書き変わっている。2
他にprocess.envで指定した環境情報を置き換える場合は、
base.pluginsで使用しているDefinePluginというライブラリで同様に置き換えることができる。
このコードではVERSION_ENVpackage.jsonのversionになるよう上書きしている。

各種ファイル作成

とりあえず実行に必要なファイルを作成する。

  • srcディレクトリを作成
  • src/main.tsの作成(メインプロセス)
  • src/index.htmlの作成(レンダラープロセスのhtml部分)
  • src/preload.tsの作成(プレロード)
  • src/renderer.tsxの作成(レンダラープロセス)

ファイルの中身はとりあえず空でいい。

とりあえず実行してみる

% npm run start

いくつかのコマンドが実行されたのち、electron .が実行されれば成功。
なお画面表示部分を作成していないので、ウィンドウは特に出てこない。

最低限のコードを書く

流石に何も表示されないのは面白くないので、よくあるHello worldを表示してみる。

main.ts

呼び出しの大元となるmain.ts。ここでレンダラープロセスを生成する。

src/main.ts
import path from 'path';
import { app, BrowserWindow } from 'electron';

/**
 * BrowserWindowインスタンスを作成する関数
 */
const createWindow = () => {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js'),
    },
  });

  // 開発時にはデベロッパーツールを開く
  if (process.env.NODE_ENV === 'development') {
    mainWindow.webContents.openDevTools({ mode: 'detach' });
  }

  // レンダラープロセスをロード
  mainWindow.loadFile('dist/index.html');
};

/**
 * アプリを起動する準備が完了したら BrowserWindow インスタンスを作成し、
 * レンダラープロセス(index.htmlとそこから呼ばれるスクリプト)を
 * ロードする
 */
app.whenReady().then(async () => {
  // BrowserWindow インスタンスを作成
  createWindow();
});

// すべてのウィンドウが閉じられたらアプリを終了する
app.once('window-all-closed', () => app.quit());

簡単にコード解説

  • createWindowはアロー関数で、new BrowserWindowで新しいウィンドウを生成している。
    webPreferencesで指定している各種設定内容は後に少し出てくる。

  • mainWindow.webContents.openDevToolsではchromeのF12でよくみるあれを初めから表示している。
    ちなみに本番では表示しないようになっているが、メニューバーに「toggle Developer tools」がある限り、普通に開くことができるので
    非表示にしたい場合は注意。

  • app.whenReady()で準備ができれば先程の関数を呼び出し、ウィンドウを生成する。

  • app.once('window-all-closed', () => void);は、全てのウィンドウが閉じた時にAppを終了するイベント。
    windowsではこれがなくても特に問題がないが、macの場合はウィンドウを閉じただけではAppが終了しない。(メールやメモなど、デフォルトのAppで見かける)
    そのため、ウィンドウが閉じると同時に終了させたい場合はこの一行が必要になる。

index.html

src/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <!-- CSP の設定 -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self';" />

    <title>Electron Title</title>
  </head>
  <body>
    <!-- react コンポーネントのマウントポイント -->
    <div id="root"></div>
  </body>
</html>

簡単にコード解説

metaタグでContent-Security-Policyを設定している。
これにより、埋め込みスクリプトや外部サーバーのスクリプト等を実行できなくなる。
これは安全に使うために入れているので、極力入れておいた方がいいが、外部ファイルの読み込み等は難しくなる。

body内には<div id="root"></div>だけが存在している。
この中にReactを用いたhtmlを構成していくので、この中は今は空でいい。
また、webpackにより、このしたの行にrenderer.jsが自動的に書き加えられる。

preload.ts

今現在はメインとレンダラーで通信を行わないので空ファイル

renderer.tsx

src/renderer.tsx
import React from "react";
import ReactDOM from "react-dom";

class RootDiv extends React.Component<{}, {}> {
  constructor(props: {}) {
    super(props);
  }
  render = () => {
    return <>Hello World</>;
  };
}

ReactDOM.render(<RootDiv />, document.getElementById("root"));

簡単にコード解説

Reactを使用しているので、React自体の詳細な説明は他のページを当たって欲しい。
今回はただ単にHello Worldを表示できればいいので、renderでは要素名なしの<>Hello World</>を返している。

また、React.Component内の関数は、constructorを除いて基本的にアロー関数を使用する。
理由はthisをbindすることを忘れる/bindが面倒だから。

再び実行してみる

もう一度実行する。

% npm run start

今回はウィンドウが表示され、「Hello World」が表示されれば成功。

メインプロセスと通信する

これで一応最低限Electronを作成出来たが、デスクトップアプリケーションなので、
基本的にメインプロセスと通信する必要が出てくる。
なので、今回はファイル名を入力して、存在するかチェックするとても簡単なアプリケーションを作成してみる。

方針決定

Electronなのでnodeを使えるように思えるが、main.tsnew BrowserWindowの際に、
nodeIntegrationをfalseに設定していると、レンダラープロセス側からnodeにアクセスできなくなる。
これをtrueにしてしまえばレンダラープロセスだけで完結するが、例えばiframeや、aタグを踏んだりするなどして
外部のサイトを開くことが出来るようになると、そのサイトに悪意のコードがあればnodeを実行できるようになり、危険になる。
そのため、基本的にはnodeIntegrationはfalseで扱う。

ではどのようにメインプロセスと通信するかというと、先ほど空白にしたpreload.tsを経由して通信を行う。

プリロードスクリプトは、ウェブコンテンツの読み込み開始前にレンダラープロセス内で実行されるコードです。 これらのスクリプトはレンダラーのコンテキスト内で実行されますが、Node.js の API にアクセスできるようにより多くの権限が与えられています。

(中略)

プリロードスクリプトは、グローバルな Window インターフェイスをレンダラーと共有し Node.js の API にアクセスすることができます。そのため、window グローバルに任意の API を公開してウェブコンテンツが利用できるようにすることで、レンダラーを強化する役割を果たしています。

プリロードスクリプトはアタッチされているレンダラーと window グローバルを共有しますが、contextIsolation のデフォルト値によりプリロードスクリプトの変数は window に直接アタッチできません。

プロセスモデル | Electronより

つまり、contextBridgeを利用してレンダラープロセス側のグローバル変数に追加でき、かつnodeを実行できるレンダラープロセス側のソースコードになる。3
これを利用して、メインプロセスと繋ぐ橋を生成し、通信を行う。

preload.ts

preload.ts
import { contextBridge, ipcRenderer } from "electron";

export const preloadObject = {
  existFile: async (filePath: string) : Promise<boolean> => {
    const result = await ipcRenderer.invoke('existFile', filePath);
    return result;
  },
};

contextBridge.exposeInMainWorld('api', preloadObject);

メインプロセスにはcontextBridge.exposeInMainWorldを利用することで使用できる。
これの第一引数で渡すstringがwindowに追加され、第二引数がその中に入る。
今回であれば第一引数がapiで、第二引数が{existFile: Function}なので、
window.api.existFiles(...arg)でレンダラープロセスで呼び出すことができる。

またexistFileの中ではipcRenderer.invokeを利用することでメインプロセス側に通信を試みる。
第一引数がイベント名で第二引数以降が渡す変数となる。
メインプロセス側の受け取りは後で記述する。

global.d.ts

唐突に出てきたd.ts。別に単にtsでもいいけど。
preloadからrendererに公開されたapiは、typescriptでは認識してくれないため型の推論をしてくれない。
そのため、その中間となる型定義ファイルを用意することで、typescriptの恩恵を受けようというもの。
ちなみに、preloadとmain間の通信で型推論する方法はないと思う。あれば教えてください。

ということで、srcの中に@typesディレクトリを生成し、その中にglobal.d.tsを生成する。
ちなみにこのglobalという名前は何でもいい。

src/@types/global.d.ts
import { preloadObject } from '../preload';

declare global {
  interface Window {
    api: typeof preloadObject;
  }
}

これで、window.api.と入力するとexistFileが入力予測で表示される。

renderer.ts

先ほど使ったglobal.d.tsを使って型推論をしてもらう。
もちろんここからコピペするだけならanyでも何でもいいので使う意味はないが、この先は自身で作成すると思うので。

src/renderer.ts
import React from "react";
import ReactDOM from "react-dom";

interface States {
  filename: string;
}

class RootDiv extends React.Component<{}, States> {
  constructor(props: {}) {
    super(props);
    this.state = {
      filename: "",
    };
  }

  handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    this.setState({
      filename: event.target.value,
    });
  };

  handleOnClick = async () => {
    const { filename } = this.state;
    const result = await window.api.existFile(filename);
    alert(`${filename}${result ? "存在しています" : "存在していません"}`);
  };

  render = () => {
    return (
      <>
        <input type="text" onChange={this.handleOnChange} />
        <button onClick={this.handleOnClick}>検索</button>
      </>
    );
  };
}

ReactDOM.render(<RootDiv />, document.getElementById("root"));

簡単にコード解説

RootDivクラスの中身を大幅に書き換えた。
今回は結果をalertで表示するため、renderは入力boxとボタンのみ設置されている。

また、handleOnChangehandleOnClickはそれぞれtextの中身を書き換えた時と、ボタンをクリックした際に呼び出される。
これにより、statefilenameで入力情報を管理し、検索を押された際は先程のAPIを利用してファイルが存在するかどうかをチェックしている。

main.ts

main.ts
import fs from "fs";
import { app, BrowserWindow, ipcMain } from "electron";

// 先ほどのウィンドウ生成コードがここに入る

ipcMain.handle("existFile", (event: Electron.IpcMainInvokeEvent, filename: string) => {
    return fs.existsSync(filename);
  }
);

レンダラープロセス側からのイベントを受け取るには、ipcMain.handleを使用し、第一引数はipcRenderer.invokeの第一引数と一致している必要がある。
ipcMain.handleの第二引数は関数が入り、この関数の第一引数はElectron.IpcMainInvokeEventが入る。第二引数以降はinvokeの第二引数以降。
今回は、nodeの標準にあるfsexistsSyncを使用して存在確認をしている。

再度実行してみる

もう一度実行する。

% npm run start

今回はウィンドウに入力boxと検索ボタンが表示される。
試しに./src/main.tsと入力し、「./src/main.tsは存在しています」と表示されれば成功。
また、./main.tsと入力し、「./main.tsは存在していません」と表示されれば成功。

ちなみに相対パス指定で実行しているのは、nodeが提供するfs.existsSyncに依存しているため、npm run startを叩く位置によってパスは変化する。

終わりに

とりあえずこれで最低限動作するようなところまで出来るようになるはず。
一部まだ見直さないといけなさそうなところもあるので、気が向いたら更新するかも。

  1. まずElectronをsave-devで入れている時点で警告が出る。

  2. ではcross-envは何をしているの?というと、webpack.config.tsのenvの中身を置き換えている。

  3. ということはcontextBridgeを利用せずプレロードでnodeを呼び出せばいいのでは?誰もやらないということは多分何かあるのだと思うが…。

2
5
0

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
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?