はじめに
前回に引き続き、グレープシティが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} />
のように利用します。
このコンポーネントは、color
label
size
の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
コンポーネントがどう定義されているか表示されていますし、エラーメッセージとしてcolor
label
size
が必須パラメータであることを教えてくれます。
では、パラメータを入力していきましょう。color
のためにc
を打ち込んだ時点で候補が出てきます。
function App() { return <Label c }
color
かchildren?
の2択に絞り込めます。children?
のように末尾に?
がついているものは省略可能です。
ここでもcolor
はstring
型であると教えてくれるので、color
に該当しそうな文字列を指定すれば大丈夫です。
同様の手順を踏んで、color
label
size
を指定すれば、正しく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を使えば、配布可能なパッケージを作成可能です。ぜひチャレンジしてみてください。