初書: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
コードは参考サイトからほとんどお借りしている。
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
に変更しておく
"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_ENV
をpackage.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。ここでレンダラープロセスを生成する。
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
<!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
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.ts
のnew BrowserWindow
の際に、
nodeIntegration
をfalseに設定していると、レンダラープロセス側からnodeにアクセスできなくなる。
これをtrueにしてしまえばレンダラープロセスだけで完結するが、例えばiframe
や、a
タグを踏んだりするなどして
外部のサイトを開くことが出来るようになると、そのサイトに悪意のコードがあればnodeを実行できるようになり、危険になる。
そのため、基本的にはnodeIntegration
はfalseで扱う。
ではどのようにメインプロセスと通信するかというと、先ほど空白にしたpreload.ts
を経由して通信を行う。
プリロードスクリプトは、ウェブコンテンツの読み込み開始前にレンダラープロセス内で実行されるコードです。 これらのスクリプトはレンダラーのコンテキスト内で実行されますが、Node.js の API にアクセスできるようにより多くの権限が与えられています。
(中略)
プリロードスクリプトは、グローバルな Window インターフェイスをレンダラーと共有し Node.js の API にアクセスすることができます。そのため、window グローバルに任意の API を公開してウェブコンテンツが利用できるようにすることで、レンダラーを強化する役割を果たしています。
プリロードスクリプトはアタッチされているレンダラーと window グローバルを共有しますが、contextIsolation のデフォルト値によりプリロードスクリプトの変数は window に直接アタッチできません。
つまり、contextBridge
を利用してレンダラープロセス側のグローバル変数に追加でき、かつnodeを実行できるレンダラープロセス側のソースコードになる。3
これを利用して、メインプロセスと繋ぐ橋を生成し、通信を行う。
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
という名前は何でもいい。
import { preloadObject } from '../preload';
declare global {
interface Window {
api: typeof preloadObject;
}
}
これで、window.api.
と入力するとexistFile
が入力予測で表示される。
renderer.ts
先ほど使ったglobal.d.tsを使って型推論をしてもらう。
もちろんここからコピペするだけならanyでも何でもいいので使う意味はないが、この先は自身で作成すると思うので。
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とボタンのみ設置されている。
また、handleOnChange
とhandleOnClick
はそれぞれtextの中身を書き換えた時と、ボタンをクリックした際に呼び出される。
これにより、state
のfilename
で入力情報を管理し、検索を押された際は先程のAPIを利用してファイルが存在するかどうかをチェックしている。
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の標準にあるfs
のexistsSync
を使用して存在確認をしている。
再度実行してみる
もう一度実行する。
% npm run start
今回はウィンドウに入力boxと検索ボタンが表示される。
試しに./src/main.ts
と入力し、「./src/main.tsは存在しています」と表示されれば成功。
また、./main.ts
と入力し、「./main.tsは存在していません」と表示されれば成功。
ちなみに相対パス指定で実行しているのは、nodeが提供するfs.existsSync
に依存しているため、npm run start
を叩く位置によってパスは変化する。
終わりに
とりあえずこれで最低限動作するようなところまで出来るようになるはず。
一部まだ見直さないといけなさそうなところもあるので、気が向いたら更新するかも。