はじめに
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によりそれぞれのプロセスを終了させます。基本的にはありませんが、trycatchにより、プロセスの終了に失敗した場合エラー表示をして処理を継続します。
- 参考: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のパーツを活用したデスクトップアプリケーションを作成できます。

