はじめに
前回に引き続き、グレープシティがHTML/JavaScript環境に向けて提供するJavaScriptライブラリWijmo(ウィジモ)と、さまざまなJavaScriptフレームワークやライブラリと組み合わせてアプリケーションを作成します。
前回は、Web技術デスクトップアプリを開発するフレームワークであるElectronとReactを利用しました。今回はさらにTypeScriptを組み合わせていきます。
なお、今回もサンプルコードは以下のリポジトリに用意しています。
TypeScriptとは
Microsoftが開発するTypeScriptは、動的型言語であるJavaScript(ECMAScript)に、静的型を導入した言語です。
ECMAScriptは、国際機関が制定しているECMA-262という規格で、JavaScriptから言語仕様のみを抜き出したものです。毎年6月ごろに仕様が追加されています。JavaScriptとは、言語仕様としてのECMAScriptに、Webブラウザ向けの仕様であるDOM(Document Object Model)などを総合したものと言えます。
すなわち、TypeScriptはECMAScriptの上位互換として、静的型の機能を追加した言語仕様ということです。また、TypeScriptのコードをECMAScriptに変換するコンパイラ(TypeScript compiler)も提供されています。
今やJavaScript開発はSPA(Single Page Application)が当たり前となり、片手間で開発できるものではなくなってきています。TypeScriptはそういった大規模・高度化している開発を、ある程度安全にできるため、世界的にもシェアを伸ばし続けている人気の言語です。
コンパイラの設定によっては型チェックなどを緩めることも可能なので、JavaScriptのコードを段階的に移行できることも人気のポイントです。
型の恩恵
例えば、Reactのコンポーネントを型定義しておくと、呼び出し元で誤ったプロパティを入れたときや、必須プロパティが足りないときに、コンパイラが静的にエラーを出してくれます。
// Labelコンポーネント
const Label: React.FC<{
color: string;
label: string;
size: number;
}> = ({ color, label, size }) => {
return <label style={{ color, fontSize: ${size}px }}>{label}</label>;
};
こうして定義したLabelコンポーネントは、<Label color="#cc0000" label="ラベル" size={24} />のように利用します。
このコンポーネントは、colorlabelsizeの3つのパラメータの指定が必須であり、colorとlabelは文字列(string)で、sizeは数値型(number)でなければいけません。
例えば、<Label color={1} label="ラベル" size={24} />のように誤ったプロパティを渡そうとすると、Type 'number' is not assignable to type 'string'.という、数値型と文字列型が食い違っていることを知らせるエラーが出ます。
また<Label color="#cc0000"/>のように必須プロパティを省略した場合には、Type '{ color: string; }' is missing the following properties from type 'Props': label, sizeという、labelとsizeが足りないことを知らせるエラーが出ます。
このようにTypeScriptを使えば、関数の引数やReactコンポーネントのプロパティに指定すべき値を必須にしたり、型を指定したりすることで、不注意でバグを出してしまう可能性を減らせます。
IDE支援
TypeScriptの効能はこれだけではありません。
Web開発で人気のあるVisual Studio CodeはTypeScriptコンパイラを使って、IDE機能を実現しています。JavaScriptで書かれたコードでも、ある程度のIDEによる支援を受けられますが、本領を発揮できるのはTypeScriptのコードです。
先ほど紹介したLabelのようなコンポーネントがいっぱいあるプロジェクトに新しく入ったとします。いちいちラベルコンポーネントの使い方を他の人に聞き回るのも非効率でしょう。
VSCodeでReact+TypeScript開発を行う場合、その程度の情報であれば、エディタ(IDE)が教えてくれます。
function App() {
return <L
}
「L」まで打ち込んだ状態で、スペルを覚えていなかったとしても、IDEが候補を出してくれます。
先頭にあるLabelを選択すると、
function App() {
return <Label
}
この通り補完されます。
パラメータはどうでしょうか? 先ほどの必須パラメータを省略したときと同じ挙動を示してくれます。
Labelコンポーネントがどう定義されているか表示されていますし、エラーメッセージとしてcolorlabelsizeが必須パラメータであることを教えてくれます。
では、パラメータを入力していきましょう。colorのためにcを打ち込んだ時点で候補が出てきます。
function App() {
return <Label c
}
colorかchildren?の2択に絞り込めます。children?のように末尾に?がついているものは省略可能です。
ここでもcolorはstring型であると教えてくれるので、colorに該当しそうな文字列を指定すれば大丈夫です。
同様の手順を踏んで、colorlabelsizeを指定すれば、正しくLabelコンポーネントを利用できるでしょう。
JSDocを活用する
コンポーネントの設計が明確、つまりプロパティの名前がわかりやすく、型が適切であれば使い方がある程度理解できるはずですが、より適切な開発をするためには JSDocと呼ばれる、コード内に記述するドキュメントを活用するといいでしょう。
/** ラベルコンポーネント */
const Label: React.FC<{
/** CSSのcolorで指定可能なカラーコード */
color: string;
/** ラベルの内容 */
label: string;
/** フォントサイズ(px) */
size: number;
}> = ({ color, label, size }) => {
return <label style={{ color, fontSize: ${size}px }}>{label}</label>;
};
このように/** 中身の説明 */をコメントするだけで、参照可能なドキュメントになります。コンポーネント名や関数名、引数名やプロパティを、マウスホバーすると参照できます。
Labelをマウスホバーした場合は「ラベルコンポーネント」という説明が表示されるようになりました。
colorなら「CSSのcolorで指定可能なカラーコード」という説明が表示されます。
TypeScriptでより快適な開発を
ここまでの説明の通り、TypeScriptはECMAScriptに静的型を導入した言語であり、大規模・複雑化するJavaSrcipt開発を、より安全に行える人気の言語です。関数や、Reactコンポーネントなどに型をつけることで、引数やプロパティの型を、適切に制限できるため、誤った使い方を事前に検出できます。
VSCodeなどのIDEを使えば、コード入力中の候補や、使い方についての表示を出してくれるため開発効率を上げることができます。特にJSDocと呼ばれるコード内ドキュメントを活用すれば、IDE上で簡単に参照できます。
次のページではReact+TypeScript+Electron+Wijmoのコードを書きながらTypeScriptのパワーを見ていきましょう。
生産性を描画するアプリを作る
それでは、ElectronとWijmoを活用したアプリをTypeScriptで書いていきましょう。セットアップ手順は前編に書いた通りです。
- プロジェクトの作成、パッケージインストール
- package.jsonを変更
- public/electron.jsとpublic/preload.jsを追加
- src/@types/global.d.tsを追加
- @grapecity/wijmo.react.allをインストール
前編の記事からの続きでも構いませんし、今回新たに、1~5のセットアップをしてから行っても構いません。
今回は、データをビジュアル化するためにWijmoのチャート「FlexChart」やデータグリッド「FlexGrid」を使います。これらのコンポーネントは、利用者が定義したデータ型の配列を渡して描画できるものです。
そこでまずはTypeScriptでデータ型を定義してみましょう。
データ型を定義する
TypeScriptではtype Data = ...のように型を定義します。
// src/types.ts
export type Data = {
/** 年月2020/01のような形式 */
ym: string;
/** 稼働時間合計 */
time: number;
/** 生産数 */
count: number;
};
src/types.tsにData型を定義しました。Data型は年と月を表現するymという文字列、稼働時間のtimeという数値型、生産数のcountという数値型を持ちます。
生産性表示コンポーネントを作成する
次に、Wijmoのチャートとデータグリッドを組み合わせた、生産性表示コンポーネントProductivityを作成します。
// src/Productivity.tsx
import { useMemo } from "react";
import * as wjGrid from "@grapecity/wijmo.react.grid"; //(1)グリッドを読み込む
import * as wjChart from "@grapecity/wijmo.react.chart"; //(2)チャートを読み込む
import * as wj from "@grapecity/wijmo"; //(3)CollectionView を読み込む
import { Data } from "./types";
//(4)Productivity コンポーネント
/** 生産性描画コンポーネント
* @param data 生産性データの配列
*/
const Productivity: React.FC<{ data: Data[] }> = ({ data }) => {
//(4-1)プロパティで受け取った Data型の配列を、WijmoのCollectionViewに変換する
const items = useMemo(() => {
return new wj.CollectionView<Data>(data);
}, [data]);
return (
<div>
<wjChart.FlexChart itemsSource={items} bindingX="ym">
<wjChart.FlexChartSeries name="稼働時間" binding="time" />
<wjChart.FlexChartSeries
name="生産数"
binding="count"
chartType="LineSymbols"
/>
<wjChart.FlexChartLegend position="Bottom" />
</wjChart.FlexChart>
<wjGrid.FlexGrid itemsSource={items}>
<wjGrid.FlexGridColumn header="年月" binding="ym" width="*" />
<wjGrid.FlexGridColumn header="稼働時間" binding="time" width="*" />
<wjGrid.FlexGridColumn header="生産数" binding="count" width="*" />
</wjGrid.FlexGrid>
</div>
);
};
export default Productivity;
(1)と(2)でそれぞれWijmoのFlexGridとFlexChartを読み込んでいます。
(3)では、Wijmoのコンポーネントで使われるCollectionViewクラスを読み込んでいます。
(4)が、今回作るProductivityコンポーネントで、先ほど作成したData型の配列をdataというプロパティで受け取るものです。const Productivity: React.FC<{ data: Data[] }> = ...はProductivityというReactの関数型コンポーネントを定義しています。
const name = 'Wijmo';のようなシンプルな変数定義と違う点は、: React.FC<{ data: Data[] }>でしょう。これはTypeScriptの型アノテーションと呼ばれるもので、React.FCというReact関数型コンポーネントであり、コンポーネントのプロパティは、{ data: Data[] }つまり、Data型の配列であるdataを持つという宣言をしています。
少し複雑な型アノテーションであるため、もっとシンプルなケースを説明します。例えば文字列型ならconst name: string = 'Wijmo';のようになりますし、数値型ならconst yaer: number = 2020;になります。
ただし、実際には数値や文字列の変数定義のように、型がわかりやすいものは、型推論というTypeScriptの賢い機能があるため、わざわざ: stringや: numberを指定することは必要はありません。
次に、Productivity関数型コンポーネントの実際の中身を見ていきましょう。
(4-1)では、ReactHooksのuseMemoというフック関数を使って、Data型の配列を、WijmoのCollectionViewオブジェクトに変換しています。useMemoは、第2引数の配列が変更されない限りは同じ値を返すという仕様をしています。キャッシュに似た仕組みであるメモ化をしてくれるものです。
サンプルデータを描画する
それでは、作成したProductivityコンポーネントを使って、実際にサンプルデータを用意して描画してみましょう。
// src/App.tsx
import React from "react";
import Productivity from "./Productivity";
import { Data } from "./types";
import "@grapecity/wijmo.styles/wijmo.css";
import "@grapecity/wijmo.cultures/wijmo.culture.ja";
//(1)Data型配列のサンプルデータを作成する関数
const createData: () => Data[] = () => {
return [
{
ym: "2018/09",
time: 160,
count: 200,
},
{
ym: "2018/10",
time: 200,
count: 300,
},
{
ym: "2018/11",
time: 120,
count: 150,
},
{
ym: "2018/12",
time: 240,
count: 200,
},
];
};
const App: React.FC = () => {
return <Productivity data={createData()} />;
};
export default App;
(1)ではData型配列のサンプルデータを作成する関数を定義しています。ここでは、() => Data[]という型アノテーションをつけています。引数はなしで、戻り値がData型の配列であるという宣言になります。
IPCを使ってJSONファイルを読み込んで描画する
ElectronではIPCを使ってメインプロセスに処理を任せることで、Webブラウザだけではできないことを実現します。任意のファイルを読み書きしたり、Node.jsの豊富なnpmパッケージを利用したり、さまざまなネイティブモジュールを使ったりすることも可能です。
今度は、Elecronが提供するOSネイティブの、ファイルオープンダイアログを使ってJSONファイルを読み込み、Productivityコンポーネントで描画してみましょう。
global.d.tsを書き換える
// src/@types/global.d.ts
interface Window {
//(1)対象はWindowというインターフェース
ipc: {
openFile: () => Promise<{ filename: string; content: string } | null>; //(2)openFile関数を定義
};
}
前編で軽く解説しましたが、改めて解説します。d.tsというのは、TypeScriptで型宣言のみを行うファイルです。
(1)で通常、Webブラウザ上でプログラミングしているときに使うwindowグローバルオブジェクトにipcというオブジェクトを追加しています。
(2)でopenFileという関数を定義します。関数の引数はなしでPromiseを返すものです。前編でも使ったPromiseは非同期処理を行うための便利な仕組みです。
Promise<{ filename: string; content: string } | null>;の型アノテーションは、非同期処理が完了した際に{ filename: string; content: string }というオブジェクトまたはnullを引き渡してくれる型を意味します。
window.ipc.openFile().then((result) => {
if (result) {
// nullではないためオブジェクト
console.log(result.filename);
console.log(result.content);
} else {
console.log("result is NULL!");
}
});
このように使います。
preload.jsを書き換える
次に、public/preload.jsを書き換えます。
// public/preload.js
const { contextBridge, ipcRenderer, remote } = require("electron");
//(1)contextBridgeを使って、メインプロセスの機能をレンダリングプロセスに提供する
contextBridge.exposeInMainWorld("ipc", {
openFile: () => {
return ipcRenderer.invoke("openFile");
},
});
前編とは異なり、先ほど型定義をした戻り値Promise<{ filename: string; content: string } | null>があるため、ipcRenderer.invoke("openFile")の戻り値をそのままreturnしています。
electron.jsを書き換える
public/electron.jsの末尾にopenFileの処理を追加します。
//(1)invokeできるopenFileを定義する
ipcMain.handle("openFile", (event) => {
//(2)処理の中身が非同期なのでasync関数を定義
const read = async () => {
//(3)dialog.showOpenDialogでファイルオープンダイアログを開く
const { canceled, filePaths } = await dialog.showOpenDialog(null, {
properties: ["openFile"],
title: "JSONファイルを開く",
defaultPath: ".",
filters: [{ name: "JSON file", extensions: ["json"] }],
});
//(4)canceledなら、nullを返す
if (canceled) {
return null;
}
//(5)ダイアログで指定されたファイルをUTF-8とみなして読み込む
const content = await fs.promises.readFile(filePaths[0], {
encoding: "utf-8",
});
//(6)filenameとcontentのオブジェクトを返す
return { filename: filePaths[0], content };
};
//(7)定義されたasync関数を実行する
return read();
});
(1)openFileという名前でinvokeできる定義をしています。
(2)は前編でも登場したasync関数で、ダイアログの処理とファイル読み込みという、2つの非同期処理を束ねるのに便利なため、関数を宣言しています。async関数の戻り値はPromiseになります。
(3)ではdialog.showOpenDialog関数で、OSネイティブのファイルオープンダイアログを開いています。
awaitは前編の記事でも説明した通り、async関数の中でのみ使えるキーワードで、awaitの後ろに指定したPromiseの完了を待つものです。
dialog.showOpenDialog関数の戻り値はPromiseであり、普通は処理が非同期で行われますが、awaitにより、処理が完了するまで待つという、同期処理と同様の振る舞いになります。Promiseのthenメソッドを並べるメソッドチェーンをしなくて済むこと、普通のプログラミングと同じように記述できてわかりやすいなどのメリットがあります。
さて、関数に設定しているオプションの説明をします。
properties配列にmultifileを追加すれば複数のファイルを開くこともできます。
defaultPathは、ダイアログの初期のパスです。指定しなければOSが自動で設定し、ホームディレクトリか、前回開いたディレクトリなどになるはずです。今回はカレントディレクトリである.を指定しています。
filtersは、今回はJSONファイルなので、拡張子がjsonのものだけ開くために設定しています。
(4)では、ダイアログの結果を見てcanceledの場合nullを返しています。
(5)ではfilePaths[0]に、ダイアログで指定されたファイル名が、フルパスで入っているため、Node.jsのfs.promises.readFileでファイル読み込みをします。第2引数に{encoding: "utf-8"}を指定しているのはreadFileが返してくるデータをUTF-8文字列に固定するためです。もし別の文字コードが入る可能性があればencodingを指定せず文字コード判定をする必要があるでしょう。
(6)では、filenameと読み込んだcontentのオブジェクトを返しています。
(7)では、(2)で定義したasync関数を呼び出しています。async関数の呼び出しなので、戻り値はPromiseです。
ここまでで、global.d.tsの型定義と、preadload.jsで、メインプロセスへのinvokeと、electron.jsでメインプロセス上で実行されるファイルオープンダイアログおよびファイル読み込み処理の実装を行いました。
これでやっとレンダラープロセスから、window.ipc.openFileを呼び出すことができるようになりました。
App.tsxを書き換える
先ほど、createData関数で固定のサンプルデータを生成していたApp.tsxを、openFileによって読み込んだJSONファイルのデータを描画するように書き換えます。
// src/App.tsx
import React, { useState, useCallback } from "react";
import Productivity from "./Productivity";
import { Data } from "./types";
import "@grapecity/wijmo.styles/wijmo.css";
import "@grapecity/wijmo.cultures/wijmo.culture.ja";
const App: React.FC = () => {
//(1)filename, dataをステート定義
const [filename, setFilename] = useState("not opend");
const [data, setData] = useState<Data[]>([]);
//(2)ダイアログを開く、ボタンのコールバックを定義
const handleDialog = useCallback(() => {
//(2-1)window.ipc.openFile関数を呼び出して Promise の処理
window.ipc.openFile().then((res) => {
//(2-2)戻り値がnullでなければ
if (res) {
// (2-3)setFilenameとsetDataでステートを更新する
setFilename(res.filename);
setData(JSON.parse(res.content));
}
});
}, []);
return (
<>
<div>
<span>{filename}</span>
<button onClick={handleDialog}>JSON File Open</button>
</div>
{data.length > 0 && <Productivity data={data} />}
</>
);
};
export default App;
(1)ではReactHooksのuseState関数でステートを定義しています。filenameは文字列型でファイル名、dataはData配列型です。useStateの引数がステートの初期値です。
(2)ではダイアログを開いてファイルを読み込み、ステート更新を行うためのコールバックを定義しています。
(2-1)でwindow.ipc.openFile関数を呼び出してPromiseの処理のためにthenメソッドを記述しています。
(2-2)で戻り値がnullではないことを確認しています。
(2-3)で戻り値からfilenameを取り出してsetFilenameでファイル名のステートを更新し、contentを取り出してJSON.parseでJSONをデータ化してからsetDataで、データのステートを更新しています。
これらのステート更新が行われると、再描画が行われて<Productivity data={data}>が更新されます。
JSONファイルを用意して実際に動かす
では、JOSNファイルを使って実際に動かしてみます。
まずは2018.jsonというファイルに2018年のデータを入れます。
[
{ "ym": "2018/09", "time": 160, "count": 200 },
{ "ym": "2018/10", "time": 200, "count": 300 },
{ "ym": "2018/11", "time": 120, "count": 150 },
{ "ym": "2018/12", "time": 240, "count": 200 }
]
次に、2020.jsonというファイルに2020年のデータを入れましょう。
[
{ "ym": "2020/09", "time": 140, "count": 300 },
{ "ym": "2020/10", "time": 180, "count": 400 },
{ "ym": "2020/11", "time": 100, "count": 250 },
{ "ym": "2020/12", "time": 200, "count": 300 }
]
この状態で実際に動かしてみましょう。electron.jsなどのメインプロセスは書き換えても自動では反映されないため、一度Electronアプリを再起動する必要があります。
Error occurred in handler for 'openFile': No handler registered for 'openFile'
再起動を忘れると、このようにopenFileのハンドラーが登録されていないエラーが出るはずです。
再起動して立ち上げ直すと以下の画面になります。
JSONファイルを選ぶとそれに合わせて更新されます。
パッケージングする
開発モードで動かしているだけではわからないこともありますし、配布するためには配布可能な状態にパッケージングする必要があります。
electron-builderというツールを使えば、とても簡単に配布可能なファイルが生成できます。
package.jsonに設定を追加する
electron-builderでElectronアプリをパッケージングする際は、package.jsonにいくつもの情報を入れる必要がありますが、作者名や説明文などは省略しようと思えば可能です。どうしても設定しなければいけないものは"homepage": "."だけです。パッケージング時に必要になる情報なため、これがないとelectron-builderの動作が完了してもパッケージングされたアプリが正しく動作しません。
"homepage": ".",
"main": "public/electron.js",
"scripts": {
"dev": "electron .",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"pack-app": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder"
},
homepageと、scriptsのpack-appおよびdistを追加しています。
pack-appおよびdistでは、electron-builderを実行する前に、npm run buildを実行してReactのビルド(バンドル)を行っています。いわゆる普通のWebアプリを作成した上で、Electron用にパッケージングを行うのです。
このときbuild/ディレクトリには、Reactのビルド時に生成したファイルが出力され、dist/ディレクトリには、electron-builderが生成したファイルが出力されます。もし、パッケージングで困ったときは、build/やdist/を消してからやり直すとうまくいくこともあります。
その後、npm run pack-appを実行すると、dist/<OS>/<アプリ名>でアプリがパッケージングされます。macOSで実行した場合は、dist/mac/wijmo-example1.appになり、Windowsならdist/win-unpacked/wijmo-example1.exeとなります。
npm run pack-app
# Macの場合 # open コマンドで実行可能 open dist/mac/wijmo-example1.app
# Windowsの場合 .\dist\win-unpacked\wijmo-example1.exe
パッケージングが正しくできているか、動作確認をしておくといいでしょう。以下はWindowsでnpm run pack-appしたときのログです。
> wijmo-example1@0.1.0 build ...\wijmo-example1 > react-scripts build Creating an optimized production build... Compiled successfully. File sizes after gzip: 222.14 KB build\static\js\2.4c3d1fdd.chunk.js 12.18 KB build\static\css\2.42521bd4.chunk.css 1.4 KB build\static\js\3.433c6905.chunk.js 1.18 KB build\static\js\nuntime-main.6198875f.js 834 B build\static\js\main.ca9ed471.chunk.js 278 B build\static\css\main.6dea0f05.chunk.css The project was built assuming it is hosted at ./. You can control this with the homepage field in your package.json. The build folder is ready to be deployed. Find out more about deployment here: https://cra.link/deployment • electron-builder version=22.9.1 os=10.0.19041 • loaded parent configuration preset=react-cra • description is missed in the package.json appPackageFile=...\wijmo-example1\package.json • author is missed in the package.json appPackageFile=...\wijmo-example1\package.json • writing effective config file=dist\builder-effective-config.yaml • packaging platform=win32 arch=x64 electron=11.1.0 appOutDir=dist\win-unpacked • downloading url=https://github.com/electron/electron/releases/download/v11.1.0/electron-v11.1.0-win32-x64.zip size=78 MB parts=8 • downloaded url=https://github.com/electron/electron/releases/download/v11.1.0/electron-v11.1.0-win32-x64.zip duration=40.874s • default Electron icon is used reason=application icon is not set
pack-appではなくdistの場合は、配布用のパッケージ(インストーラやZipファイル)が作成されます。
npm run dist
macOSならdist/wijmo-exampe1-0.1.0-mac.zipやdist/wijmo-example1-0.1.0.dmg、Windowsならdist\wijmo-example1 Setup 0.1.0.exeなどが作成されます。
実際に配布する際には
Electronはクロスプラットフォームなフレームワークであり、electron-builderもがんばればmacOS、Windows、Linuxアプリを1つの環境でパッケージング可能です。しかし、そのためにはいろいろとインストールしなければいけないものや、指定しなければいけないコマンドラインオプションがあります。
実際に配布するときにはpackage.jsonにauthorやdescriptionなどの情報、開発者署名、アイコンなどを指定する必要があるでしょう。
紙面の限りもあり、それらすべてを説明することはできませんが、Electron公式とelectron-builder公式を一通り読めば、必要な情報は得られるはずです。
まとめ
冒頭でお伝えした通り、年々JavaScript開発は大規模・高度化しているため、静的型が使えるTypeScriptが人気です。Electron+Wijmoの開発においてもTypeScriptを活用すれば、より安全に開発ができるでしょう。
IPCを使えば、レンダラープロセス(Webブラウザ)ではできないことをメインプロセスに任せることができます。また、electron-builderを使えば、配布可能なパッケージを作成可能です。ぜひチャレンジしてみてください。

