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

13 min read読了の目安(約12400字

下準備

VSCode のインストール

Node.js + Git のインストール

bash
$ brew install node@14
$ brew install git

ビルドツールのインストール

https://qiita.com/chenglin/items/b3ca0bc1dca0ed682b05
  • macOS は Homebrew のセットアップ時に Command Line Tools for Xcode がインストールされている。何らかの理由でインストールされていない場合は以下のコマンドでインストールする。
bash
// CLT のインストール
$ xcode-select --install

// 確認
$ brew --config
~ snip ~
macOS: 11.2.1-arm64
CLT: 12.4.0.0.1.1610135815
Xcode: 12.4
Rosetta 2: false

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

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

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

Electron + TypeScript のインストール

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

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 によるスクリプト実行時には module: CommonJS の指定が必要。

リンターとフォーマッター (eslint + prettier ほか) のインストール(オプショナル)

bash
// ESLint と Prettier
$ npm i -D eslint prettier eslint-config-prettier

// TypeScript用ESLint
$ npm i -D @typescript-eslint/parser @typescript-eslint/eslint-plugin

// React関連プラグイン
$ npm i -D eslint-plugin-react eslint-plugin-react-hooks

設定ファイル(.eslintrc.json / .prettierrc.json)の例

.eslintrc.json
{
  "env": {
    "es6": true,
    "node": true,
    "browser": true,
    "commonjs": true
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "sourceType": "module",
    "ecmaVersion": 2018,
    "ecmaFeatures": {
      "jsx": true
    }
  },
  "plugins": ["@typescript-eslint", "react", "react-hooks"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "prettier"
  ],
  "rules": {
    "react/prop-types": "off"
  }
}
.prettierrc.json
{
  "singleQuote": true,
  "jsxBracketSameLine": true
}

参考記事:

https://zenn.dev/sprout2000/articles/9f20902d394aa2

VSCode の設定(オプショナル)

  1. ESLint 拡張Prettier 拡張 をインストール
  2. 設定ファイルの作成
bash
$ mkdir .vscode
$ touch .vscode/settings.json
.vscode/settings.json
{
  "editor.formatOnSave": true,      // <-- prettier で整形
  "editor.codeActionsOnSave": {
    "source.fixAll": true           // <-- eslint でリント
  },
  "editor.defaultFormatter": "esbenp.prettier-vscode"
}

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

  • 本体
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 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,
};

// メインプロセス用の設定
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];

参考記事:

https://qiita.com/sprout2000/items/0bd863a69d10dd46b05b

https://qiita.com/sprout2000/items/0d9fcab0407b3f5012c9

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

各種ユーティリティのインストール(オプショナル)

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

NPM スクリプトの設定例

package.json
{
  "main": "dist/main.js",
  "scripts": {
    "start": "run-s clean build serve",
    "clean": "rimraf dist",
    "build": "cross-env NODE_ENV=\"development\" webpack --progress",
    "serve": "electron ."
  }
}
  • main エントリには webpack が出力するメインプロセスの JS ファイルを指定
  • scripts
    • clean: 古いコンパイル済みファイルを削除
    • build: webpack でバンドル&コンパイル(development モード)
    • serve: electron アプリの起動
    • start: npm start で上記3つのコマンドを順番に実行

想定するファイル構成

bash
$ mkdir src
$ touch src/{index.html,main.ts,preload.ts,renderer.tsx}

$ tree -a -I 'node_modules'
.
|-- .eslintrc.json
|-- .prettierrc.json
|-- .vscode
|   └── settings.json
|-- package-lock.json
|-- package.json
|-- src
|   |-- index.html
|   |-- main.ts
|   |-- preload.ts
|   └── renderer.tsx
|-- tsconfig.json
└── webpack.config.ts

2 directories, 11 files

Content-Security-Policy の設定 (index.html)

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

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

https://qiita.com/sprout2000/items/b402e2ab31a0e43fce41
src/index.html
<!DOCTYPE html>
<html lang="en">
  <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';" />
    <meta
      http-equiv="X-Content-Security-Policy"
      content="default-src 'self';"
    />

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

本番環境では、クロスサイトスクリプティング攻撃に対する耐性を高め、インラインスクリプトや eval() の実行を防ぐため、default-src'self' (保護された文書が提供されたオリジンを、同じ URL スキームおよびポート番号で参照する)を指定する。

この設定では、CSS ファイルや画像ファイルが style-loaderasset/inline によって JS ファイルにバンドルされていると**「インライン」**と解釈されて読み込みを拒否される。

よって、webpack.config.ts では、style-loader の代わりに Mini-css-extract-plugin を、asset/inline の代わりに asset/resource を使用する。

メインプロセス main.ts

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

/**
 * BrowserWindowインスタンスを作成する関数
 */
const createWindow = () => {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      /**
       * BrowserWindowインスタンス(レンダラープロセス)では
       * Node.jsの機能を無効化する(electron@8以降でデフォルト)
       */
      nodeIntegration: false,
      /**
       * メインプロセスとレンダラープロセスとの間で
       * コンテキストを共有しない (electron@11以降でデフォルト)
       */
      contextIsolation: true,
      /**
       * Preloadスクリプト
       * webpack.config.js で 'node.__dirname: false' を
       * 指定していればパスを取得できる
       */
      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 () => {
  /**
   * 開発時には React Developer Tools をロードする
   */
  if (process.env.NODE_ENV === 'development') {
    await session.defaultSession
      .loadExtension(path.join(os.homedir(), '/path/to/reactDevtools'), {
        allowFileAccess: true,
      })
      .then(() => console.log('React Devtools loaded...'))
      .catch((err) => console.log(err));
  }

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

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

参考資料

https://www.electronjs.org/docs/tutorial/devtools-extension

レンダラープロセス renderer.tsx

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

const App: React.FC = () => <h1>Hello Electron!</h1>;

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

実行テスト

bash
$ npm start

2021-02-20 17.03.34 (1).png

https://zenn.dev/sprout2000/articles/7d2644bb4e198e

または

https://zenn.dev/sprout2000/articles/02d83ec849792e

へ続く