はじめに
Wijmo(ウィジモ)は、グレープシティがHTML/JavaScript環境に向けて提供しているJavaScriptライブラリで、WebサイトやWebアプリケーションで活用できるUI部品を利用者に提供します。
Wijmoは単体での利用に加え、さまざまなJavaScriptフレームワークやライブラリと組み合わせて利用できます。今回はWeb技術デスクトップアプリを開発するフレームワークであるElectronとReactでWijmoを利用する方法を紹介します。また、後編ではTypeScriptを組み合わせます。
なお、本記事のサンプルコードは以下のリポジトリに用意しています。
対象読者
- WebサイトやWebアプリケーションのレベルをワンランク上げたい方
- 高度なUI部品を手軽に使いたいReactユーザーの方
- Web技術でデスクトップアプリを作りたい方
必要な環境
Electronの開発にはNode.jsが必須です。Electronでは、最新のLTSまたは現在のバージョンのいずれかをインストールすることを推奨しています。
Electronとは
Electronとは、Web技術でクロスプラットフォームなデスクトップアプリを開発できるフレームワークです。Electronで作られたアプリとしてはVSCodeやSlackなどが有名どころです。
Web技術で開発できるため、WebサービスやWebアプリケーションと、コードの共通化をしたまま、デスクトップアプリを開発できます。
仕組み
デスクトップアプリケーションのエンジンとして、GoogleのOSSなWebブラウザChromiumを組み込んでいるため、Web技術でウィンドウなどを作成でき、サーバーサイドJavaScriptでも使われるNode.jsを組み込むことでWebブラウザだけではできない処理を実現しています。
2種類のプロセス
起動時にはメインプロセスが走ります。このメインプロセスは実質Node.jsなので、ファイルシステムやローカルデバイスの全てにアクセスが可能ですし、任意のサーバーとして動かすことができます。
メインプロセスからウィンドウを開くことができます。このウィンドウはレンダリングプロセスと呼ばれ、ChromiumによるWebブラウザのフロントエンド部分をアプリケーションっぽくしたものです。
これら、メインプロセスとレンダリングプロセスは分離されているため、わざと緩める設定をしなければ、レンダリングプロセスからは直接ローカルの仕組みにアクセスできないようにすることでセキュリティを担保できます。
メインプロセスとレンダリングプロセスは名前の通り、プロセスとして分離しているため、IPC(プロセス間通信)という仕組みで通信します。ローカルデバイスやファイルにアクセスする場合、メインプロセス側にその仕組みを実装し、レンダリングプロセスからはIPCを使って、メインプロセスにお願いをするのが一般的です。
配布可能形式にパッケージングする
Electronアプリケーションは、パッケージングすることで、Windows、Mac、Linuxなどで動くアプリケーションとして配布可能です。
具体的なパッケージング手順は後編にて紹介いたします。
Reactとは
React自体は過去の連載でも取り扱っているため、詳細は省略します。
この記事と同じくcreate-react-app
を使ってプロジェクトを作成します。実際のReactのビルドや開発サーバー機能の提供は、プロジェクト作成時にインストールされるreact-scripts
というパッケージが受け持っています。
セットアップ(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 通信のテスト実装をしています。
セットアップ(2)
プリロードスクリプトを作成する
Electronはセキュリティ改善のため、バージョンアップのたびにセキュリティ要件が厳しくなっています。
Electronバージョン5より前では、nodeIntegraion
という、レンダラープロセスにそのままNode.jsを統合する機能がデフォルトで有効でした。
レンダラープロセスはWebブラウザの技術で作られており、元々利便性のためにNode.jsも一緒に組み込んでいましたが、動作するマシンの全てのリソースに簡単にアクセスできセキュリティリスクが高かったため、バージョン5ではデフォルトで無効化されました。
ところがElectronの機能を初期化するときに不都合があるため、プリロードという、レンダラープロセスでありながらNode.jsの機能も使える特権的なスクリプトが登場しました。
プリロードが使われ始めたころはwindow
グローバル変数を書き換えていましたが、これもセキュリティリスクが高いため、contextIsolation
というオプションが追加されました。contextIsolation
が有効になると、プリロードスクリプトと、レンダラープロセスのアプリケーションはwindow
などを共有しません。
それらを踏まえて、プリロードスクリプトを見てみましょう。
// public/preload.js const { contextBridge, ipcRenderer } = require("electron"); //(1)contextBridgeを使って、メインプロセスの機能をレンダリングプロセスに提供する contextBridge.exposeInMainWorld("ipc", { test: (message) => { ipcRenderer.invoke("test", message); //(2)IPC通信を送信する }, });
(1)で使っているcontextBridge
はcontextIsolation
が有効な場合でも、安全に処理をやりとりするためのオブジェクトです。
contextBridge.exposeInMainWorld
の第1引数は、レンダラープロセスのwindow
の下に新たに生やすオブジェクトの名前です。第2引数はオブジェクトで実際の機能を定義します。
(2)で使われているipcRenderer.invoke
はIPC通信を行うものです。古い資料にはipcRenderer.send
が使われる事例がありますが、現在ではipcRenderer.invoke
が推奨されています。
このプリロードスクリプトにより、レンダラープロセスはwindow.ipc.test('message')
によって、メインプロセスにテストメッセージを送信できるようになります。
TypeScriptでIPC通信をできるようにする
TypeScriptに関しての詳細は後編で書きますが、IPC通信のためにwindow
を拡張する場合、専用の定義が必要です。
まずsrc/@types/global.d.ts
に定義します。
// src/@types/global.d.ts interface Window { //(1)対象はWindowというインターフェース ipc: { test: (message: string) => void; //(2)test関数の型定義 }; }
(1)の Window
というインターフェースは、Webブラウザのwindow
オブジェクトの型を定義するものです。ここに追加で、IPCの為のオブジェクトを追加します。
(2)のtest: (message: string) => void
は、ipc
オブジェクトの中にtest
という関数を定義しています。引数にstring
型を受け取り、戻り値がないを意味するvoid
を指定しています。
これにより、window.ipc.test
が使えるようになりました。
Electronアプリを開発モードで起動する
ここまでの工程によって、npm run dev
でElectronアプリとして起動できるようになりました。
# npmの場合 npm run dev
IPC送信を試す
起動したままソースコードを書き換えることもできます。さっそくApp.tsx
を書き換えてみましょう。
// src/App.tsx import React from "react"; function App() { return ( <button onClick={() => window.ipc.test("Hello, Electron Main World.")}> テストメッセージ送信 </button> ); } export default App;
こちらはボタンを押すと、window.ipc.test
でメッセージを送信するコードです。
Starting the development server... Compiled successfully! You can now view wijmo-example in the browser. Local: http://localhost:3000 On Your Network: http://192.168.1.7:3000 Note that the development build is not optimized. To create a production build, use npm run build. test message: Hello, Electron Main World. Compiling... Files successfully emitted, waiting for typecheck results... Compiled successfully! test message: Hello, Electron Main World.
メインプロセスの管轄である、コンソールにテストメッセージが表示されました。
Wijmoを組み込む
ここまでで、Electron+React+TypeScriptが動くようになっていますが、まだWijmoが組み込まれていません。
Wijmoを組み込むのはとても簡単です。まずElectronを止めて、パッケージをインストールしましょう。
npm i @grapecity/wijmo.react.all
npmパッケージを追加するだけです。追加が完了したら再度Electronを起動しましょう。
Wijmoの直線ゲージを使ってみる
Wijmoのゲージウィジェットの中でも直線でゲージを表示するLinearGauge
を使ってみましょう。
// src/App.tsx import React from "react"; import "@grapecity/wijmo.styles/wijmo.css"; //(1) import * as wjGauge from "@grapecity/wijmo.react.gauge"; //(2) function App() { const [gauge, setGauge] = React.useState(30); //(3) return ( <div> <h1>Wijmo + React + Electron</h1> <wjGauge.LinearGauge //(4) className="wijmo-control" //(5) isReadOnly={false} //(6) value={gauge} //(7) valueChanged={(ev: any) => setGauge(ev.value)} //(8) /> ゲージの値: {gauge} {/*(9)*/} </div> ); } export default App;
(1)ではWijmoを使う上で必要となるCSSを読み込んでいます。この行を消してみるとゲージのようにCSS必須のコンポーネントではまともに表示できないことを確認できます。
(2)ではWijmoのゲージウィジェットをまとめてインポートしています。
(3)は、React16.8から追加されたReactHooksという仕組みを使って、関数コンポーネントにステートを追加しています。以前のクラス型コンポーネントでは、this.state
でステートを保持していましたが、ReactHooksではステートを持つということをフック関数により宣言するようになりました。これにより、クラス型コンポーネントよりも圧倒的に短いコードでコンポーネントを記述できます。このuseState
の戻り値は2要素固定の配列(タプルといいます)で、タプルの1つ目の要素が値を格納した変数で、2つ目の要素が値を変更するセッター関数です。
(4)ではwjGauge.LinearGauge
というReactコンポーネントを使っています。
(5)のclassName
プロパティは、CSSのためにクラス名としてwijmo-control
を指定しています。
(6)のisReadOnly
プロパティは、「読み取り専用かどうか?」をtrue
やfalse
で設定します。false
の場合、ゲージをクリックするとvalueChanged
イベントが発生して値を更新できます。true
の場合は、ゲージをクリックしても何も起こりません。単にゲージで情報を表示したいだけの場合にはtrue
を指定するとよいでしょう。
(7)は、ゲージの数値を0~100で指定します。レンジ外の数値を指定してもエラーにはなりませんが、NaN
を指定するとエラーになるので注意しましょう。文字などを指定してもエラーになるので<input type="text">
などで値を取り出す場合にはNumber.parseInt
などを活用して数字以外を渡さないように注意してください。
(8)は、isReadOnly
がfalse
のときに、ゲージをクリックしたときのイベントハンドラです。(ev: any) => setGauge(ev.value)
は、TypeScriptのany
型を指定した引数ev
を持つアロー関数です。valueChanged
イベントでは、ev.value
で値(0~100の数値)を取り出すことができます。
(9)の{gauge}
はReactのJSXで値をそのまま表示するための記法です。
前編まとめ
本記事では、グレープシティのJavaScriptライブラリ「Wijmo」を、Electron+React+TypeScriptと組み合わせて利用する方法を説明しました。
Wijmoのパーツを活用したデスクトップアプリケーションを作成できます。