セットアップ(1)
最初に、Electron+React+TypeScriptの環境を準備します。
npx create-react-app --use-npm --template typescript wijmo-example1 # プロジェクト作成 cd wijmo-example1 # プロジェクトフォルダに移動 npm i -D electron electron-builder # Electron 開発に必要なソフトのインストール npm i electron-is-dev # Electron 開発に必要なソフトのインストール npm i ps-tree detect-port
インストールをしただけではElectronは動作しません。2つやらなければいけないことがあります。それはpackage.jsonの修正と、起動時に呼び出されるスクリプトの作成です。
package.jsonの修正
まず、Electronのメインプロセスが起動するためのエントリポイントを指定する必要があります。package.json
のmain
を"public/electron.js"
に追加し、scripts
に"dev": "electron .",
を追加してElectronを起動できるようにします。
"main": "public/electron.js", "scripts": { "dev": "electron .", "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" },
create-react-app
を使った通常のReact開発では、react-scripts start
によって開発サーバーが起動され、Webブラウザで開発サーバーのURL(普通はhttp://localhost:3000/
)が開かれます。
Electronを開発モードで起動するためには、electron
パッケージでインストールされるelectron
コマンドでpackage.json
のあるディレクトリを指定する必要があります。またpackage.json
のmain
エントリに、メインプロセスとして起動するファイルを指定する必要があります。
メインプロセスの起動スクリプトを作成する
次に、メインプロセスとして起動するJavaScriptのスクリプトを作成します。
// public/electron.js const path = require("path"); const fs = require("fs"); const childProcess = require("child_process"); const psTree = require("ps-tree"); const detect = require("detect-port"); const { app, BrowserWindow, ipcMain, dialog } = require("electron"); const isDev = require("electron-is-dev"); let mainWindow = null; let pids = []; //終了時に消す子プロセス一覧 let cp; //(1)起動時に react-scripts start を実行して React アプリをビルドする const bootBuilder = (port) => { return new Promise((resolve, reject) => { cp = childProcess.exec("npm run start", { //(1-1)子プロセスを起動する env: { ...process.env, BROWSER: "none", PORT: port }, }); //(1-2)ブラウザ起動抑制とポート指定をする const reURL = /Local:\s+(http:\/\/localhost:\d+)/; //(1-3)子プロセスの画面出力を監視して、開発サーバーのURLを見つけたら Promise を解決する cp.stdout.on("data", (data) => { process.stdout.write(data); //(1-4)標準出力を素通しする const matched = reURL.exec(data); //(1-5)正規表現で対象文字列を検出 if (matched) { resolve(matched[1]); //(1-6)URLを返してPromiseを解決する } }); cp.stderr.pipe(process.stderr); //(1-7)標準エラー出力を素通しする cp.on("close", () => { app.quit(); //(1-8)子プロセスが終了したらアプリ全体を終了する }); pids.push(cp.pid); //(1-9)終了時に消すプロセスIDを登録する }); }; //(2)ウィンドウ(レンダラープロセス)を作成する const createWindow = async () => { const url = isDev //(2-1)開発モードかどうか? ? await bootBuilder(await detect(3000)) //(2-2)Reactビルド待ち : `file://${path.join(__dirname, "../build/index.html")}`; //(2-3)ビルド済みのReactを読み込む //(3)子プロセスがある場合 if (cp && cp.pid) { //(3-1)子プロセスのさらに子孫の一覧を取得する psTree(cp.pid, (err, children) => { children.forEach((child) => { //(3-1)子プロセスのコマンドのうちパスを取り除いたものを取り出す const command = path.basename(child.COMM || child.COMMAND || ""); //(3-2)子プロセスの子孫のうちNode.jsの場合のみ if (["node", "node.exe"].includes(command)) { //(3-3)終了時に消すプロセス番号を登録する pids.push(Number.parseInt(child.PID, 10)); } }); }); } //(4)レンダラープロセス作成 mainWindow = new BrowserWindow({ webPreferences: { contextIsolation: true, //(4-1)セキュリティ設定 preload: path.join(__dirname, "preload.js"), //(4-2)レンダラープロセス初期化スクリプト指定 }, width: 1000, height: 600, }); mainWindow.loadURL(url); //(4-3)レンダラープロセスでURLを読み込む mainWindow.on("closed", () => { //(4-4)終了時の後始末 mainWindow = null; }); }; //(5)アプリケーションが起動可能になったらcreateWindowを呼び出す app.on("ready", () => createWindow()); //(6)ウィンドウを全て閉じたときの挙動を定義 app.on("window-all-closed", () => app.quit()); //(7)終了時に子プロセスとその子孫を終了させる app.on("quit", () => { pids.forEach((pid) => { try { process.kill(pid); } catch (e) { console.log(e); } }); }); //(8)IPC通信のテスト実装 ipcMain.handle("test", (event, message) => { console.log("test message:", message); });
順に説明していきます。
//(1)起動時に react-scripts start を実行してReactアプリをビルドする const bootBuilder = (port) => { return new Promise((resolve, reject) => { cp = childProcess.exec("npm run start", { //(1-1)子プロセスを起動する env: { ...process.env, BROWSER: "none", PORT: port }, }); //(1-2)ブラウザ起動抑制とポート指定をする const reURL = /Local:\s+(http:\/\/localhost:\d+)/; //(1-3)子プロセスの画面出力を監視して、開発サーバーのURLを見つけたら Promise を解決する cp.stdout.on("data", (data) => { process.stdout.write(data); //(1-4)標準出力を素通しする const matched = reURL.exec(data); //(1-5)正規表現で対象文字列を検出 if (matched) { resolve(matched[1]); //(1-6)URLを返してPromiseを解決する } }); cp.stderr.pipe(process.stderr); //(1-7)標準エラー出力を素通しする cp.on("close", () => { app.quit(); //(1-8)子プロセスが終了したらアプリ全体を終了する }); pids.push(cp.pid); //(1-9)終了時に消すプロセスIDを登録する }); };
まず(1-1)でnpm run start
というコマンドを子プロセスとして起動します。npm run start
は、さらにpackage.json
のscripts
のstart
に書かれたreact-scripts start
コマンドを実行しています。前述の通り、create-react-app
で作成されたプロジェクトでは、react-scripts
というコマンドにより、Reactの開発サーバーとWebブラウザが起動します。
Electronアプリでは、Webブラウザが自動的に起動すると困るため、(1-2)で環境変数としてBROWSER: "none"
を指定します。さらにbootBuilder
関数の引数port
に指定されたポート番号で起動するため、PORT: port
も指定しています。
(1-3)のcp.stdout.on('data', ...)
は子プロセスの標準出力を受け取るものです。子プロセスの標準出力、標準エラー出力は、特に何もしなければ画面に表示されずに捨てられます。そのため、ここで明示的に標準出力を受け取って、(1-4)で標準出力を素通しします。
また(1-5)で正規表現を使ってLocal: http://localhost:3000
のような開発サーバーのURLが出力されているかを検出します。検出されれば開発サーバーの起動は完了し、そのURLを取り出すことができたため、(1-6)でPromise
を解決してURLを返します。
(1-7)は標準エラー出力を素通しするためのものです。こちらは標準出力とは違って中身を見る必要がないため、Node.jsのStream APIにあるpipeメソッドを使っています。
(1-8)は、開発サーバーの子プロセスが終了したらアプリ全体を終了するためのもの、(1-9)は、終了時に消すプロセスIDを登録するもので、詳しくは後述します。
次に、(2)ウィンドウ(レンダラープロセス)を作成するコードを見ていきましょう。
//(2)ウィンドウ(レンダラープロセス)を作成する const createWindow = async () => { const url = isDev //(2-1)開発モードかどうか? ? await bootBuilder(await detect(3000)) //(2-2)Reactビルド待ち : `file://${path.join(__dirname, "../build/index.html")}`; //(2-3)ビルド済みのReactを読み込む
(2)のcreateWindow
はElectronのウィンドウ(レンダラープロセス)を作成するためのasync
関数です。async
関数では、await
キーワードを使うことで非同期処理を行うPromise
を同期的に扱えます。
(2-1)でisDev
を判定します。Electronが開発モードで起動しているかどうかを確認するのは大変なため、electron-is-dev
パッケージで得られるisDev
変数を使って判別します。この変数がtrue
の場合はelectron .
で起動している開発モードで、false
の場合はビルドされたアプリを起動しているプロダクションモードです。
開発モードなら(2-2)によって、先ほどのリスト(1)で作成したbootBuilder
によるReactのビルドを待ちます。bootBuilder
はPromise
を返す非同期処理ですが、await
キーワードにより、ビルド処理が完了するまで待つという同期処理になり、(1-6)でresolve
されたURLを受け取ります。
プロダクションモードなら(2-3)で、ビルド済みのReactを読み込むようにURLを指定します。
ここまでの処理で、開発モードとプロダクションモードの違いを踏まえて起動用のURLを取得できました。
//(3)子プロセスがある場合 if (cp && cp.pid) { //(3-1)子プロセスのさらに子孫の一覧を取得する psTree(cp.pid, (err, children) => { children.forEach((child) => { //(3-1)子プロセスのコマンドのうちパスを取り除いたものを取り出す const command = path.basename(child.COMM || child.COMMAND || ""); //(3-2)子プロセスの子孫のうちNode.jsの場合のみ if (["node", "node.exe"].includes(command)) { //(3-3)終了時に消すプロセス番号を登録する pids.push(Number.parseInt(child.PID, 10)); } }); }); }
開発モードのときはbootBuilder
で起動した子プロセスの情報がcp
変数に入っています。(3)ではcp
変数で、子プロセスがあるかどうか判定します。
子プロセスがある場合は、まず(3-1)でps-tree
パッケージを使って、子プロセスの子孫の一覧を取得します。
開発モードではnpm run start
を子プロセスとして起動しました。こちらはさらにいくつもの子孫となるプロセスを生み出します。これらのプロセスは、場合によっては親プロセスが終了しても生き残ります。
たとえば、ターミナルで親プロセスをCtrl+Cで終了した場合は子孫も全部終了しますが、Electronアプリの終了を行った場合、子プロセスは独立して生き残る挙動をします。
これを繰り返すと、余分なプロセスが生成され続けてリソースを食い散らかしてしまいます。そのため、子孫プロセスを消去する必要があります。
ただし子孫プロセスの中には削除すべきではないプロセスもあるため、Node.jsのプロセスのみを消すようにしなければいけません。ターミナルで動く子プロセスを起動するためのヘルパーなどが該当します。
まず(3-2)で子孫のコマンド名を取得します。child.COMM
かchild.COMMAND
にコマンド名が入っていますが、コマンド名のみのケースもあればフルパスを含む場合もあるため、path.basename
というNode.jsのパス名を操作する関数を使って、コマンド名のみを取り出します。
(3-2)では、コマンド名がnode
もしくはnode.exe
の場合のみに絞り込んでいます。
そして(3-3)で、Node.jsのプロセス番号をpids
に登録しますが、child.PID
は文字列のためNumber.parseInt
関数で10進数の数字として取り込んでいます。
子孫のプロセス番号の配列pids
は後ほど使います。
//(4)レンダラープロセス作成 mainWindow = new BrowserWindow({ webPreferences: { contextIsolation: true, //(4-1)セキュリティ設定 preload: path.join(__dirname, "preload.js"), //(4-2)レンダラープロセス初期化スクリプト指定 }, width: 1000, height: 600, }); mainWindow.loadURL(url); //(4-3)レンダラープロセスでURLを読み込む mainWindow.on("closed", () => { //(4-4)終了時の後始末 mainWindow = null; });
(4)のnew BrowserWindow(option)
でウィンドウ(レンダラープロセス)を作成します。
オプションとして指定されている(4-1)のcontextIsolation
はセキュリティを高めるための設定で、(4-2)はレンダラープロセスを初期化するための特別なスクリプト(プリロードスクリプト)を指定します。プリロードスクリプトの詳細は後述します。
ウィンドウが作成されても、(4-3)のmainWindow.loadURL(url)
を行わなければ、ウィンドウは白紙状態です。
(4-4)はそのウィンドウが終了するときの後始末のコードです。ウィンドウが1つの単純なアプリケーションではmainWindow = null
をするだけで済みます。
//(5)アプリケーションが起動可能になったらcreateWindowを呼び出す app.on("ready", () => createWindow());
(5)ではアプリケーションが起動可能になったら(2)で作成したcreateWindow
を呼び出しています。
//(6)ウィンドウを全て閉じたときの挙動を定義 app.on("window-all-closed", () => app.quit());
(6)ではウィンドウを全て閉じたときの挙動を定義しています。
//(7)終了時に子プロセスとその子孫を終了させる app.on("quit", () => { pids.forEach((pid) => { try { process.kill(pid); } catch (e) { console.log(e); } }); });
(7)ではElectronが終了するときの処理を定義しています。リスト(1)や(2)で登録した子孫プロセスのID配列pids
は、ここで使われます。
ここで、process.kill
によりそれぞれのプロセスを終了させます。基本的にはありませんが、try
catch
により、プロセスの終了に失敗した場合エラー表示をして処理を継続します。
- 参考:process.kill
//(8)IPC通信のテスト実装 ipcMain.handle("test", (event, message) => { console.log("test message:", message); });
最後に(8) で、IPC 通信のテスト実装をしています。