⚛️

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

14 min read

💡 はじめに

ReactTypeScript でつくる Electron アプリのボイラープレートです。
メインプロセス、レンダラープロセスともにホットリロード可能な開発環境の構築を目指します。

完成したテンプレートはこちら:

https://github.com/sprout2000/electron-react-ts

2021/08/03: 改題のうえ、内容を大幅にアップデートしました。

⚖️ 前提

Node.jsGit Bash (もしくは何らかの UNIX シェル)はインストール済みであることを想定しています。

⚒️ プロジェクトディレクトリの作成

⚙️ Node.js のプロジェクトとして初期化

npm コマンドは Node.js に同梱されています。

bash
$ mkdir zenn
$ cd zenn
$ npm init --yes

📥 Electron + TypeScript のインストール

bash
# electron本体とTypeSciprt関連
$ npm install --save-dev electron typescript ts-node @types/node

# メインプロセスのホットリロードに必要なパッケージ
$ npm install --save-dev electron-reload

# React Developer Tools をロードするための拙作ライブラリ
$ npm install --save-dev electron-search-devtools

https://www.npmjs.com/package/electron-search-devtools

📥 React のインストール

bash
# 本体
$ npm install react react-dom

# 型定義ファイル
$ npm install -D @types/react @types/react-dom

⚙️ レンダラープロセス用 TypeScirpt 設定ファイル: tsconfig.json の作成

プロジェクトフォルダ直下に tsconfig.json を作成します。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "moduleResolution": "Node",
    "esModuleInterop": true,
    "lib": ["DOM", "ES2020"],
    "jsx": "react",
    "strict": true,
    "sourceMap": true
  },
  "ts-node": {
    "compilerOptions": {
      "target": "ES2015",
      "module": "CommonJS"
    }
  }
}
  • Electron アプリでは Chromium ランタイムのみを考慮すれば足りるため target には es2015 以降を指定できます。

  • ts-node でスクリプトを実行する(webpack.config.ts などの TS で書かれた設定ファイルを評価する)には module: CommonJS の指定が必要です。

余談

ソースコードのコピー&ペーストも macOS ターミナルGit Bash for Windows などではコマンドラインから簡単に実行できます。

mac OS ターミナルの場合:

zsh
# 上記のソースコードをマウスドラッグ&右クリックメニューでコピーして...
% pbpaste > tsconfig.json

Git Bash の場合:

bash
# 上記のソースコードをマウスドラッグ&右クリックメニューでコピーして...
$ cat /dev/clipboard > tsconfig.json

⚙️ メインプロセス用 TypeScirpt 設定ファイル: tsconfig.main.json の作成

同じくプロジェクトフォルダ直下に tsconfig.main.json を作成します。

tsconfig.main.json
{
  "extends": ".",
  "compilerOptions": {
    "module": "CommonJS",
    "outDir": "dist"
  },
  "include": ["src/*.ts"]
}

📥 Webpack(バンドラー)のインストール

https://webpack.js.org/

本体

bash
$ npm i -D webpack webpack-cli

バンドルするための各種ローダーとプラグイン

bash
# ローダー
$ npm i -D ts-loader css-loader

# プラグインとその型定義ファイル
$ npm i -D mini-css-extract-plugin @types/mini-css-extract-plugin

バンドルされた JavaScirpt ファイルを HTML の <script> タグに差し込むためのプラグイン

bash
$ npm i -D html-webpack-plugin

⚙️ webpack.config.ts(webpack 設定ファイル)の例

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

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

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

const config: Configuration = {
  mode: 'development',
  // セキュリティ対策として 'electron-renderer' ターゲットは使用しない
  target: 'web',
  node: {
    __dirname: false,
    __filename: false,
  },
  resolve: {
    extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'],
  },
  entry: {
    // エントリーファイル
    app: './src/web/index.tsx',
  },
  output: {
    // バンドルファイルの出力先(ここではプロジェクト直下の 'dist' ディレクトリ)
    path: path.resolve(__dirname, 'dist'),
    // webpack@5 + electron では必須の設定
    publicPath: './',
    /**
     * エントリーセクションで名前を付けていれば [name] が使える
     * ここでは 'app.js' として出力される
     */
    filename: '[name].js',
    // 画像などのアセット類は 'assets' フォルダへ配置する
    assetModuleFilename: 'assets/[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: true },
          },
        ],
      },
      {
        /** 画像やフォントなどのアセット類 */
        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',
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin(),
    /**
     * バンドルしたJSファイルを <script></script> タグとして差し込んだ
     * HTMLファイルを出力するプラグイン
     */
    new HtmlWebpackPlugin({
      template: './src/web/index.html',
      filename: 'index.html',
      scriptLoading: 'blocking',
      inject: 'body',
      minify: false,
    }),
  ],
  /**
   * developmentモードではソースマップを付ける
   *
   * レンダラープロセスでは、ソースマップがないと
   * electron のデベロッパーコンソールに 'Uncaught EvalError' が
   * 表示されてしまうことに注意
   */
  devtool: 'inline-source-map',
};

export default config;

参考記事:

https://zenn.dev/sprout2000/articles/aa9ce779fd51a2

📥 各種ユーティリティのインストール

bash
$ npm i -D rimraf wait-on cross-env npm-run-all

⚙️ NPM スクリプトの設定

package.json
{
  "main": "dist/main.js",
  "scripts": {
    "predev": "rimraf dist",
    "dev": "run-p dev:*",
    "dev:electron": "wait-on ./dist/index.html && cross-env NODE_ENV=\"development\" electron .",
    "dev:tsc": "tsc -w -p tsconfig.main.json",
    "dev:webpack": "webpack --watch"
  }
}
  • main エントリには tsc が出力するメインプロセスの JS ファイルを指定します。
  • scripts
    • dev:electron: webpack によって dist/index.html が出力されるのを待って electron アプリを開発モードで起動します。
    • dev:tsc: メインプロセスをウォッチモードでコンパイルし、ソースへ加えられた変更を自動的に反映します。
    • dev:webpack: webpack でレンダラープロセス (React) をウォッチモードでバンドル&コンパイルします。
    • dev: npm run dev で上記3つのコマンドを並列に実行します。
    • predev: プロジェクトのスタート前に前回のビルド結果を削除します。

⚒️ Electron アプリの作成

📚 想定するファイル構成

bash
$ mkdir -p src/web
$ touch src/web/index.{html,tsx,css}
$ touch src/{main,preload}.ts

% tree -I 'node_modules'
.
├── package-lock.json
├── package.json
├── src
│   ├── main.ts
│   ├── preload.ts
│   └── web
│       ├── index.css
│       ├── index.html
│       └── index.tsx
├── tsconfig.json
├── tsconfig.main.json
└── webpack.config.ts

2 directories, 10 files

⚒️ レンダラープロセス (React) の作成

📋 src/web/index.html

src/web/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <!-- CSP の設定 https://developer.mozilla.org/ja/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self';" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Electron App</title>
  </head>
  <body>
    <!-- React アプリのマウントポイント -->
    <div id="root"></div>
  </body>
</html>

https://www.electronjs.org/docs/tutorial/security#6-define-a-content-security-policy

https://developer.mozilla.org/ja/docs/Web/HTTP/CSP

📋 src/web/index.tsx

src/web/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';

import './index.css';

const App = (): JSX.Element => {
  return (
    <div>
      <h1>Hello.</h1>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById('root'));

⚒️ メインプロセスの作成

src/main.ts
import path from 'path';
import { BrowserWindow, app, session } from 'electron';
import { searchDevtools } from 'electron-search-devtools';

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

const execPath =
  process.platform === 'win32'
    ? '../node_modules/electron/dist/electron.exe'
    : '../node_modules/.bin/electron';

// 開発モードの場合はホットリロードする
if (isDev) {
  require('electron-reload')(__dirname, {
    electron: path.resolve(__dirname, execPath),
    forceHardReset: true,
    hardResetMethod: 'exit',
  });
}

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

  if (isDev) {
    // 開発モードの場合はデベロッパーツールを開く
    mainWindow.webContents.openDevTools({ mode: 'detach' });
  }

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

app.whenReady().then(async () => {
  if (isDev) {
    // 開発モードの場合は React Devtools をロード
    const devtools = await searchDevtools('REACT');
    if (devtools) {
      await session.defaultSession.loadExtension(devtools, {
        allowFileAccess: true,
      });
    }
  }

  // BrowserWindow インスタンスを作成
  createWindow();
});

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

参考にした記事

https://qiita.com/geekduck/items/1e7b4a6bb242cd577c30

🧪 実行テスト

bash
$ npm run dev

📌 おまけ

じつは、上のコードを ↓ の webpack.config.prod.ts を使って本番向けに macOS 上でビルドしようとすると失敗します。

webpack.config.prod.ts (クリックして展開/折り畳み)
webpack.config.prod.ts
import path from 'path';
import { Configuration } from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';

const base: Configuration = {
  mode: 'production',
  node: {
    __dirname: false,
    __filename: false,
  },
  resolve: {
    extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'],
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: './',
    filename: '[name].js',
    assetModuleFilename: 'images/[name][ext]',
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          { loader: 'ts-loader' },
          // { loader: 'ifdef-loader', options: { DEBUG: false } },
        ],
      },
      {
        test: /\.s?css$/,
        use: [
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: { sourceMap: false },
          },
        ],
      },
      {
        test: /\.(bmp|ico|gif|jpe?g|png|svg|ttf|eot|woff?2?)$/,
        type: 'asset/resource',
      },
    ],
  },
  stats: 'errors-only',
  performance: { hints: false },
  optimization: { minimize: true },
  devtool: undefined,
};

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,
  target: 'web',
  entry: {
    index: './src/web/index.tsx',
  },
  plugins: [
    new MiniCssExtractPlugin(),
    new HtmlWebpackPlugin({
      template: './src/web/index.html',
      minify: true,
      inject: 'body',
      filename: 'index.html',
      scriptLoading: 'blocking',
    }),
  ],
};

export default [main, preload, renderer];
zsh
% webpack --config webpack.config.prod.ts --progress
ERROR in ./node_modules/fsevents/fsevents.node 1:0
Module parse failed: Unexpected character '�' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
(Source code omitted for this binary file)
 @ ./node_modules/fsevents/fsevents.js 13:15-41
 @ ./node_modules/chokidar/lib/fsevents-handler.js 9:13-32
 @ ./node_modules/chokidar/index.js 15:24-57
 @ ./node_modules/electron-reload/main.js 2:17-36
 @ ./src/main.ts 12:4-30

webpack compiled with 1 error
error Command failed with exit code 1.

📥 そこで ifdef-loader

ifdef-loader を利用して、本番ビルド時にはホットリロード関連のコードを評価しないようにします。

zsh
% npm i -D ifdef-loader

👷 メインプロセスのコードを改修

ホットリロード周りを #if DEBUG でラップします。

main.ts
+  /// #if DEBUG
   const execPath =
     process.platform === 'win32'
       ? '../node_modules/electron/dist/electron.exe'
       : '../node_modules/.bin/electron';

   if (isDev) {
     require('electron-reload')(__dirname, {
       electron: path.resolve(__dirname, execPath),
       forceHardReset: true,
       hardResetMethod: 'exit',
     });
   }
+  /// #endif

webpack.config.prod.ts へ ifdef-loader を組み込みます。

webpack.config.prod.ts
       {
         test: /\.tsx?$/,
         exclude: /node_modules/,
         use: [
           { loader: 'ts-loader' },
+          { loader: 'ifdef-loader', options: { DEBUG: false } },
         ],
       },

🎉 ビルドふたたび

zsh
% npm run build

> zenn@1.0.0 build
> webpack --config webpack.config.prod.ts --progress

💡 こちらもよろしくお願いします

https://zenn.dev/sprout2000/books/6f6a0bf2fd301c

https://zenn.dev/sprout2000/books/3691a679478de2

Discussion

ログインするとコメントできます