概要
タイトル通りの記事です。一応解説記事ですが、自身の脳内で理解している事柄を整理するために書いた、という面が強いです。制作物は以下のリンクからどうぞ。
艦これスケジューラー(Web版)
GitHub
制作理由と使用技術について
○制作理由
元々、「艦隊これくしょん」用の遠征スケジュール管理ツールとして、「艦これスケジューラー」というアプリを制作、公開していました。このアプリはJava製ですので、「動作環境を選ばない」ことをウリの一つにしていました。
ただ、ご存知のように近年ではスマートフォンなどの「PC以外のデジタル端末」が増えています。すると、パソコン向けのアプリケーションがそのままでは動かず、いちいち移植版を作る必要がある……といった問題が出てきました。
そのため、「複数スマホOSで作成できる」「PC版もまとめて作成できる」といったクロスプラットフォーム開発用フレームワークを使おうかと当初は考えていました。だが、気づいてしまったのです。
ブラウザ上で動くWebアプリなら自動的にマルチプラットフォームになるし、利用者に常に最新版を使用させることができるので捗るということに。
○使用技術
……今までWebアプリケーションを組んだことはなかったので、技術選定には手間が掛かりました。
動作させるためのサイト(デプロイ先)にはGitHub.ioを採用しましたが(タダなので)、するとその上で動く言語に選択肢が絞られます。「実はRailsやGoなどで書ける」ということには当時気づきませんでしたので、JavaScriptか、それにトランスコンパイルできる言語にしようと考えました。
ここで「トランスコンパイル」とは、「言語Aのソースコードを言語Bのソースコードに変換する」作業を指します。Webブラウザは基本的にJavaScript(とHTMLとCSS)を解釈しますので、別の言語をJavaScriptに変換できればそれでいいわけです。そこで白羽の矢が当たったのがTypeScriptでした。なぜなら当時、「TypeScriptならVisualStudio上で書ける!つまりVisual Studioでデバッグ&実行できる!これだ!」と考えていたからです。
……ただ結局、「Visual Studio Codeでも実行できるし、ブラウザ上でデバッグすればいいじゃん」といった結論になりました。図示すると、こういった開発環境になります。
・npm
Node.jsに付属するパッケージ管理ツールのことです。「Node.js用」というお題目は付いていますが、感覚としてはNugetやapt-getのようなノリでライブラリを簡単にインストールできます。また、ts-loaderやWebpackなどによるソース変換やソースを監視して自動ビルドなど、単なる管理ツールに留まらない働きをしてくれます。
・Visual Studio Code
Microsoft社製のテキストエディタです。何気にキーワード塗り分けとかマウスホバーで説明ポップアップなどの機能が充実していましたのでかなり便利でした。マルチプラットフォームなので、Macなどでも同じ感覚でWeb開発ができるのはいいですね。
・Webpack
複数のJavaScriptやメディアファイルなどを、1つのJavaScriptに結合してくれる便利なツールです。これを使用することで、少ないファイルを読み込むだけでいいからWebページの表示が高速になるといった利点があります。また、結合する際に「ts-loader」を併用すれば、TypeScriptを自動でトランスコンパイルしつつ結合といった器用なこともできて非常に捗ります。
参考:サルでもわかるwebpack 4.5入門 - 古い情報に騙されずに学ぼう - ICS MEDIA
・D3.js
JavaScriptで画面上にグラフ・図形を書いたり、その他のDOM要素(≒タグで囲まれた単位とその属性)を操作したりできる便利なライブラリです。HTML5の<canvas>
にも対応している他、データをbindすることで一括描画したり、配列を簡単に生成できたりなどの器用なことができます。
参考:D3.jsの概要と使い所について - Qiita
・mapファイル
これは「ソースマップ」と呼ばれるものです。Webpackなどで複数ソースやメディアファイルなどを括ってしまうと、元のソースコードがどういったものかをブラウザから判別することができません。そこでmapファイルを添付することにより、読み取ったWebブラウザが元のソースコード(今回はTypeScript)やメディアファイルを復元してデバッガに表示できるようにします。
「別に元のソースコード出せるようにすることなくない?」と思われるかもしれませんが、これをするかしないかでデバッグのしやすさが段違いです。ブラウザのデバッガだと「ブレークポイント」も「変数の中身表示」もバッチリ可能ですので、この機会に慣れておきましょう。
制作の道のり
○npmで必要なライブラリをインストールする
Node.jsをインストールした後、アプリ開発に使用するフォルダをカレントフォルダとして、次のようにコマンドを叩きます。
REM npmを初期化
REM (参考→https://techacademy.jp/magazine/16151)
npm init
REM 必要なライブラリをインストールしまくる
REM リモートリポジトリからcloneしてきた時のように、あらかじめpackage.jsonが
REM 整っている場合は「npm install」だけで全て揃うが、そうではない場合は
REM ライブラリ名を指定していく必要がある
npm install -D webpack webpack-cli typescript typings ts-loader url-loader
npm install d3 @types/d3
ここで注意したいのは、npm install
する際、「-D」オプションを付けると「devDependencies
」、付けないと「dependencies
」扱いでpackage.json
に登録されるということです。前者は「開発には使用するがリリースするアプリケーションには入らない」、後者は「アプリケーションに入る」ですので、しっかり区別しておきましょう。
※変換ツールであるWebpackやTypeScriptをアプリケーションに含める意味はないのは当然。typingsはTypeScript用の型定義管理ツール、XXX-loaderは「その形式のファイルを読み込むために使うWebpack用のプラグイン」といった感じ。
○開発時の画面配置
上記のように、今回はVisual Studio Codeで書きました。Visual Studio Codeで開発に使用するフォルダを開きながらプログラミングしていくことになります。と言うかむしろ、先に開発フォルダをVSCodeで開いてからコマンドを叩くことの方が一般的でしょうね。
○開発フォルダにあるファイルの説明
ファイル名 | 説明 |
---|---|
package.json | npm用の設定ファイル。導入したライブラリやアプリケーション情報などが書かれている |
package-lock.json | npmが自動生成するものだが、特に弄る必要はない |
webpack.config.js | Webpack用の設定ファイル。パッケージング時の設定が書かれている |
tsconfig.json | TypeScript用の設定ファイル。どのJavaScriptのバージョン向けにコンパイルするかなどが書かれている |
node_module | 導入したライブラリを保存するためのフォルダ。コミット不要 |
この中でGit管理しなくていいのはnode_moduleだけです。package-lock.jsonも元は自動生成ですが、「元の開発者が使用していたライブラリ」をpackage.jsonより正確に復元できる……といったメリットがあるのでGitで残しておくべきです。
webpack.config.jsとtsconfig.jsonはちまちま手で弄る必要があるのが面倒ですが、「コマンド一発で全部OK」にするためには必須なので頑張って使い方を覚えましょう。私は上記アプリで、こういった設定にしました。
// path.某メソッドを使うために使用。ファイルパスの各種処理を簡単にしてくれるそうな。
// 参考→http://koukitips.net/post1825/
const path = require('path');
module.exports = {
// このソースコードから起動する、ということを明示する
entry: [
'./src/app.ts'
],
// 最終的にどこに出力するかを明示する。「__dirname」はカレントディレクトリを
// 指すので、結局「変換してpublicフォルダのbundle.jsに書き出す」という意味
output: {
path: path.join(__dirname, 'public'),
filename: 'bundle.js'
},
// ソースマップ(後述)を利用するために記述
devtool: 'source-map',
// ソースコードとして処理する拡張子を指定。Webpackは新しいバージョンだと「""」の空白文字を
// この一覧に含められないようになったので、古い記事を読む際は注意!
resolve: {
extensions: [".ts", ".tsx", ".js"]
},
module: {
// ある拡張子について、どういった処理を行うかについてのオプション
rules: [
{
// 「.tsか.tsxについては、ts-loaderで読み出す。ただしnode_modulesフォルダ以下は除く」
test: /\.tsx?$/,
loader: 'ts-loader',
exclude: /node_modules/
},
{
// 「.csvについては、url-loaderで読み出す。ファイルパスは自然な形式で……」
// 参考→https://qiita.com/tomi_shinwatec/items/ef66a60950939618c449
test: /\.csv$/,
loader: 'url-loader',
options: {
name: '[path][name].[ext]'
}
}
]
},
performance: { hints: false }
};
{
"compilerOptions": {
"module": "commonjs", // モジュール管理をCommonJSで行う
"target": "es5", // 出力するJavaScriptをES5基準にする
"noImplicitAny": false, // ソースにおける暗黙のany型は全てエラーとする
"outDir": "public", // publicフォルダに出力
"rootDir": ".", // コンパイルの起点とするフォルダ
"sourceMap": true, // ソースマップを出力するか
"lib": [ // 使用するライブラリ
"dom",
"es5",
"scripthost",
"es2015.promise",
"es2015"
]
},
"exclude": [
"node_modules" // node_modulesフォルダは(既にJavaScriptなので)コンパイルしない
],
"typeRoots" : [
"src/typings", // TypeScriptは型情報を使う言語なので、ここにフォルダパスを指定して
"node_modules/@types/" // おくと、そこの型定義ファイルを読み込んでくれる。プログラミングに便利
]
}
※モジュール管理……JavaScriptのソースコードを、モジュール毎に分割して管理できるようにするシステムのこと。CommonJSやAMDやRequireJSなどがある。詳細はこの記事が詳しい。
JavaScriptのモジュール管理(CommonJSとかAMDとかBrowserifyとかwebpack)
※ES5……「ECMAScript 5」の略。JavaScriptの規格は「ECMAScript」で規定されているが、そのVer.5ということ。2018/05/06現在で最新なのはVer.8(ECMAScript 2017)だが、新機能ほど動かないブラウザが多くなったりするので、結局「古いバージョンの規格にトランスコンパイルする」ことが必要になったりする。正確な実装対応表は以下から。
ECMAScript 5 compatibility table
ECMAScript 6 compatibility table
ECMAScript 2016+ compatibility table
○実際の開発ステップ
ざっくり書くとこんな感じでした。以下、重要ポイントについて解説していきます。
順番 | 説明 | 備考 |
---|---|---|
1 | データモデルを作成・表示 | 「pタグのinner_textにstring型の文字列を加算」というモックアップで確認した |
2 | グラフィックAPIを確認 | D3.jsで遠征タスクがちゃんと描画できるかを確認した |
3 | HTMLも編集し、概形だけは作成する | 説明しやすいし、自分自身のモチベ向上に繋がるため |
4 | 遠征タスクのドラッグ&ドロップを実装 | 最難関箇所。後で後述します |
5 | 2つのセレクトボックスを動的に変化させられるようにした | コツを覚えるまでは面倒でした…… |
6 | クリックイベントを取得できるようにした | ドラッグと区別したいので注意が必要 |
7 | ソースコードを分割 | 1ファイルだけが肥大化するのは健全ではないため |
8 | ファイルを読み込ませる処理を実装 | データベースの読み込み部分がずっとモックアップだったのでツッコミを受けたため |
9 | ローカルストレージにデータを読み書きする処理を実装 | 非常に簡単でした |
10 | HTMLとCSS側の調整 | headerおよびstickyに関する処理 |
11 | UAを判定・画面に反映 | PC以外からサイトを見ると、ボタンなどの大きさが大きくなるようにした |
・データモデル
TypeScriptはclassが使えますので、普段C#やJavaなどで行っているようなオブジェクト指向プログラミングができます。今回はModel・View・Controllerと厳密に分けたコーディングはしませんでしたが、次以降のアプリ開発では行っていこうと思います。
ちなみにES6でもclassが使えるそうですが、
(初心者向け) JavaScript のクラス (ES6 対応) - Qiita
TypeScriptは型指定できるとかEnumがあるとかクラスメソッドのオーバーロードがあるとかの細か差があるようです。
TypeScriptを使った方がいいケースとは? | POSTD
TypeScript における ES6 との兼ね合いで避けているパーツ __ハブろぐ
・D3.jsによるグラフィック操作
ググれば例文が出てくる……と言いたかったのですが、D3.jsがv3→v4→v5とバージョンアップする度にAPI体系を変えやがるせいで、「例文がそのまま使えないことがある」という初心者にとって地獄のような状況になっていましたorz
とはいえ基本的なノリは同じなのですが。D3.jsのv5についてざっくりまとめるとこんな感じです。これでも短くまとめたんだ許して
import * as d3 from 'd3';
// CSSセレクタで、DOM要素を選択することができる。selectAllは全要素選択
d3.select("p")
d3.select("g > rect.hoge")
d3.selectAll("#sample")
// 選択したDOM要素のプロパティを参照・変更する
val = d3.select("a").property("href");
d3.select("#piyoFlg").property("checked", piyoFlg);
// 選択したDOM要素の中の文字列を参照する/文字列を追加する
// (\nで改行はできないので、後述する.html()内で"<br>"入りのHTML文字列を付加すること)
val = d3.select("td").text();
d3.select("strong").text("Strong Text");
// 選択したDOM要素の中のHTMLを参照する/HTMLを追加する
val = d3.select("td").html();
d3.select("strong").html("<a>Strong Text</a>");
// 選択したDOM要素にCSS効果を適用する
d3.selectAll("h2").style("font-size", "40px");
// 選択したDOM要素にイベントを貼り付ける
d3.select("#changeTask").on("click", this.changeTask.bind(this));
// 別にfilterしても良いのだろう?
d3.selectAll("g > rect").filter((d, i) => (i === index)).attr("x", data.rx).attr("y", data.ry);
// JavaScriptの仕様により、ラムダ式を渡すとthisの意味が変化してしまう。
// それを嫌う場合は.bind(hoge)とすることにより、ラムダ式の先のメソッドにおける
// thisの中身をhogeに設定できる(Function.prototype.bind())
d3.select("p.foobar").text("font-size", "40px");
// 選択したDOM要素を削除する(selectAllだともちろん全削除)
d3.select("option").remove();
// SVGとして描画する際の土台を作る
canvas = d3.select("div.canvas").append("svg")
.attr("width", width)
.attr("height", height);
// 要素追加はappendで出来る。SVG要素以外でも<p>や<a>など何でも追加できる。
// メソッドチェーンは「それ以前で選択・追加された要素に操作を行う」ので、
// 大きさなどの属性を後付けすることになる。次の例は、
// 「50x50の矩形を座標(100, 200)に配置し、枠線を不透明度100%の黒・不透明度80%のskyblueで一様に塗り潰す」
canvas.append("rect").attr("x", 100).attr("y", 200).attr("width", 50).attr("height", 50)
.attr("stroke", "black").style("opacity", 0.8).attr("fill","skyblue");
// 「テキストを位置(200, 300)に配置し、フォントサイズを36pxにして"Hello"と表示」
canvas.append("text").attr("x", 200).attr("y", 300).attr("font-size", "36px").text("Hello");
// 直線を(a, b)から(c, d)に引く。その際太さは5、線の色は白とする
canvas.append("line").attr("x1", a).attr("y1", b).attr("x2", c).attr("y2", d)
.attr("stroke-width", 5).attr("stroke", "white");
// data(list).enter()とすると、以降のメソッドチェーンは配列listを対象にできる。
// すると、データ(list)からまとめてオブジェクトを作成できるので便利。
// 次の例は、canvasに対して直線をいっぺんに引く例
canvas.data(nameList).enter().append("line")
.attr("x1", function(i){return Utility.func1(i)})
.attr("y1", function(i){return Utility.func2(i, flg)})
.attr("x2", i => Utility.func3(i))
.attr("y2", i => Utility.func4(i,flg))
.attr("stroke-width", 1)
.attr("stroke", "red");
// attrの第二引数に関数オブジェクトを渡す際、第一引数が「dataで設定した配列の各中身」、
// 第二引数が「その中身のインデックス」を表す
canvas.data(dataList).enter().append("rect").attr("x1", (中身, 中身のインデックス) => {~});
// オブジェクトに対するドラッグ操作を扱う際の例。ドラッグ開始・途中・終了時の操作を記述する。
// ここで言うところの「d」とは……何だったっけ?
canvas.select("rect").call(d3.drag()
.on("start", function(d){this.dragstartedFunc(d);})
.on("drag", d => this.draggedFunc(d))
.on("end", this.dragendedFunc)
);
// 対象オブジェクトにおけるドラッグイベントの無効化
canvas.select("rect").on(".drag", null);
// ドラッグ時の対象イベントで、現在のマウス座標やマウス座標の変化量などを読み取ることができる
draggedFunc(){
x = d3.event.x;
y = d3.event.y;
dx = d3.event.dx;
dy = d3.event.dy;
}
// それぞれ、[0, 1, ..., 9]と[3, 4, ..., 19]を表す
d3.range(10);
d3.range(3, 20);
・2つのセレクトボックスを動的に変化させる
例えば、「都道府県のセレクトボックスを選択すると対応した市区の一覧が別のセレクトボックスに出る」感じの奴です。
まず、文字列配列をセレクトボックスの中身にします。
// id="areaName"である要素に、配列areaNameListの中身をセレクトボックスの要素として設定
// その際、「<option>におけるvalue属性の値」と「<option>で囲った表示テキスト」は同じとしたd3.select("#areaName").selectAll("option").data(areaNameList).enter()
.append("option").attr("value", d => d).text(d => d);
次に、セレクトボックスの変化時に、別の処理を発火させるようにします。
// セレクトボックスの変化時にonChangeMethodを発火させる。
// この場では「1つ目のセレクトボックスで選択した中身」を渡せないのが残念
d3.select("#areaName").on("change", this.onChangeMethod)
.selectAll("option").data(areaNameList).enter()
.append("option").attr("value", d => d).text(d => d);
発火した先では、選択した中身をおもむろに取得しながら、それに応じて生成する中身を変化させます。
これでミッションコンプリート!
onChangeMethod(){
// 1つ目のセレクトボックスで選択した中身を取得する
var areaName = d3.select("#areaName").property('value');
// そこから2つ目のセレクトボックスで使う文字列配列を生成
var nameList = DataStore.func(areaName);
// 何度も生成するものなので、まず2つ目のセレクトボックスの中身を全部削除
d3.select("#expName")
.selectAll("option").remove();
// その後に要素を追加していく
d3.select("#expName").selectAll("option")
.data(nameList).enter()
.append("option").attr("value", d => d).text(d => d);
}
・クリックイベントを取得
まず、上記のようにドラッグイベントを設定しているオブジェクトでは、クリックもドラッグ扱いになることに注意しましょう。
そのことから、D3.js v3では「ドラッグ時はd3.event.preventDefault()
が内部で叩かれるので、d3.event.defaultPrevented
がfalse
ならクリック、そうでないとドラッグ」といったレトリックが使えました。
drag.on - ドラッグイベントの内容を設定する
ただし、この機能はD3.js v5の段階では消滅しているので、同じ手は使えません。さてどうしたものか?
そこで思いついたのは、「ドラッグ開始時の座標と終了時の座標を使う」ことです。
(クラスのフィールド変数に保持する際は、上記のようにbind()
でthisの意味を修正しておくこと)
dragstartedFunc(){
this.mx = d3.event.x;
this.my = d3.event.y;
}
dragendedFunc(){
if(this.mx == d3.event.x && this.my == d3.event.y){
// クリック時の記述
}else{
// ドラッグ時の記述
}
}
しかし、これだとPCならまだしも、スマホのタップがドラッグ扱いされるといった問題がありました。D3.jsではクリックもタップも同様に扱いますので、タップが「少しだけ動くドラッグ」扱いになるのです。
そこで、「移動距離を測って、ある距離以下ならクリックとみなす」という力技で解決しました。え、drag.clickDistance
? 誰かあれの使い方教えてください!
dragstartedFunc(){
this.mx = d3.event.x;
this.my = d3.event.y;
}
dragendedFunc(){
const dx = this.mx - d3.event.x;
const dy = this.my - d3.event.y;
if(dx * dx + dy * dy <= 100){ //この閾値は真面目に測定して決めたわけではないので注意
// クリック時の記述
}else{
// ドラッグ時の記述
}
}
・ソースコードの分割
いろいろ試行錯誤した結果、次のような形に落ち着きました(CommonJSの話)。CommonJS以外のモジュール管理システムでもいいので、TypeScript+Webpackでもっと綺麗に書く方法を募集中です。
(ソースコード分割の事例では、なぜexportされるのが生functionの話ばかりでclassの話が全然ないのか小一時間)
export class AAA {
~
}
// インポート部分
import ccc = require("./models/Hoge"); //ファイルパスを拡張子抜きで指定
import DDD = ccc.AAA; //要するにcccは中間変数(省略不可)。以降は「DDD」という名前でAAAクラスを使用できる
// 使用例
let val: DDD = new DDD();
・ファイルを読み込ませる処理
まずデータをちゃんとWebpackさせるのに苦労しました(上記のwebpack.config.js
になるまでどれだけググったことか)。
で、それを読み込む際は(CSVデータなので)``d3.csvを使用したのですが、困ったことにこいつは**D3.js v5で仕様が変わった**ため、
Promise`と呼ばれるものを返します。つまり非同期に処理させるわけなのですが、「確定」させないとそのデータを使った処理ができないので、ああでもないこうでもないと苦労した結果、次のようになりました。
// ここでtsファイルじゃないのにimportできるのは、後述する.d.tsを作成した上で、
// tsconfig.jsonにその置き場所を指定しているから。なおこれらのファイルパス指定は、
// 「そのソースコードの置き場所」から相対的に決めるので注意!
import csv = require('../files/ExpList.csv');
// TypeScriptでもasync/awaitが使える感動……
// ただし、tsconfig.jsonでlibに"es2015.promise"や"es2015"を追加する必要がある。
// 「最低限どれを追加すればいいのか」は、大変クソなことにググった先のWebページ毎に
// 違う有様なので、もう「動けばいいや」精神に落ち着いた
async initialize(){
// 1. ファイルcsvを読み込み、dataと言う名のd3.DSVRowStringの配列に変換する
this.list = await d3.csv(csv).then((data) => {
// row(=d3.DSVRowString)は、row["CSVの列の名前"]と入力すると
// その列における値を返してくれる。要するに連想配列
return data.map(row => {
return this.func(data);
})
});
}
// 呼び出し先までasync/awaitを書き続けるのはasync/awaitのお約束
window.onload = async function(){
// データベースを初期化
await this.initialize();
}
・ローカルストレージにデータを読み書き
// windows.sessionStorageだとセッションストレージになる。両者の差はググれ
var storage = window.localStorage;
// データの読み取り(文字列が返ってくる。読み取れない際はnullを返す。データはドメイン単位で保持される)
const val = storage.getItem("キー");
// データの書き込み(KeyもValueも文字列を与える)
storage.setItem("キー", val);
// データの削除
storage.removeItem("キー");
// データの要素数
const count = storage.length;
// データの指定したインデックスにあるキーの名前(引数に渡すインデックスは0スタート)
const key = storage.key(5);
// データの全削除
storage.clear();
・HTMLとCSS側の調整
闇が深すぎて未だに理解できていない面もあるので略
・UAを判定・画面に反映
UA文字列はwindow.navigator.userAgent
で取れます。PC版Chromeでこの値をモバイル用に変更するには、開発者ツールでモバイル解像度に設定するボタンを押すのが一番簡単でしょう。
ただ、UA文字列からモバイル端末か否かを判定するのはそこそこ面倒なので、私はnpmからライブラリをインストールして使うことにしました。
UAParser.js
モバイルと判定したら何をするかですが、よくあるのがCSS差し替えでしょうか。D3.jsを使えばゴリゴリHTMLを書き換えることもできますが、流石にそれは面倒なので拙作の場合、「ボタンやセレクトボックスや文字列の大きさを変更する」ことで乗り切りました。
まとめ
今回は初めてづくしで考えることが多く、楽しいながらかなり大変でした。その過程で、雑なスタブコードを指摘されるとか本質的にIE11未対応なことを利用者から指摘されるとかの愉快な出来事もありましたが、Webフロントエンドに関する経験値はもりもり溜まったのではないかと感じています。
何より嬉しいのは、「これ私が造ったんですよ」って他人に説明しやすいことですね!Webたのちー!!