CodeZine(コードジン)

特集ページ一覧

「Wijmo(ウィジモ)」とElectron、Reactを組み合わせて、Web技術でデスクトップアプリをつくろう

ECMAScript 5に準拠した高速・軽量なJavaScript UIライブラリ「Wijmo 5」の活用 第16回

  • ブックマーク
  • LINEで送る
  • このエントリーをはてなブックマークに追加
2021/01/05 12:00

目次

セットアップ(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.jsonmain"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.jsonmainエントリに、メインプロセスとして起動するファイルを指定する必要があります。

メインプロセスの起動スクリプトを作成する

 次に、メインプロセスとして起動する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.jsonscriptsstartに書かれた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のビルドを待ちます。bootBuilderPromiseを返す非同期処理ですが、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.COMMchild.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によりそれぞれのプロセスを終了させます。基本的にはありませんが、trycatchにより、プロセスの終了に失敗した場合エラー表示をして処理を継続します。

//(8)IPC通信のテスト実装
ipcMain.handle("test", (event, message) => {
  console.log("test message:", message);
});

 最後に(8) で、IPC 通信のテスト実装をしています。


関連リンク

  • ブックマーク
  • LINEで送る
  • このエントリーをはてなブックマークに追加

あなたにオススメ

著者プロフィール

  • erukiti(エルキチ)

     TypeScrip+React(とくに React Hooks)をこよなく愛するウェブエンジニア。技術書典に東京ラビットハウスという個人サークルで参加して、JavaScriptなどの技術同人誌を出している。大体いつも締め切りに追い立てられている。

バックナンバー

連載:最先端テクノロジーに対応した高速・軽量なJavaScript UIライブラリ「Wijmo」の活用

もっと読む

All contents copyright © 2005-2021 Shoeisha Co., Ltd. All rights reserved. ver.1.5