エフアンダーバー

個人開発の記録

クトゥルフTRPG Webアプリβ版の開発についてだらだらと

先日公開したクトゥルフTRPG Webアプリβ版の開発周りの話です。

アプリ紹介については過去記事を参照してください。

www.f-sp.com

アプリ本体はこちら。

coc.f-sp.com

はじめに

アプリ開発の話、というと『こんな技術やあんな技術を組み合わせて素晴らしいシステムを組み上げました!!』とか、 『あれこれこんな難しい課題があったけどこういった工夫で乗り越えました!!』みたいな話を思い浮かべますが、 この記事にはほとんどそんな話はありません。

何故かというと、開発当初私がWeb技術に関して素人同然であり、このアプリは私のWeb技術実験場だったからです。 なので、傾向として『○○という技術を使ってみた』系の話が中心になります。

特に目新しい話題もないので、 ヒトの成長記録とかをみて楽しめる人向けです。

ソースコード

GitHubにて公開中。

github.com

開発の軌跡

開発当初Web技術に明るくなかったこともあり、個人の成長に伴って何度か大規模な変更を行っています。 せっかくなので、その時代ごとに振り返っていこうと思います。

ちなみに、開発前の私の経験はjQueryを弄ってTODO管理アプリを作成したことがある程度です。 ただ、ツクールMVで使っていたため最新のJavaScriptやTypeScriptの言語に関する知識はありました。 また、ブログのデザインで利用したためSCSSの知識もありました。

DOM時代

アプリ紹介記事にも書いたのですが、元々このアプリは身内で使うための小規模なものでした。 それほど複雑なことをする気もなかったので、新しい技術を習得しようとも思わず、素のDOM APIを使ってコードを書き始めました。

jQueryの存在が頭を掠めましたが、その時すでにjQueryの使い方をほぼ覚えていなかったのと、 DOM APIも便利になった的な話をどこかで聞いた記憶があったのでそれでいいか、という感じでした。

この頃のプロジェクトを見てみると、 かろうじてpackage.jsonはあったものの"dependencies"の項目が存在しません。 バンドラなんてものはもちろんなく、グローバルインストールしたtscとnode-sassでどうにかしていました。

久しぶりに見て面白かったのは、<reference path="..." />をまとめたファイルをスクリプトで自動生成していたことです。 C言語とかでひたすら#includeを並べたヘッダファイルを作るようなことをやっていました。 バンドラがないのでモジュールが使えなかったみたいです(多分RequireJSとかも使う気がなかった)。

/// <reference path="DiceImage.ts"/>
/// <reference path="DiceNumberRenderer.ts"/>
/// <reference path="DiceRenderer.ts"/>
/// <reference path="PageManager.ts"/>
/// <reference path="ViewManager.ts"/>

webpack 襲来

そんなDOM時代の終わりごろ、ようやくプロジェクトにwebpackがやってきます。

といっても、おそらく皆が知っているwebpackとは違います。 なんと設定ファイルの行数わずかに十四行、"loader"の文字がひとつも見当たりません。

const path = require("path");

module.exports = {
    entry: "./js/page/root.js",
    output: {
        filename: "./main.js"
    },
    resolve: {
        modules: [
            path.resolve("./js"),
            "node_modules"
        ]
    },
}

本当にただESモジュールが使いたいだけだったんですね。 丁寧に、tscしてからその結果に対してwebpackしていました。

直DOMの限界

そんなこんなでDOM APIを直接繰ってどうにかしていたのですが、ついに限界がやってきます。

読み込んだJSONデータの内容に従って入力項目を動的に生成していたのですが、この生成が壊滅的に遅かったのです。 ページ遷移のボタンを押してから十秒近く画面が固まるのはさすがに許容できません (当時Edgeの実装にパフォーマンスの問題があったためで、それ以外のブラウザでは数秒弱でしたが)。

原因を探った結果、DOM操作が非常に遅いことと仮想DOMの存在を知り、 ここでようやくフレームワークの学習を始めることにしました。

Vue時代

どうせ何か覚えるなら最新のやつがいいな、と探しているとVueというのが今イケてると噂だったのでこれを使うことに。

Vueの使い方の学習はスムーズに進みました。 Vueのドキュメントは何ができるのかについてシンプルに書かれており、日本語訳もあるため機能の把握は非常に楽でした。

しかしVueを導入するとなると、Vue.jsの使い方だけ覚えればいいというわけにはいかなかったのです。

単一ファイルコンポーネントとの格闘

Vueのドキュメントを眺めていて単一ファイルコンポーネントの存在を知り、これはいいと導入を決めます。 コード内に文字列としてHTMLを書くことに抵抗があったのと、Pugを利用したかったためです。

さっそくVSCodeに.vueファイル用の拡張機能であるVeturを入れて書き始めてみるも、 フォーマット時にエディタの設定を無視して2文字幅スペースでインデントすることが判明。 4文字幅タブ派の自分としてはこれは受け入れがたいので、仕様を確認するとVeturがフォーマットにPrettierを利用することがわかります。 この時点でPrettierはタブインデントに対応していたようなのですが、 以前*1調べた際にPrettierはタブが利用できないと記憶していたため、ここで単一ファイルを断念します。

代わりにHTML(Pug)、CSS(SCSS)、JavaScript(TypeScript)をそれぞれ別のファイルとして作成し、 それらを単一ファイルコンポーネントのソースに指定することにしました。 そのままだと.vueファイルのインポート時に型が利用できないため、中継のための型定義ファイルも追加します。

かくしてひとつのコンポーネントに".vue", ".pug", ".scss", ".ts", ".d.ts"の5つのファイルが必要となり、 作成が面倒なので生成用のスクリプトを書きました。 単一ファイルコンポーネントとはなんだったのか……。

+--- header
|    +--- component.vue
|    +--- component.vue.d.ts
|    +--- header.pug
|    +--- header.scss
|    +--- header.ts
+--- ...

ちなみに、Prettierがタブに対応したことを最近知り、 CSSのフォーマットのためにVSCodeに拡張機能を入れてみたのですが、 何故か対象言語の設定がopt-out方式で面倒になったのでやめました。 どうもPrettierとはウマが合わないらしいです。

webpack との死闘

単一ファイルコンポーネントなんかを使い始めると、もうwebpackから逃げることはできません。

仕方なくwebpack中心の構成へと変えていくわけですが、これに悪戦苦闘します。 ソースマップやTypeScriptのパスマッピングなど開発に必須でない機能はローダがうまく動作しないことも多く、 自分でソースを読んで書き換えたりしながら使いました。 またこの業界ではWindowsは地雷らしく、やたらとバグを踏み抜きます。 どうもファイルシステムのパスとそれ以外のパスとの相互変換でミスるらしいです。

www.f-sp.com

www.f-sp.com

途中何度かParcelに乗り換えようと試行したのですが、 こちらも大差ない上にwebpackのようにダーティなハックができないため諦めました。 結局、危ういバランスを保ちながらParcel 2を待っているのが現状です。

Vueによる構成の限界

そんなこんなで歪ながらもなんとかVueで構成していたのですが、これにも限界が訪れます。

理由は単純で、ごちゃごちゃとし過ぎたのです。

Vueは一見シンプルに書けるフレームワークに見えますが、 機能が充実しているだけにそれを享受するための制約もいくらかあります。 この制約を避けようとしたり無理やり合わせようとした結果、コードが複雑化し、 フレームワークに使われているような感覚に陥ったのです。

もちろんこれはVueの期待するモデルを書かなかった私が悪いのですが、 当時はVueの設計が悪いのだと思い込みました。 そこで、より素晴らしいフレームワークを探すことにしたのです。

React時代

代替フレームワークを探していた際、硬派な人はReactを使うと聞き、「これだ!!」と直感します (この時点で自分の硬派とは程遠い優柔不断さには気づいていない)。 結果としてコレでした。

Reactは機能自体はVueより若干簡素な程度でさほど変わりないのですが、 かくあるべきという設計方針が非常にしっかりとしています。 ドキュメントにもそれがよく反映されており、 ひととおり目を通すとどのように書くべきかを理解できるようになっています。 まさにこのときの自分に必要なフレームワークでした。

こうしてフレームワークとともに設計技法まで習得できたわけですが、 それでも終始うまく、とはいきませんでした。

Atomic Design との出会いと別れ

少しだけ時間を遡り、Vueをやめようかと考えていた頃、Atomic Designというのが流行っていることを知ります。 これはUI要素をAtom, Molecule, Organism, Template, Pageの5つに分解して管理しようという方法論です。

Vueの構成で悩んでいた自分は早速これを取り入れて、各コンポーネントをこれらに分類しました (最初は本当に『分解』ではなく『分類』でAtomic Designっぽいものでしかなかった *2 )。 そうして、Reactに乗り換えた際にもこの構成を引き継ぎました。 しかし、このAtomic Designが曲者だったのです。

TemplateとPageは役割が明確なため問題ないのですが、残りの三つは単なる粒度であって定義が非常に曖昧です。 Atomの定義も一見明確のように思えますが、実際にやってみるとボタンの中にテキストのコンポーネントが入ったりして、 最小単位とは何かがわからなくなったりします。 他の二つも同様に境界がわからないので、結局自分で何かしらの境界を定義する羽目になります。

しかしそうなると、今度は三つに分ける意味がわかりません。 必要な境界の数に従って分ければいいはずで、それが三つである必然性がありません。 また、分割数が固定だとアプリの規模に対してスケールできないのも問題です。

別の問題としてそもそもどれにも属さないコンポーネントというのも出てきました。 単に機能を与えるためのHOCのような役割を持つコンポーネントです。 これはUI要素を前提としたAtomic Designの対象を勝手にコンポーネントに置き換えた弊害です。

……と、まあいろいろと考えた結果、 Atomic Designに無理に従うコストの方が大きいと判断し、 そのエッセンスだけを汲みながら自由に組むことにしました。

個人開発という環境のせいもあるかもしれませんが、手放した後の方がはるかに開発が楽でした。

パフォーマンスとの戦い

Reactは決して遅いフレームワークではありません。 しかし、特別速いフレームワークというわけでもありません。 仮想DOMとはいえ、結局最終的にはDOM操作なのでその遅さをどうにかすることもできません。

カスタムダイス

パフォーマンスが問題となった場所のひとつはカスタムダイスロールです。

元々10D100が限界だったのですが、友人に「13D800が振れねぇんだけど」と言われたので100D1000まで拡張することに。 結果として、最大で300個のダイスの目を100ms以内に書き換える必要に迫られました。 楽勝な数字のように見えますが、普通にやると若干オーバーします。

Reactの高速化手法の主流は枝切りです。 つまり、更新の必要のない部分を早めに検出して、その部分をスキップすることで処理時間を短縮します。

一方で今回のケースは、すべての枝で更新が必要だけれどひとつひとつの枝は短いという状況です。 こうなると仮想DOMでDOM操作を減らせないどころか、 無駄な更新検出処理その他のオーバーヘッドまでかかってくるので処理に時間がかかります。 Reactの不得手なケースです *3

このケースに関して、 最初はReactのオーバーヘッドを回避しようと直接DOM APIを呼んだりして、実際それでそれなりに速くもなったのですが、 結局はDOM操作自体が一番のボトルネックだという結論に至りました。 現在はCanvas APIとてきとーに書き下したレイアウトアルゴリズムで力技の実装をしています。 これ以上速くするならあとはもうWebGLですね……。

カスタムダイスのスクリーンショット

カルーセル

パフォーマンスが問題となったもうひとつの場所はカルーセルです。

カルーセルは回転木馬なんかを表す英語で、ステータス画面のページ切り替えに使っているUIのことです。 ページを切り替える際に新たに表示するページの初期化が必要になるため、そのタイミングに集中して負荷がかかり、 アニメーションがぎこちなくなってしまいます。 前述のカスタムダイスもそうですが、 JavaScriptによるアニメーション部分は遅延が視覚的に捉えやすいので問題となりがちです。

このケースに関してもReactの最適化はあてになりません。 なぜなら、新たに表示するページの初期化は必要な更新処理だからです。

そこで最初に考えたのは、事前に表示される可能性のあるページを初期化しておくことです。 しかし、これはいつ初期化するのかが問題となったためやめました。 ユーザが連続でページを切り替え続けた場合にアニメーションは常に動き続けるので、 初期化する暇がなくなってしまうためです。

結局、このケースではアニメーションの最大変化量の制限により負荷が目立たないようにすることで対応しました。 しかし、これはページの初期化処理の最悪時間に依存するため、 初期化に時間をかけないという負債を負う形になってしまいました。 アニメーション処理だけ別スレッドに逃がせればよかったのですが、CSSアニメーションだけではイマイチなのですよね……。

カルーセルのスクリーンショット

XXX時代

さて、ここまでが現在の話で、ここからはあるかどうかもわからない未来の話です。

Reactにはある程度満足しているのですが、Reactの思想を突き詰めていくと関数型言語に辿り着きます。 React学習初期に書いたイマイチなコードの一掃も兼ねて、関数型言語で書き直したいなという思いがあるので候補の検討について少し。

現在の候補は次の通りです。

  • Elm
  • PureScript
  • Reason

ElmはHaskellから難しい要素を取り去ってWeb用に最適化したような言語です。 ほとんどフレームワークのような言語で、Reduxの元となったアーキテクチャを持っていることで有名です。 別のプロジェクトで少し使ってみた感想としては「発想は素晴らしいけど圧倒的に未完成」という感じでした(実際まだver.1になっていないですし)。

PureScriptは一部でHaskellよりHaskellらしいと言われている言語だそうです。 Haskellに興味があるので使ってみたい気持ちはあるのですが、とにかくドキュメント等の整備が遅れている言語で、 少し触ってみたところサンプルが動かなくてどうしようもありませんでした。

ReasonはFacebook製の言語でOCamlベースみたいです。 全体的に悪くない印象なのですが、どうせなら純粋関数型言語を使ってみたいという気持ちがあるのであまり乗り気ではないです。

また、関数型言語ではないのですが、近々ReactにHooksという機能が追加され、 これで関数ベースの記述が可能になるようです。 書き換えのコストとか考えるとこれが一番無難ですね。

UI デザイン

開発の話が長くなりましたが、少しだけUIデザインについても触れておこうと思います。

今回、開発初期からずっと意識し続けてきたことは、TRPGのセッション中に操作するアプリだということです。

そのため、

  • セッション中に必要な情報や操作に素早くアクセスできること
  • 常に手元にあって不快でないこと

などを意識してデザインしました。

例えば、アプリにはいくつかページがありますが、下部のナビゲーションにはそのうち二つしか配置していません。 これは主にセッション中に操作するページがこの二つだからです。 視点を変えれば、セッション中に必要な操作をこの二つのページにまとめています。

また別の例として、常に動き続けるアニメーションというのを極力避けています。 視覚的効果として炎が揺らめくようなループアニメーションなどは魅力的なのですが、このアプリでは使っていません。 理由はセッション中に常に動いている要素があるとウザいからです。 逆にそのウザさを利用しているのがステータス画面の編集ボタンで、 「現在編集状態だから終わったら元に戻してね」という意味でループアニメーションを使っています。

……微妙なアピールポイントだなと思いつつもどこかに書いておきたかったので、それだけ。

おわりに

記事を書いてみて思ったのは「これ、面白いんだろうか?」ということです。 せめて面白げな雰囲気だけでもだそうと、少し語り口を工夫してみたのですがよけいに寒いような……。

まあ、だれも読んでくれなくても自分の開発記録ということでいいでしょう。
……という無意味な言い訳で軽く締めておきます。

*1:おそらくブログのデザイン時と思われる

*2:当時からそれは認識していたようでコミットメッセージにもAtomic Designライクと書いてある

*3:とはいえ200ms以内には終わっていたので一般的なアプリではあまり問題にならないかもしれませんが